Files
pvemanager/backend/app/models/appstore.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

255 lines
13 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.
from .base import *
import json as _json
# Статусы установленного приложения (ТЗ 8)
INSTALLED_STATUSES = (
"installing", "running", "stopped", "error",
"updating", "update_failed", "orphaned", "deleting",
)
class CatalogApp(Base):
"""
Приложение из каталога App Store (импорт из runtipi/runtipi-appstore).
Наполняется Catalog Service (services/appstore_catalog.py). Публичные
метаданные — шифрование не требуется. Секреты появляются только на этапе
установки (installed_apps.env_encrypted, M2).
"""
__tablename__ = "catalog_apps"
# id приложения из runtipi (config.json → "id"), напр. "uptime-kuma".
# Для источников кроме runtipi app_id префиксуется ("umbrel-<id>"), чтобы
# не было коллизий PK между каталогами разных источников.
app_id = Column(String(100), primary_key=True, index=True)
name = Column(String(200), nullable=False)
# источник каталога: "runtipi" | "umbrel". Определяет адаптацию compose
# при установке (публикация порта, вырезание рантайм-сервисов источника).
source = Column(String(50), nullable=False, default="runtipi", index=True)
# version — строка тега docker-образа ("2", "1.23.1"); tipi_version — целочисленный
# инкремент версии рецепта (используется для update_available в M2).
version = Column(String(50), nullable=True)
tipi_version = Column(Integer, nullable=True)
categories = Column(JSON, nullable=False, default=list)
short_desc = Column(Text, nullable=True)
description_md = Column(Text, nullable=True)
# относительный путь логотипа внутри APPSTORE_DATA_DIR (напр. "logos/uptime-kuma.jpg")
logo_path = Column(String(300), nullable=True)
port = Column(Integer, nullable=True)
# массив объектов form_fields из config.json (см. ТЗ 6.2)
form_fields = Column(JSON, nullable=False, default=list)
compose_yaml = Column(Text, nullable=True)
# Вспомогательные файлы приложения из репозитория каталога, которые compose
# bind-mount'ит из ${APP_DATA_DIR} (runtipi: apps/<id>/data/**, umbrel: файлы
# папки приложения). Без них ~60% приложений получают пустые конфиги/скрипты
# и падают на старте. Формат: {relpath: {"b64": <содержимое>, "exec": bool}}.
data_files = Column(JSON, nullable=True)
# Учётные данные по умолчанию из манифеста источника (umbrel-app.yml:
# defaultUsername/defaultPassword/deterministicPassword). deterministic=true →
# реальный пароль — это сгенерированный при установке APP_PASSWORD.
# Формат: {"username": str|None, "password": str|None, "deterministic": bool}.
default_credentials = Column(JSON, nullable=True)
architectures = Column(JSON, nullable=False, default=list)
available = Column(Boolean, nullable=False, default=True, index=True)
unavailable_reason = Column(String(200), nullable=True)
deprecated = Column(Boolean, nullable=False, default=False)
# config.json → dynamic_config: приложение имеет docker-compose.json (новый формат).
# В MVP всё равно используем docker-compose.yml; флаг сохраняем на будущее (риск п.13 ТЗ).
dynamic_config = Column(Boolean, nullable=False, default=False)
source_url = Column(String(500), nullable=True)
author = Column(String(200), nullable=True)
synced_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
def to_dict(self, *, light: bool = False, with_compose: bool = False) -> dict:
data = {
"app_id": self.app_id,
"name": self.name,
"source": self.source or "runtipi",
"version": self.version,
"tipi_version": self.tipi_version,
"categories": self.categories or [],
"short_desc": self.short_desc,
"port": self.port,
"architectures": self.architectures or [],
"available": self.available,
"unavailable_reason": self.unavailable_reason,
"deprecated": self.deprecated,
"dynamic_config": self.dynamic_config,
"source_url": self.source_url,
"author": self.author,
"logo_url": f"/api/appstore/catalog/{self.app_id}/logo" if self.logo_path else None,
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
}
if not light:
# тяжёлые поля — только для страницы приложения
data["form_fields"] = self.form_fields or []
data["description_md"] = self.description_md
if with_compose:
data["compose_yaml"] = self.compose_yaml
return data
def __repr__(self):
return f"<CatalogApp(app_id='{self.app_id}', name='{self.name}', v='{self.version}')>"
class InstalledApp(Base):
"""
Установленное приложение = 1 LXC-контейнер с Docker Compose внутри (ТЗ 8).
env_encrypted — JSON-строка переменных .env (включая сгенерированные секреты),
хранится зашифрованной (Fernet) через EncryptedString.
"""
__tablename__ = "installed_apps"
id = Column(Integer, primary_key=True, index=True)
# ссылка на каталог (мягкая — приложение может исчезнуть из каталога)
app_id = Column(String(100), index=True, nullable=True)
server_id = Column(Integer, index=True, nullable=True)
vmid = Column(Integer, index=True, nullable=True)
node = Column(String(100), nullable=True)
name = Column(String(200), nullable=False) # пользовательское имя / hostname LXC
version_installed = Column(String(50), nullable=True)
tipi_version_installed = Column(Integer, nullable=True)
env_encrypted = Column(EncryptedString, nullable=True) # JSON строки .env (зашифровано)
ip = Column(String(64), nullable=True)
port = Column(Integer, nullable=True)
status = Column(String(20), nullable=False, default="installing", index=True)
update_available = Column(Boolean, nullable=False, default=False)
last_snapshot = Column(String(200), nullable=True)
# Параметры создания CT ({cores, memory, disk, storage, bridge, ip_config}) —
# чтобы повтор установки пересоздал контейнер с исходными настройками,
# а не с дефолтами (NULL у записей, созданных до появления колонки).
install_params = Column(JSON, nullable=True)
# версия/tipi_version, зафиксированные в last_snapshot — для отката версии в БД (M4)
snapshot_version = Column(String(50), nullable=True)
snapshot_tipi_version = Column(Integer, nullable=True)
owner_id = Column(Integer, index=True, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def env_dict(self) -> dict:
if not self.env_encrypted:
return {}
try:
return _json.loads(self.env_encrypted)
except Exception:
return {}
def to_dict(self) -> dict:
return {
"id": self.id,
"app_id": self.app_id,
"server_id": self.server_id,
"vmid": self.vmid,
"node": self.node,
"name": self.name,
"version_installed": self.version_installed,
"tipi_version_installed": self.tipi_version_installed,
"ip": self.ip,
"port": self.port,
"url": f"http://{self.ip}:{self.port}" if self.ip and self.port else None,
"status": self.status,
"update_available": self.update_available,
"last_snapshot": self.last_snapshot,
"owner_id": self.owner_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f"<InstalledApp(id={self.id}, name='{self.name}', vmid={self.vmid}, status='{self.status}')>"
# Человекочитаемые названия операций для общего списка «Задачи».
APP_OPERATION_LABELS = {
"install": "Установка приложения",
"update": "Обновление приложения",
"rollback": "Откат приложения",
"delete": "Удаление приложения",
"start": "Запуск приложения",
"stop": "Остановка приложения",
"restart": "Перезапуск приложения",
"golden_template": "Подготовка золотого шаблона",
}
class AppOperation(Base):
"""Журнал операции над приложением (install/update/rollback/delete/...) — ТЗ 8."""
__tablename__ = "app_operations"
id = Column(Integer, primary_key=True, index=True)
installed_app_id = Column(Integer, ForeignKey("installed_apps.id", ondelete="CASCADE"), index=True, nullable=True)
# Только для операций без installed_app_id (напр. golden_template) — чтобы
# различать прогресс/историю сборки шаблона по серверам Proxmox.
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), index=True, nullable=True)
type = Column(String(20), nullable=False) # install/update/rollback/delete/start/stop/restart
status = Column(String(20), nullable=False, default="running") # running/completed/failed
progress = Column(Integer, nullable=False, default=0)
steps_log = Column(JSON, nullable=False, default=list) # [{step, progress, ts}]
error_text = Column(Text, nullable=True)
user_id = Column(Integer, nullable=True)
started_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
finished_at = Column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id,
"kind": "appstore",
"installed_app_id": self.installed_app_id,
"server_id": self.server_id,
"type": self.type,
"status": self.status,
"progress": self.progress,
"steps_log": self.steps_log or [],
"error_text": self.error_text,
"user_id": self.user_id,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
}
def to_task_dict(self, *, app_name: Optional[str] = None,
node: Optional[str] = None,
server_id: Optional[int] = None) -> dict:
"""Представление операции для общего списка «Задачи» (/api/all-tasks).
Поля приведены к общему виду с TaskQueue/ProxmoxTask/DeployTask:
description, step/current_step, error_message, created_at, progress.
"""
label = APP_OPERATION_LABELS.get(self.type, self.type)
steps = self.steps_log or []
last_step = steps[-1].get("step") if steps else None
return {
"id": self.id,
"kind": "appstore",
"type": self.type,
"description": f"{label}: {app_name}" if app_name else label,
"status": self.status,
"progress": self.progress,
"step": last_step,
"current_step": last_step,
"error_message": self.error_text,
"installed_app_id": self.installed_app_id,
"user_id": self.user_id,
"node": node,
"server_id": server_id,
"created_at": self.started_at.isoformat() if self.started_at else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.finished_at.isoformat() if self.finished_at else None,
}
def __repr__(self):
return f"<AppOperation(id={self.id}, type='{self.type}', status='{self.status}')>"