Files
pvemanager/backend/app/services/script_engine.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

196 lines
9.2 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.
"""
Движок выполнения скриптов из каталога (backend/app/models/scripts.py).
Переиспользует существующую инфраструктуру:
- SSHClient (root@node) — выполнение на ноде и `pct exec` для LXC (тот же
паттерн, что и backend/app/appstore/pipeline.py: base64 -> файл -> exec,
без конкатенации пользовательского ввода в shell-команду).
- ProxmoxClient.execute_script() — выполнение на VM через QEMU guest agent
(backend/app/proxmox/client.py), уже используется в
POST /api/{server_id}/vm/{vmid}/execute-script.
Безопасность параметров: значения параметров НЕ подставляются в тело скрипта
текстово. Вместо этого перед скриптом добавляется блок `export VAR='...'` с
экранированием одинарных кавычек, скрипт читает их как обычные переменные
окружения — тот же принцип, что и .env в appstore pipeline.
"""
from __future__ import annotations
import base64
import time
from dataclasses import dataclass
from typing import Dict, Optional
from loguru import logger
from ..models import ProxmoxServer
from ..proxmox import ProxmoxClient
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):
"""Ошибка выполнения скрипта с человекочитаемым сообщением."""
@dataclass
class ExecResult:
success: bool
output: str
exit_code: int
error: Optional[str] = None
def _shell_quote(value: str) -> str:
"""Обернуть значение в одинарные кавычки, безопасно для shell."""
return "'" + str(value).replace("'", "'\\''") + "'"
def render_params(content: str, params: Dict[str, str]) -> str:
"""Добавить блок export-переменных перед телом скрипта.
Скрипт обращается к параметрам как к обычным переменным окружения
($NAME), значения никогда не конкатенируются в исполняемые команды.
"""
if not params:
return content
lines = ["#!/bin/sh", "# --- параметры, подставленные каталогом скриптов ---"]
for name, value in params.items():
# Имя переменной ограничено тем, что уже провалидировано схемой
# (см. api/scripts.py) — здесь только защитное экранирование значения.
lines.append(f"export {name}={_shell_quote('' if value is None else value)}")
lines.append("# --- конец параметров ---")
return "\n".join(lines) + "\n" + content
def _node_ssh(server: ProxmoxServer, client: ProxmoxClient) -> SSHClient:
ssh_host = (server.ip_address or server.hostname or "").split(":")[0]
return SSHClient(
hostname=ssh_host,
username="root",
password=getattr(client, "_password", None),
timeout=30,
)
def _run(ssh: SSHClient, cmd: str, *, what: str, timeout: int = _CMD_TIMEOUT) -> str:
output, exit_code = ssh.execute(cmd, return_exit_code=True, timeout=timeout)
if exit_code == -1:
raise ScriptExecError(f"{what}: таймаут (>{timeout}с) или обрыв SSH")
if exit_code != 0:
raise ScriptExecError(f"{what}: exit={exit_code}: {(output or '').strip()[:2000]}")
return output or ""
def run_on_node(server: ProxmoxServer, client: ProxmoxClient, script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт на хосте Proxmox (root@node)."""
ssh = _node_ssh(server, client)
if not ssh.connect():
return ExecResult(success=False, output="", exit_code=-1,
error=f"Не удалось подключиться по SSH к {server.name}")
b64 = base64.b64encode(script.encode("utf-8")).decode("ascii")
tmp = f"/tmp/pve_script_{abs(hash(script)) % 100000}_{int(time.time())}"
try:
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp} && chmod +x {tmp}",
what="write script on node")
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))
finally:
try:
ssh.execute(f"rm -f {tmp}", timeout=10)
except Exception:
pass
def run_on_lxc(server: ProxmoxServer, client: ProxmoxClient, node: str, vmid: int,
script: str, interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт внутри LXC через `pct exec` (SSH ноды, нет guest agent для LXC)."""
ssh = _node_ssh(server, client)
if not ssh.connect():
return ExecResult(success=False, output="", exit_code=-1,
error=f"Не удалось подключиться по SSH к {server.name}")
b64 = base64.b64encode(script.encode("utf-8")).decode("ascii")
tmp_node = f"/tmp/pve_script_{vmid}_{int(time.time())}"
tmp_ct = f"/tmp/pve_script_{vmid}.sh"
try:
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp_node}",
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} -- 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)
except ScriptExecError as e:
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
finally:
try:
ssh.execute(f"rm -f {tmp_node}", timeout=10)
ssh.execute(f"pct exec {vmid} -- rm -f {tmp_ct}", timeout=10)
except Exception:
pass
def run_on_qemu(client: ProxmoxClient, node: str, vmid: int, script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт внутри VM через QEMU guest agent."""
result = client.execute_script(node, vmid, script, interpreter=interpreter, timeout=timeout)
if not result.get("success"):
return ExecResult(
success=False, output=result.get("stdout", "") or "",
exit_code=result.get("exit_code", -1),
error=result.get("error") or result.get("stderr"),
)
return ExecResult(
success=result.get("exit_code", 0) == 0,
output=result.get("stdout", "") or "",
exit_code=result.get("exit_code", 0),
error=result.get("stderr") or None,
)
def execute(server: ProxmoxServer, *, target_type: str, node: Optional[str],
vmid: Optional[int], vm_type: Optional[str], script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Точка входа: выполнить скрипт на нужной цели.
target_type: "node" (хост Proxmox) | "guest" (VM/LXC).
vm_type (только для guest): "qemu" | "lxc".
"""
try:
client = ProxmoxClient.from_server(server)
except Exception as e:
return ExecResult(success=False, output="", exit_code=-1,
error=f"Не удалось создать Proxmox-клиент: {e}")
try:
if target_type == "node":
return run_on_node(server, client, script, interpreter, timeout)
if target_type == "guest":
if vmid is None or not node:
return ExecResult(success=False, output="", exit_code=-1,
error="Для guest-скрипта нужны node и vmid")
if vm_type == "lxc":
return run_on_lxc(server, client, node, vmid, script, interpreter, timeout)
return run_on_qemu(client, node, vmid, script, interpreter, timeout)
return ExecResult(success=False, output="", exit_code=-1,
error=f"Неизвестный target_type: {target_type}")
except Exception as e:
logger.error(f"[script_engine] execute failed: {e}")
return ExecResult(success=False, output="", exit_code=-1, error=str(e))