Files
pvemanager/backend/app/api/scripts/executions.py
markmorado 0b0b11c689 feat(scripts): run script executions in background and poll for completion
Community scripts (build.func) install packages like Java/OpenSearch and
can take minutes, well past the nginx/proxy HTTP timeout. execute_script
now returns immediately with status "running" and finishes the job on a
background thread with its own DB session; the frontend polls
/api/scripts/executions/{id} every 3s while running.

Also set TERM=xterm for script execution over SSH — community scripts
call `clear` and print colored output, which fails with "TERM
environment variable not set" when run without a PTY.
2026-07-23 21:59:18 +05:00

177 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Запуск скриптов и журнал выполнений.
POST /api/scripts/{id}/execute — запустить скрипт
GET /api/scripts/executions — история запусков текущего пользователя (админ — все)
GET /api/scripts/executions/{id} — детали запуска (вывод, exit code)
"""
from __future__ import annotations
import threading
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...auth import PermissionChecker
from ...db import SessionLocal, get_db
from ...models import ProxmoxServer, ScriptCatalog, ScriptExecution, User
from ...services import script_engine
from ...config import utcnow
router = APIRouter()
_MAX_OUTPUT = 200_000 # см. SCRIPT_EXECUTION_OUTPUT_LIMIT в models/scripts.py
def _run_execution_bg(execution_id: int, server_id: int, target_type: str,
node: Optional[str], vmid: Optional[int],
vm_type: Optional[str], rendered: str,
interpreter: str, timeout: int) -> None:
"""Фоновое выполнение скрипта: community-скрипты (build.func) идут минуты и
не помещаются в таймаут прокси/HTTP-запроса. Эндпоинт возвращает execution
со статусом `running` сразу, а результат дописывается сюда; фронтенд поллит
статус до завершения. Своя DB-сессия — сессия запроса уже закрыта.
"""
db = SessionLocal()
try:
server = db.get(ProxmoxServer, server_id)
execution = db.get(ScriptExecution, execution_id)
if not server or not execution:
logger.error(f"[scripts] bg exec {execution_id}: server/execution пропал")
return
try:
result = script_engine.execute(
server, target_type=target_type, node=node, vmid=vmid,
vm_type=vm_type, script=rendered, interpreter=interpreter,
timeout=timeout,
)
execution.status = "success" if result.success else "failed"
execution.exit_code = result.exit_code
execution.output = (result.output or "")[:_MAX_OUTPUT]
execution.error_text = result.error
except Exception as e: # защита фонового потока: не оставляем running навсегда
logger.exception(f"[scripts] bg exec {execution_id} failed: {e}")
execution.status = "failed"
execution.exit_code = -1
execution.error_text = str(e)
execution.finished_at = utcnow()
db.commit()
finally:
db.close()
class ExecuteRequest(BaseModel):
server_id: int
node: Optional[str] = None
vmid: Optional[int] = None
vm_type: Optional[str] = Field(default=None, pattern="^(qemu|lxc)$")
params: dict = {}
# Верхняя граница поднята под долгие community-скрипты (build.func ставит
# Java/OpenSearch/MongoDB и т.п. — это минуты). Выполнение фоновое, так что
# длинный таймаут не держит HTTP-запрос. 0 → взять дефолт по типу скрипта.
timeout: int = Field(default=0, ge=0, le=3600)
# Дефолтные таймауты выполнения по источнику скрипта (сек), когда клиент не
# задал timeout явно (req.timeout == 0). Community-скрипты создают LXC и тянут
# образы — им нужен большой запас; обычные скрипты остаются короткими.
_DEFAULT_TIMEOUT = 60
_COMMUNITY_TIMEOUT = 1800
def _can_admin(user: User) -> bool:
return bool(getattr(user, "is_admin", False)) or user.has_permission("script:manage")
@router.post("/api/scripts/{script_id}/execute")
def execute_script(
script_id: int,
req: ExecuteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:execute")),
):
script = db.get(ScriptCatalog, script_id)
if not script:
raise HTTPException(status_code=404, detail="Скрипт не найден")
server = db.get(ProxmoxServer, req.server_id)
if not server:
raise HTTPException(status_code=404, detail="Сервер не найден")
if script.target_type == "guest" and (req.vmid is None or not req.node or not req.vm_type):
raise HTTPException(status_code=400, detail="Для guest-скрипта нужны node, vmid и vm_type")
# Валидация обязательных параметров по схеме скрипта.
schema = script.params_schema or []
params = dict(req.params or {})
for field in schema:
name = field.get("name")
if not name:
continue
if name not in params or params[name] in (None, ""):
if field.get("required"):
raise HTTPException(status_code=400, detail=f"Параметр '{name}' обязателен")
if field.get("default") is not None:
params[name] = field["default"]
params = {k: str(v) for k, v in params.items() if k in {f.get("name") for f in schema}}
execution = ScriptExecution(
script_id=script.id, script_name=script.name,
target_type=script.target_type, server_id=server.id,
node=req.node, vmid=req.vmid, params_used=params,
status="running", user_id=current_user.id,
)
db.add(execution)
db.commit()
db.refresh(execution)
rendered = script_engine.render_params(script.content, params)
# Эффективный таймаут: явное значение клиента, иначе дефолт по источнику
# (community-скрипты идут минуты — см. _COMMUNITY_TIMEOUT).
is_community = getattr(script, "source", None) == "community-scripts"
timeout = req.timeout or (_COMMUNITY_TIMEOUT if is_community else _DEFAULT_TIMEOUT)
# Выполнение — в фоновом потоке: долгие community-скрипты (build.func) не
# укладываются в таймаут HTTP/прокси (nginx рвёт на 60с → 504). Возвращаем
# execution со статусом `running`, фронтенд поллит его до завершения.
threading.Thread(
target=_run_execution_bg,
args=(execution.id, server.id, script.target_type, req.node, req.vmid,
req.vm_type, rendered, script.interpreter, timeout),
daemon=True,
).start()
return execution.to_dict()
@router.get("/api/scripts/executions")
def list_executions(
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:view")),
):
q = db.query(ScriptExecution)
if not _can_admin(current_user):
q = q.filter(ScriptExecution.user_id == current_user.id)
items = q.order_by(ScriptExecution.started_at.desc()).limit(min(limit, 200)).all()
return {"items": [e.to_dict() for e in items]}
@router.get("/api/scripts/executions/{execution_id}")
def get_execution(
execution_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:view")),
):
execution = db.get(ScriptExecution, execution_id)
if not execution:
raise HTTPException(status_code=404, detail="Запуск не найден")
if not _can_admin(current_user) and execution.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Нет доступа к этому запуску")
return execution.to_dict()