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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) : '';
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user