"""Script execution API endpoints.""" import asyncio import logging import re import subprocess import time from concurrent.futures import ThreadPoolExecutor from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from ..auth import verify_token from ..config import ScriptConfig, settings from ..config_manager import config_manager from ..services.websocket_manager import ws_manager router = APIRouter(prefix="/api/scripts", tags=["scripts"]) # Dedicated executor for script/subprocess execution (avoids blocking the default pool) _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script") logger = logging.getLogger(__name__) class ScriptExecuteRequest(BaseModel): """Request model for script execution with optional arguments.""" args: list[str] = Field(default_factory=list, description="Additional arguments") class ScriptExecuteResponse(BaseModel): """Response model for script execution.""" success: bool script: str exit_code: int | None = None stdout: str = "" stderr: str = "" error: str | None = None execution_time: float | None = None class ScriptInfo(BaseModel): """Information about an available script.""" name: str label: str command: str description: str icon: str | None = None timeout: int @router.get("/list") async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]: """List all available scripts. Returns: List of available scripts with their descriptions """ return [ ScriptInfo( name=name, label=config.label or name.replace("_", " ").title(), command=config.command, description=config.description, icon=config.icon, timeout=config.timeout, ) for name, config in settings.scripts.items() ] @router.post("/execute/{script_name}") async def execute_script( script_name: str, request: ScriptExecuteRequest | None = None, _: str = Depends(verify_token), ) -> ScriptExecuteResponse: """Execute a pre-defined script by name. Args: script_name: Name of the script to execute (must be defined in config) request: Optional arguments to pass to the script Returns: Execution result including stdout, stderr, and exit code """ # Check if script exists if script_name not in settings.scripts: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.", ) script_config = settings.scripts[script_name] args = request.args if request else [] logger.info(f"Executing script: {script_name}") try: # Build command command = script_config.command if args: # Append arguments to command command = f"{command} {' '.join(args)}" # Execute in dedicated thread pool to not block the default executor loop = asyncio.get_event_loop() result = await loop.run_in_executor( _script_executor, lambda: _run_script( command=command, timeout=script_config.timeout, shell=script_config.shell, working_dir=script_config.working_dir, ), ) return ScriptExecuteResponse( success=result["exit_code"] == 0, script=script_name, exit_code=result["exit_code"], stdout=result["stdout"], stderr=result["stderr"], execution_time=result.get("execution_time"), ) except Exception as e: logger.error(f"Script execution error: {e}") return ScriptExecuteResponse( success=False, script=script_name, error=str(e), ) def _run_script( command: str, timeout: int, shell: bool, working_dir: str | None, ) -> dict[str, Any]: """Run a script synchronously. Args: command: Command to execute timeout: Timeout in seconds shell: Whether to run in shell working_dir: Working directory Returns: Dict with exit_code, stdout, stderr, execution_time """ start_time = time.time() try: result = subprocess.run( command, shell=shell, cwd=working_dir, capture_output=True, text=True, timeout=timeout, ) execution_time = time.time() - start_time return { "exit_code": result.returncode, "stdout": result.stdout[:10000], # Limit output size "stderr": result.stderr[:10000], "execution_time": execution_time, } except subprocess.TimeoutExpired: execution_time = time.time() - start_time return { "exit_code": -1, "stdout": "", "stderr": f"Script timed out after {timeout} seconds", "execution_time": execution_time, } except Exception as e: execution_time = time.time() - start_time return { "exit_code": -1, "stdout": "", "stderr": str(e), "execution_time": execution_time, } # Script management endpoints class ScriptCreateRequest(BaseModel): """Request model for creating or updating a script.""" command: str = Field(..., description="Command to execute", min_length=1) label: str | None = Field(default=None, description="User-friendly label") description: str = Field(default="", description="Script description") icon: str | None = Field(default=None, description="Custom MDI icon (e.g., 'mdi:power')") timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) working_dir: str | None = Field(default=None, description="Working directory") shell: bool = Field(default=True, description="Run command in shell") def _validate_script_name(name: str) -> None: """Validate script name. Args: name: Script name to validate. Raises: HTTPException: If name is invalid. """ if not name: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Script name cannot be empty", ) if not re.match(r"^[a-zA-Z0-9_]+$", name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Script name must contain only alphanumeric characters and underscores", ) if len(name) > 64: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Script name must be 64 characters or less", ) @router.post("/create/{script_name}") async def create_script( script_name: str, request: ScriptCreateRequest, _: str = Depends(verify_token), ) -> dict[str, Any]: """Create a new script. Args: script_name: Name for the new script (alphanumeric + underscore only). request: Script configuration. Returns: Success response with script name. Raises: HTTPException: If script already exists or name is invalid. """ # Validate name _validate_script_name(script_name) # Check if script already exists if script_name in settings.scripts: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.", ) # Create script config script_config = ScriptConfig(**request.model_dump()) # Add to config file and in-memory try: config_manager.add_script(script_name, script_config) except Exception as e: logger.error(f"Failed to add script '{script_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to add script: {str(e)}", ) # Notify WebSocket clients await ws_manager.broadcast_scripts_changed() logger.info(f"Script '{script_name}' created successfully") return {"success": True, "script": script_name} @router.put("/update/{script_name}") async def update_script( script_name: str, request: ScriptCreateRequest, _: str = Depends(verify_token), ) -> dict[str, Any]: """Update an existing script. Args: script_name: Name of the script to update. request: Updated script configuration. Returns: Success response with script name. Raises: HTTPException: If script does not exist. """ # Validate name _validate_script_name(script_name) # Check if script exists if script_name not in settings.scripts: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.", ) # Create updated script config script_config = ScriptConfig(**request.model_dump()) # Update config file and in-memory try: config_manager.update_script(script_name, script_config) except Exception as e: logger.error(f"Failed to update script '{script_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update script: {str(e)}", ) # Notify WebSocket clients await ws_manager.broadcast_scripts_changed() logger.info(f"Script '{script_name}' updated successfully") return {"success": True, "script": script_name} @router.delete("/delete/{script_name}") async def delete_script( script_name: str, _: str = Depends(verify_token), ) -> dict[str, Any]: """Delete a script. Args: script_name: Name of the script to delete. Returns: Success response with script name. Raises: HTTPException: If script does not exist. """ # Validate name _validate_script_name(script_name) # Check if script exists if script_name not in settings.scripts: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Script '{script_name}' not found", ) # Delete from config file and in-memory try: config_manager.delete_script(script_name) except Exception as e: logger.error(f"Failed to delete script '{script_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete script: {str(e)}", ) # Notify WebSocket clients await ws_manager.broadcast_scripts_changed() logger.info(f"Script '{script_name}' deleted successfully") return {"success": True, "script": script_name}