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.
This commit is contained in:
markmorado
2026-07-23 21:59:18 +05:00
parent 761bd7bca8
commit 0b0b11c689
4 changed files with 86 additions and 19 deletions

View File

@@ -8,14 +8,16 @@
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 get_db
from ...db import SessionLocal, get_db
from ...models import ProxmoxServer, ScriptCatalog, ScriptExecution, User
from ...services import script_engine
from ...config import utcnow
@@ -25,13 +27,60 @@ 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 = {}
timeout: int = Field(default=60, ge=1, le=600)
# Верхняя граница поднята под долгие 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:
@@ -81,19 +130,21 @@ def execute_script(
db.refresh(execution)
rendered = script_engine.render_params(script.content, params)
result = script_engine.execute(
server, target_type=script.target_type, node=req.node, vmid=req.vmid,
vm_type=req.vm_type, script=rendered, interpreter=script.interpreter,
timeout=req.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
execution.finished_at = utcnow()
db.commit()
db.refresh(execution)
# Эффективный таймаут: явное значение клиента, иначе дефолт по источнику
# (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()

View File

@@ -30,6 +30,13 @@ from ..ssh_client import SSHClient
_CMD_TIMEOUT = 60
# Community-скрипты (build.func и т.п.) вызывают `clear` и печатают цветной
# вывод, которым нужен установленный TERM. Панель запускает скрипт без PTY
# (get_pty=False), поэтому TERM не наследуется — и `clear` падает с
# "TERM environment variable not set", обрывая весь скрипт. Задаём безопасный
# дефолт префиксом команды (надёжнее env paramiko: sshd AcceptEnv его режет).
_SCRIPT_ENV = "TERM=xterm"
class ScriptExecError(RuntimeError):
"""Ошибка выполнения скрипта с человекочитаемым сообщением."""
@@ -97,7 +104,8 @@ def run_on_node(server: ProxmoxServer, client: ProxmoxClient, script: str,
try:
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp} && chmod +x {tmp}",
what="write script on node")
output, exit_code = ssh.execute(f"{interpreter} {tmp}", return_exit_code=True, timeout=timeout)
output, exit_code = ssh.execute(f"{_SCRIPT_ENV} {interpreter} {tmp}",
return_exit_code=True, timeout=timeout)
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
except ScriptExecError as e:
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
@@ -124,7 +132,7 @@ def run_on_lxc(server: ProxmoxServer, client: ProxmoxClient, node: str, vmid: in
what="write script on node")
_run(ssh, f"pct push {vmid} {tmp_node} {tmp_ct}", what="pct push script")
output, exit_code = ssh.execute(
f"pct exec {vmid} -- {interpreter} {tmp_ct}",
f"pct exec {vmid} -- env {_SCRIPT_ENV} {interpreter} {tmp_ct}",
return_exit_code=True, timeout=timeout,
)
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)

View File

@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { useServers, useNodes } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import { useExecuteScript } from '@/hooks/use-scripts';
import { useExecuteScript, useScriptExecution } from '@/hooks/use-scripts';
import type { ScriptCatalogItem } from '@/types/scripts';
import type { ScriptExecution } from '@/types/scripts';
@@ -29,7 +29,13 @@ export default function ScriptExecuteDialog({ script, onOpenChange }: Props) {
const [node, setNode] = useState('');
const [guestKey, setGuestKey] = useState(''); // `${server_id}:${vmid}:${type}`
const [paramValues, setParamValues] = useState<Record<string, string>>({});
const [result, setResult] = useState<ScriptExecution | null>(null);
const [executionId, setExecutionId] = useState<number | null>(null);
// Выполнение фоновое: запуск возвращает execution со статусом `running`,
// дальше поллим его до завершения (см. useScriptExecution).
const { data: liveExec } = useScriptExecution(executionId ?? 0);
const result: ScriptExecution | null = liveExec ?? null;
const isRunning = result?.status === 'running';
const serverIdNum = serverId ? Number(serverId) : 0;
const { data: nodesData } = useNodes(serverIdNum || 0);
@@ -42,7 +48,7 @@ export default function ScriptExecuteDialog({ script, onOpenChange }: Props) {
useEffect(() => {
if (!open) return;
setServerId(''); setNode(''); setGuestKey(''); setResult(null);
setServerId(''); setNode(''); setGuestKey(''); setExecutionId(null);
const defaults: Record<string, string> = {};
for (const p of script?.params_schema || []) {
defaults[p.name] = p.default != null ? String(p.default) : '';

View File

@@ -146,5 +146,7 @@ export function useScriptExecution(id: number) {
queryKey: scriptKeys.execution(id),
queryFn: () => apiClient.get<ScriptExecution>(`/api/scripts/executions/${id}`),
enabled: id > 0,
// Выполнение фоновое: пока статус `running`, поллим каждые 3с до завершения.
refetchInterval: (query) => (query.state.data?.status === 'running' ? 3000 : false),
});
}