Files
pvemanager/backend/app/services/appstore_engine.py
markmorado 4621ffe88a
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s
fix(appstore): data files, надёжность установки/удаления и сворачиваемые диалоги
Доставка файлов приложения из каталога (data_files) до compose up,
APP_PASSWORD/default_credentials для Umbrel, сворачивание диалогов
долгих операций (MinimizableDialog), фильтрация pull-шума в ошибках.

Исправления багов:
- teardown: отсутствующий в Proxmox CT считается удалённым — orphaned-приложение
  теперь можно удалить из БД
- install: сдвиг VMID мимо занятых записей (nextid не резервирует номер,
  retry мог подхватить чужой контейнер)
- retry: параметры установки сохраняются в installed_apps.install_params
  (cores/memory/disk/storage/bridge/ip_config/port_explicit, миграция JSONB)
  вместо жёстких дефолтов
- reconcile: недоступный сервер больше не помечает все приложения orphaned
- update: реально опубликованный порт фиксируется в БД (url/health)
- .env: значения с '#' кавычатся (dotenv-парсер обрезал их как inline-комментарий)
- порт 80, выбранный явно в мастере, публикуется как есть
- useCatalog: available_only включён в queryKey (коллизия кэша)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:52:14 +05:00

896 lines
40 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.
"""
App Engine (M2) — жизненный цикл приложения поверх пайплайна M0.
Установка: клон золотого шаблона → pct push compose/.env → docker compose up →
health-check, с журналом шагов (app_operations), статусами (installed_apps),
шифрованием env-секретов (Fernet) и прогрессом по WebSocket.
Переиспуёт app/appstore/pipeline.py (poc_install / poc_teardown).
"""
from __future__ import annotations
import json
import re
import secrets as _secrets
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Optional
from loguru import logger
from apscheduler.triggers.interval import IntervalTrigger
from ..appstore.pipeline import (
APP_DIR, APP_DATA_DIR, InstallSpec, PocError, _NAME_RE, _node_ssh, _shq,
poc_install, poc_teardown, poc_update,
)
from ..appstore.golden_template import GoldenSpec, build_golden_template
from ..api.proxmox._helpers import _get_proxmox_client, get_next_vmid, soft_delete_vm_instance
from ..api.settings import get_setting, set_setting
from ..config import settings
from ..db import SessionLocal
from ..ipam_service import IPAMService
from ..models import AppOperation, CatalogApp, InstalledApp, IPAMNetwork, ProxmoxServer
from ..websocket_manager import broadcast_task_update
_engine_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="appstore_engine")
class EngineError(RuntimeError):
"""Ошибка бизнес-валидации движка (человекочитаемая)."""
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ── Журнал операции + WS ──────────────────────────────────────────────────────
def _record_step(operation_id: int, user_id: int, step: str, progress: int) -> None:
"""Добавить шаг в журнал операции и разослать по WebSocket (своя сессия)."""
db = SessionLocal()
try:
op = db.get(AppOperation, operation_id)
if not op:
return
log = list(op.steps_log or [])
log.append({"step": step, "progress": progress, "ts": _utcnow().isoformat()})
op.steps_log = log
op.progress = progress
db.commit()
try:
broadcast_task_update(user_id, "appstore_operation_update", op.to_dict())
except Exception:
pass
finally:
db.close()
def _finish_op(operation_id: int, user_id: int, status: str, error: Optional[str] = None) -> None:
db = SessionLocal()
try:
op = db.get(AppOperation, operation_id)
if not op:
return
op.status = status
op.finished_at = _utcnow()
if error:
op.error_text = error[:2000]
if status == "completed":
op.progress = 100
db.commit()
try:
broadcast_task_update(user_id, "appstore_operation_update", op.to_dict())
except Exception:
pass
finally:
db.close()
# ── Генерация env (ТЗ 5.3 + 6.2) ──────────────────────────────────────────────
def _generate_random(length: int) -> str:
length = max(8, int(length or 32))
# hex → безопасно для .env (нет спецсимволов), длина = length
return _secrets.token_hex(length // 2 + 1)[:length]
def build_env(app: CatalogApp, form_answers: dict) -> tuple[dict, dict]:
"""Собрать .env: платформенные переменные + ответы формы + random-секреты.
Возвращает (env, secrets_shown) — secrets_shown показываются пользователю один раз.
"""
form_answers = form_answers or {}
env = {
"APP_PORT": str(app.port or 80),
"APP_DATA_DIR": APP_DATA_DIR,
"APP_PROTOCOL": "http",
"APP_EXPOSED": "false",
"APP_ID": app.app_id,
"TZ": settings.TZ,
"ROOT_FOLDER_HOST": APP_DATA_DIR,
}
secrets_shown: dict = {}
# Umbrel: рантайм генерирует APP_PASSWORD (app password) сам, а compose
# ссылается на неё для паролей/ключей (ENCRYPTION_KEY, JWT_SECRET и т.п.).
# Без неё docker compose подставит пустую строку и приложение упадёт или
# останется без пароля. Генерируем здесь, чтобы значение сохранилось в
# env_encrypted и показалось пользователю один раз.
if (app.source or "runtipi") == "umbrel":
env["APP_PASSWORD"] = _generate_random(32)
secrets_shown["APP_PASSWORD"] = env["APP_PASSWORD"]
for field in (app.form_fields or []):
ev = field.get("env_variable")
if not ev:
continue
ftype = (field.get("type") or "text").lower()
if ftype == "random":
val = _generate_random(field.get("min") or 32)
env[ev] = val
secrets_shown[ev] = val
continue
val = form_answers.get(ev)
if val is None or val == "":
if field.get("required"):
raise EngineError(f"Поле '{field.get('label') or ev}' обязательно")
val = field.get("default", "")
if ftype == "boolean":
val = "true" if str(val).lower() in ("1", "true", "yes", "on") else "false"
val = str(val)
env[ev] = val
if ftype == "password":
secrets_shown[ev] = val
return env, secrets_shown
# ── Золотой шаблон (Workstream B) ─────────────────────────────────────────────
# Ключ PanelSettings, куда сохраняется volid собранного золотого шаблона.
_GOLDEN_SETTING_KEY = "appstore_golden_template"
def _golden_key(server_id: int) -> str:
return f"{_GOLDEN_SETTING_KEY}:{server_id}"
def _resolve_golden_template(db, server_id: int, ostemplate: Optional[str] = None) -> Optional[str]:
"""Определить golden vztmpl для конкретного сервера.
Приоритет: явный override → сохранённый для ЭТОГО server_id → старый общий
ключ (обратная совместимость с настройкой до per-server хранения) →
env-дефолт. vztmpl — файл на storage конкретного Proxmox-сервера, поэтому
шаблон, собранный на сервере A, не существует на сервере B — нельзя просто
отдавать один и тот же сохранённый volid для всех серверов.
"""
return (
ostemplate
or get_setting(db, _golden_key(server_id))
or get_setting(db, _GOLDEN_SETTING_KEY)
or settings.APPSTORE_GOLDEN_TEMPLATE
)
def prepare_golden_template(db, user, *, server_id: int, node: Optional[str] = None,
template_storage: str = "local",
rootfs_storage: str = "local-lvm",
bridge: str = "vmbr0", force: bool = False) -> dict:
"""Запустить сборку золотого шаблона в фоне. Возвращает operation_id."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise EngineError("Proxmox сервер не найден")
op = _new_operation(db, None, "golden_template", user.id, server_id=server_id)
_engine_executor.submit(
_run_golden_template, op.id, user.id, server_id, node,
template_storage, rootfs_storage, bridge, force,
)
return {"operation_id": op.id}
def _run_golden_template(operation_id, user_id, server_id, node,
template_storage, rootfs_storage, bridge, force):
db = SessionLocal()
try:
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
spec = GoldenSpec(
server_id=server_id, node=node, template_storage=template_storage,
rootfs_storage=rootfs_storage, bridge=bridge, force=force,
)
result = build_golden_template(db, spec, on_step)
# Сохраняем volid ПОД ЭТИМ СЕРВЕРОМ — install() на других серверах не
# должен получить чужой (недоступный на их storage) шаблон.
try:
set_setting(db, _golden_key(server_id), result.volid,
description=f"App Store: золотой LXC-шаблон сервера #{server_id}")
except Exception as e:
logger.warning(f"[appstore-engine] save golden template setting: {e}")
_finish_op(operation_id, user_id, "completed")
try:
broadcast_task_update(user_id, "appstore_golden_template_done",
{"volid": result.volid, "node": result.node,
"status": result.status, "server_id": server_id})
except Exception:
pass
except Exception as e:
logger.error(f"[appstore-engine] golden template failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
def golden_template_status(db, server_id: int) -> dict:
"""Текущий шаблон конкретного сервера (БД/env) + последняя операция сборки на нём."""
op = (
db.query(AppOperation)
.filter(AppOperation.type == "golden_template", AppOperation.server_id == server_id)
.order_by(AppOperation.started_at.desc())
.first()
)
return {
"configured_template": _resolve_golden_template(db, server_id),
"env_template": settings.APPSTORE_GOLDEN_TEMPLATE,
"saved_template": get_setting(db, _golden_key(server_id)),
"last_operation": op.to_dict() if op else None,
}
# ── Установка ─────────────────────────────────────────────────────────────────
def _allocate_ipam(db, *, network_id: int, pool_id: Optional[int], name: str,
server_id: int, vmid: int, node: Optional[str],
allocated_by: Optional[str]) -> tuple[str, str]:
"""Выделить IP из IPAM для приложения. Возвращает (ip_config, static_ip).
ip_config — строка для net0 ("ip=<addr>/<mask>,gw=<gw>"). Бросает EngineError
при отсутствии свободного адреса, чтобы ошибка сразу вернулась пользователю.
"""
ipam = IPAMService(db)
allocation, error = ipam.auto_allocate_ip(
network_id=network_id, pool_id=pool_id,
resource_type="ct", resource_name=name,
proxmox_server_id=server_id, proxmox_vmid=vmid, proxmox_node=node,
allocated_by=allocated_by, notes=f"App Store: {name}",
)
if error or not allocation:
raise EngineError(f"Не удалось выделить IP из IPAM: {error or 'нет свободных адресов'}")
ip = allocation.ip_address
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
mask = "24"
gateway = None
if net:
if net.network and "/" in net.network:
mask = net.network.split("/")[1]
gateway = net.gateway or None
ip_config = f"ip={ip}/{mask},gw={gateway}" if gateway else f"ip={ip}/{mask}"
return ip_config, ip
def install(db, user, *, app_id: str, name: str, form_answers: dict,
server_id: int, node: Optional[str] = None, cores: int = 2,
memory: int = 2048, disk: int = 8, storage: str = "local-lvm",
bridge: str = "vmbr0", ostemplate: Optional[str] = None,
port: Optional[int] = None,
ipam_network_id: Optional[int] = None,
ipam_pool_id: Optional[int] = None) -> dict:
"""Синхронная часть: валидация + запись в БД. Пайплайн — в пуле потоков."""
app = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
if not app:
raise EngineError("Приложение не найдено в каталоге")
if not app.available:
raise EngineError("Приложение помечено недоступным")
if not app.compose_yaml:
raise EngineError("У приложения отсутствует docker-compose.yml")
if not _NAME_RE.match(name or ""):
raise EngineError("Недопустимое имя приложения (a-z, 0-9, дефис, 1..63)")
golden = _resolve_golden_template(db, server_id, ostemplate)
if not golden:
raise EngineError("Не настроен золотой шаблон LXC — подготовьте его "
"(App Store → золотой шаблон) или задайте APPSTORE_GOLDEN_TEMPLATE")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise EngineError("Proxmox сервер не найден")
# Кастомный порт из формы имеет приоритет над портом каталога (app.port).
if port is not None and not (1 <= port <= 65535):
raise EngineError("Порт должен быть в диапазоне 1..65535")
effective_port = port if port else app.port
env, secrets_shown = build_env(app, form_answers)
# Учётные данные по умолчанию из манифеста (Umbrel) — показываем сразу
# после установки: у многих приложений это единственный способ узнать
# логин/пароль админа. deterministic → пароль = APP_PASSWORD (см. build_env).
creds = app.default_credentials or {}
if creds.get("username"):
secrets_shown["USERNAME"] = creds["username"]
default_pwd = env.get("APP_PASSWORD") if creds.get("deterministic") else creds.get("password")
if default_pwd:
secrets_shown["PASSWORD"] = default_pwd
# APP_PORT в окружении должен соответствовать выбранному порту.
if port:
env["APP_PORT"] = str(port)
vmid = get_next_vmid(db, server_id)
# Proxmox nextid не резервирует номер: пока предыдущая установка ещё не
# создала CT, nextid возвращает тот же VMID. Вторая установка тогда падает
# на create, а её retry (идемпотентность по vmid) «подхватывает» ЧУЖОЙ
# контейнер и заливает в него свой compose. Сдвигаемся мимо VMID, уже
# занятых записями installed_apps этого сервера.
taken = {
v for (v,) in db.query(InstalledApp.vmid)
.filter(InstalledApp.server_id == server_id, InstalledApp.vmid.isnot(None))
.all()
}
while vmid in taken:
vmid += 1
# IPAM (опционально): выделяем адрес до старта пайплайна, чтобы ошибка
# (нет свободных IP) вернулась пользователю синхронно.
ip_config = None
static_ip = None
if ipam_network_id:
ip_config, static_ip = _allocate_ipam(
db, network_id=ipam_network_id, pool_id=ipam_pool_id, name=name,
server_id=server_id, vmid=vmid, node=node,
allocated_by=getattr(user, "username", None),
)
ia = InstalledApp(
app_id=app_id, server_id=server_id, vmid=vmid, node=node, name=name,
version_installed=str(app.version) if app.version is not None else None,
tipi_version_installed=app.tipi_version,
env_encrypted=json.dumps(env), ip=static_ip, port=effective_port, status="installing",
owner_id=user.id,
# Параметры создания CT — для повтора установки: если контейнер так и
# не был создан, retry должен пересоздать его с ТЕМИ ЖЕ ресурсами,
# storage/bridge и статическим IP из IPAM, а не с дефолтами.
install_params={
"cores": cores, "memory": memory, "disk": disk,
"storage": storage, "bridge": bridge, "ip_config": ip_config,
"port_explicit": bool(port),
},
)
db.add(ia)
db.commit()
db.refresh(ia)
op = _new_operation(db, ia.id, "install", user.id)
_engine_executor.submit(
_run_install, ia.id, op.id, user.id, app.compose_yaml, env, golden,
cores, memory, disk, server_id, node, name, effective_port, ia.version_installed, vmid,
storage, bridge, ip_config, static_ip, app.source or "runtipi",
app.data_files or {}, bool(port),
)
return {"installed_app_id": ia.id, "operation_id": op.id, "vmid": vmid, "secrets": secrets_shown}
def _new_operation(db, installed_app_id: int, op_type: str, user_id: int,
server_id: Optional[int] = None) -> AppOperation:
op = AppOperation(
installed_app_id=installed_app_id, type=op_type, server_id=server_id,
status="running", progress=0, steps_log=[], user_id=user_id,
)
db.add(op)
db.commit()
db.refresh(op)
return op
def _run_install(installed_app_id, operation_id, user_id, compose_yaml, env, ostemplate,
cores, memory, disk, server_id, node, name, port, version, vmid,
storage, bridge, ip_config=None, static_ip=None, source="runtipi",
data_files=None, port_explicit=False):
"""Тело установки в пуле потоков."""
db = SessionLocal()
try:
spec = InstallSpec(
server_id=server_id, ostemplate=ostemplate, name=name,
compose_yaml=compose_yaml, port=port or 80, port_explicit=port_explicit,
env_vars=env,
node=node, vmid=vmid, cores=cores, memory=memory, disk=disk,
storage=storage, bridge=bridge, ip_config=ip_config, static_ip=static_ip,
source=source or "runtipi", data_files=data_files or {},
)
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
result = poc_install(db, spec, on_step)
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.vmid = result.vmid
ia.node = result.node
ia.ip = result.ip
ia.port = spec.port
ia.version_installed = version
ia.status = "running" if result.status == "running" else "error"
db.commit()
if result.status == "running":
_finish_op(operation_id, user_id, "completed")
else:
_finish_op(operation_id, user_id, "failed",
error="Health-check не прошёл (приложение не ответило по HTTP)")
except Exception as e:
try:
db.rollback()
except Exception:
pass
try:
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "error"
ia.vmid = vmid # сохранить vmid для идемпотентного повтора
db.commit()
except Exception:
pass
logger.error(f"[appstore-engine] install #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
def retry_install(db, user, installed_app_id: int) -> dict:
"""Повтор установки (ТЗ 6.1): контейнер не пересоздаётся (проверка по vmid)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if ia.status not in ("error", "installing"):
raise EngineError(f"Повтор недоступен из статуса '{ia.status}'")
app = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
compose = app.compose_yaml if app else None
if not compose:
raise EngineError("compose приложения недоступен для повтора")
golden = _resolve_golden_template(db, ia.server_id)
if not golden:
raise EngineError("Не настроен золотой шаблон LXC — подготовьте его "
"(App Store → золотой шаблон) или задайте APPSTORE_GOLDEN_TEMPLATE")
env = ia.env_dict()
ia.status = "installing"
db.commit()
op = _new_operation(db, ia.id, "install", user.id)
# Повтор идемпотентен по vmid: существующий контейнер не пересоздаётся
# (тогда params/ip_config игнорируются pайплайном). Если же CT так и не был
# создан — пересоздаём с исходными параметрами установки (install_params),
# включая статический IP из IPAM; для старых записей без install_params
# остаются прежние дефолты. Известный IP (ia.ip) передаём как static_ip,
# чтобы не ждать выдачи DHCP впустую.
params = ia.install_params or {}
_engine_executor.submit(
_run_install, ia.id, op.id, user.id, compose, env, golden,
params.get("cores", 2), params.get("memory", 2048), params.get("disk", 8),
ia.server_id, ia.node, ia.name, ia.port, ia.version_installed, ia.vmid,
params.get("storage", "local-lvm"), params.get("bridge", "vmbr0"),
params.get("ip_config"), ia.ip, (app.source or "runtipi"),
(app.data_files or {}), bool(params.get("port_explicit")),
)
return {"installed_app_id": ia.id, "operation_id": op.id}
# ── Удаление (нужно для обработки сбоя M2; полный lifecycle — M3) ──────────────
def delete_app(db, user, installed_app_id: int) -> dict:
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
op = _new_operation(db, ia.id, "delete", user.id)
ia.status = "deleting"
db.commit()
_engine_executor.submit(_run_delete, ia.id, op.id, user.id, ia.server_id, ia.vmid, ia.node)
return {"operation_id": op.id}
def _run_delete(installed_app_id, operation_id, user_id, server_id, vmid, node):
db = SessionLocal()
try:
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
if vmid and server_id:
# Не глотаем ошибку teardown: если Proxmox не подтвердил реальное
# завершение удаления (см. poc_teardown/_TEARDOWN_WAIT_TIMEOUT),
# ресурсы ноды могут быть ещё заняты — пробрасываем наверх, чтобы
# НЕ помечать установку удалённой раньше времени (иначе следующая
# установка того же приложения словит гонку блокировки VMID).
poc_teardown(db, server_id, vmid, node, on_step)
# Освобождаем IP в IPAM (no-op, если приложение ставилось по DHCP).
try:
IPAMService(db).release_ip_by_vmid(
server_id, vmid, released_by="appstore",
reason=f"App Store: удаление приложения #{installed_app_id}")
except Exception as e:
logger.warning(f"[appstore-engine] IPAM release vmid={vmid}: {e}")
# Помечаем кэш-запись инстанса удалённой сразу, чтобы контейнер
# исчез из списка Инстансов немедленно, не дожидаясь фонового воркера.
try:
soft_delete_vm_instance(db, server_id, vmid)
except Exception as e:
logger.warning(f"[appstore-engine] soft-delete vm_instance vmid={vmid}: {e}")
# финальное событие ДО удаления записи (каскад удалит app_operations)
_finish_op(operation_id, user_id, "completed")
ia = db.get(InstalledApp, installed_app_id)
if ia:
db.delete(ia)
db.commit()
try:
broadcast_task_update(user_id, "appstore_app_deleted", {"installed_app_id": installed_app_id})
except Exception:
pass
except Exception as e:
logger.error(f"[appstore-engine] delete #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
# Оставляем InstalledApp на месте (ресурсы ноды не подтверждены
# освобождёнными) — статус "error" позволяет повторить удаление позже.
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "error"
db.commit()
finally:
db.close()
# ── Обновление и откат (M4) ───────────────────────────────────────────────────
_SNAP_PREFIX = "preupd"
def _snap_name(version: Optional[str], ts: datetime) -> str:
"""Имя снапшота pre-update: буквы/цифры/подчёркивание, ≤ 40, начинается с буквы."""
ver = re.sub(r"[^A-Za-z0-9]", "", str(version or "0"))[:12] or "0"
stamp = ts.strftime("%Y%m%d%H%M%S")
return f"{_SNAP_PREFIX}_{ver}_{stamp}"[:40]
def _prune_snapshots(client, node: str, vmid: int, keep: str) -> None:
"""Оставить только последний pre-update снапшот (ТЗ 6.3), старые удалить."""
try:
snaps = client.get_container_snapshots(node, vmid)
except Exception:
return
for s in snaps or []:
name = s.get("name")
if not name or name == "current" or name == keep:
continue
if name.startswith(_SNAP_PREFIX + "_"):
try:
res = client.delete_container_snapshot(node, vmid, name)
upid = res.get("upid") if isinstance(res, dict) else None
if upid:
client.wait_for_task(node, upid, timeout=120)
except Exception as e:
logger.warning(f"[appstore-engine] prune snapshot {name}: {e}")
def update(db, user, installed_app_id: int, form_answers: Optional[dict] = None) -> dict:
"""Обновление приложения: снапшот → pull → up → health (ТЗ 6.3)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if ia.status not in ("running", "stopped", "update_failed"):
raise EngineError(f"Обновление недоступно из статуса '{ia.status}'")
if not ia.vmid or not ia.server_id or not ia.node:
raise EngineError("У приложения нет привязанного контейнера")
app = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
if not app or not app.compose_yaml:
raise EngineError("Свежая версия приложения недоступна в каталоге")
op = _new_operation(db, ia.id, "update", user.id)
_engine_executor.submit(
_run_update, ia.id, op.id, user.id, app.compose_yaml,
str(app.version) if app.version is not None else None, app.tipi_version,
form_answers or {}, (app.source or "runtipi"), (app.data_files or {}),
)
return {"installed_app_id": ia.id, "operation_id": op.id}
def _run_update(installed_app_id, operation_id, user_id, compose_yaml,
new_version, new_tipi, form_answers, source="runtipi",
data_files=None):
db = SessionLocal()
try:
ia = db.get(InstalledApp, installed_app_id)
if not ia:
_finish_op(operation_id, user_id, "failed", error="Установка исчезла")
return
server, client = _client_for(db, ia.server_id)
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
# 1. Снапшот pre-update-<version>-<timestamp>
on_step("Создание снапшота перед обновлением...", 15)
snapname = _snap_name(ia.version_installed, _utcnow())
snap = client.create_container_snapshot(
ia.node, ia.vmid, snapname, description="pvemanager-appstore pre-update")
if not (isinstance(snap, dict) and snap.get("success")):
err = snap.get("error") if isinstance(snap, dict) else "snapshot failed"
ia.status = "update_failed"
db.commit()
_finish_op(operation_id, user_id, "failed", error=f"Снапшот не создан: {err}")
return
ia.last_snapshot = snapname
ia.snapshot_version = ia.version_installed
ia.snapshot_tipi_version = ia.tipi_version_installed
ia.status = "updating"
db.commit()
# 2. env: сохраняем прежний, доливаем новые ответы формы (ТЗ 6.3)
env = ia.env_dict()
for k, v in (form_answers or {}).items():
env[k] = str(v)
# 3-4. push нового compose → pull && up → health
status, eff_port = poc_update(
server, client, ia.vmid, ia.node, compose_yaml,
env, ia.port or 80, ia.ip, on_step,
source=source or "runtipi", data_files=data_files or {},
port_explicit=bool((ia.install_params or {}).get("port_explicit")),
)
if status == "running":
ia.status = "running"
ia.version_installed = new_version
ia.tipi_version_installed = new_tipi
ia.update_available = False
ia.env_encrypted = json.dumps(env)
# Нормализация compose могла опубликовать другой порт — фиксируем
# его в БД, иначе url/health-check будут указывать не туда.
if eff_port:
ia.port = eff_port
db.commit()
_prune_snapshots(client, ia.node, ia.vmid, keep=snapname) # хранить последний 1
_finish_op(operation_id, user_id, "completed")
else:
ia.status = "update_failed"
db.commit()
_finish_op(operation_id, user_id, "failed",
error="Health-check после обновления не прошёл — доступен откат")
except Exception as e:
try:
db.rollback()
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "update_failed"
db.commit()
except Exception:
pass
logger.error(f"[appstore-engine] update #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
def rollback(db, user, installed_app_id: int) -> dict:
"""Откат обновления восстановлением снапшота (ТЗ 6.4)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if not ia.last_snapshot:
raise EngineError("Нет pre-update снапшота для отката")
if not ia.vmid or not ia.server_id or not ia.node:
raise EngineError("У приложения нет привязанного контейнера")
op = _new_operation(db, ia.id, "rollback", user.id)
_engine_executor.submit(_run_rollback, ia.id, op.id, user.id)
return {"installed_app_id": ia.id, "operation_id": op.id}
def _run_rollback(installed_app_id, operation_id, user_id):
db = SessionLocal()
try:
ia = db.get(InstalledApp, installed_app_id)
if not ia:
_finish_op(operation_id, user_id, "failed", error="Установка исчезла")
return
server, client = _client_for(db, ia.server_id)
snapname = ia.last_snapshot
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
on_step("Остановка контейнера...", 20)
try:
upid = client.stop_container(ia.node, ia.vmid)
if upid:
client.wait_for_task(ia.node, upid, timeout=60)
except Exception:
pass
on_step(f"Восстановление снапшота {snapname}...", 45)
res = client.rollback_container_snapshot(ia.node, ia.vmid, snapname, start=True)
upid = res.get("upid") if isinstance(res, dict) else None
if isinstance(res, dict) and not res.get("success"):
ia.status = "update_failed"
db.commit()
_finish_op(operation_id, user_id, "failed",
error=f"Откат не удался: {res.get('error')}")
return
if upid:
client.wait_for_task(ia.node, upid, timeout=300)
on_step("Запуск контейнера...", 70)
try:
client.start_container(ia.node, ia.vmid)
except Exception:
pass
on_step(f"Health-check http://{ia.ip}:{ia.port} ...", 88)
from ..appstore.pipeline import _health_check
ok = _health_check(ia.ip, ia.port or 80, 180) if ia.ip else False
# вернуть прежнюю версию в БД (ТЗ 6.4)
if ia.snapshot_version is not None:
ia.version_installed = ia.snapshot_version
ia.tipi_version_installed = ia.snapshot_tipi_version
ia.status = "running" if ok else "stopped"
db.commit()
_finish_op(operation_id, user_id, "completed" if ok else "failed",
error=None if ok else "Контейнер восстановлен, но health-check не прошёл")
except Exception as e:
try:
db.rollback()
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "update_failed"
db.commit()
except Exception:
pass
logger.error(f"[appstore-engine] rollback #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
# ── Управление жизненным циклом (M3): start / stop / restart / logs ───────────
def _client_for(db, server_id: int):
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise EngineError("Proxmox сервер не найден")
return server, _get_proxmox_client(server)
def lifecycle_action(db, user, installed_app_id: int, action: str) -> dict:
"""Start / stop / restart LXC приложения (ТЗ 6.5)."""
if action not in ("start", "stop", "restart"):
raise EngineError(f"Неизвестное действие: {action}")
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if not ia.vmid or not ia.server_id or not ia.node:
raise EngineError("У приложения нет привязанного контейнера")
_server, client = _client_for(db, ia.server_id)
if action == "start":
upid = client.start_container(ia.node, ia.vmid)
new_status = "running"
elif action == "stop":
upid = client.stop_container(ia.node, ia.vmid)
new_status = "stopped"
else: # restart
upid = client.restart_container(ia.node, ia.vmid)
new_status = "running"
if upid:
try:
client.wait_for_task(ia.node, upid, timeout=60)
except Exception:
pass
ia.status = new_status
db.commit()
db.refresh(ia)
try:
broadcast_task_update(user.id, "appstore_operation_update",
{"kind": "appstore", "installed_app_id": ia.id,
"type": action, "status": "completed", "progress": 100})
except Exception:
pass
return ia.to_dict()
def get_logs(db, installed_app_id: int, tail: int = 200) -> str:
"""docker compose logs --tail N через pct exec (ТЗ 6.5, по запросу)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia or not ia.vmid or not ia.server_id:
raise EngineError("У приложения нет привязанного контейнера")
server, client = _client_for(db, ia.server_id)
ssh = _node_ssh(server, client)
if not ssh.connect():
raise EngineError("SSH к ноде не установлен")
try:
tail = max(1, min(int(tail or 200), 2000))
inner = f"cd {APP_DIR} && docker compose logs --tail {tail} --no-color 2>&1"
out, _code = ssh.execute(
f"pct exec {ia.vmid} -- /bin/bash -lc {_shq(inner)}", return_exit_code=True)
return out or ""
finally:
ssh.close()
# ── Реконсиляция состояния (ТЗ 6.6) ───────────────────────────────────────────
_RECONCILE_STATES = ("running", "stopped", "orphaned")
def reconcile(db) -> dict:
"""Сверить статус LXC в Proxmox с БД. Пропавший контейнер → 'orphaned'."""
apps = db.query(InstalledApp).filter(InstalledApp.status.in_(_RECONCILE_STATES)).all()
changed = 0
for ia in apps:
if not ia.vmid or not ia.server_id or not ia.node:
continue
try:
_server, client = _client_for(db, ia.server_id)
if not client.is_connected():
# Сервер целиком недоступен (сеть/креды) — это ничего не говорит
# о судьбе контейнера; не помечать все приложения orphaned.
continue
st = client.get_container_status(ia.node, ia.vmid)
except Exception:
continue
if not st:
new_status = "orphaned"
else:
s = (st.get("status") or "").lower()
new_status = "running" if s == "running" else "stopped" if s == "stopped" else ia.status
if new_status != ia.status:
ia.status = new_status
changed += 1
if changed:
db.commit()
return {"checked": len(apps), "changed": changed}
def _scheduled_reconcile() -> None:
db = SessionLocal()
try:
reconcile(db)
except Exception as e:
logger.warning(f"[appstore-engine] scheduled reconcile failed: {e}")
finally:
db.close()
def start_reconcile_scheduler():
"""Периодическая реконсиляция каждые 60с (ТЗ 6.6). Из lifespan."""
from .backup_scheduler import get_scheduler
aps = get_scheduler()
aps.add_job(
_scheduled_reconcile,
trigger=IntervalTrigger(seconds=60),
id="appstore_reconcile",
replace_existing=True,
misfire_grace_time=30,
)
if not aps.running:
aps.start()
logger.info("App Store reconcile scheduler registered (every 60s)")
return aps