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

262 lines
9.5 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 Store — эндпоинты жизненного цикла приложений (M2: установка).
POST /api/appstore/apps/install — установить приложение (app:install)
GET /api/appstore/apps — список установленных (app:view)
GET /api/appstore/apps/{id} — детали (app:view)
GET /api/appstore/apps/{id}/operations — журнал операций (app:view)
GET /api/appstore/apps/{id}/credentials — секреты из формы, показать один раз (app:view)
POST /api/appstore/apps/{id}/retry — повтор установки (app:manage)
DELETE /api/appstore/apps/{id} — удалить приложение вместе с LXC (app:manage)
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...auth import PermissionChecker
from ...db import get_db
from ...models import AppOperation, CatalogApp, InstalledApp, User
from ...services import appstore_engine as engine
router = APIRouter()
class InstallRequest(BaseModel):
app_id: str
name: str
server_id: int
node: Optional[str] = None
form_answers: dict = {}
# advanced (ТЗ 7.2, шаг 2)
cores: int = 2
memory: int = 2048 # МБ
disk: int = 8 # ГБ
storage: str = "local-lvm"
bridge: str = "vmbr0"
ostemplate: Optional[str] = None # переопределение золотого шаблона
# Кастомный рабочий порт приложения (переопределяет порт из каталога).
# None — использовать порт по умолчанию из config.json приложения.
port: Optional[int] = Field(default=None, ge=1, le=65535)
# IPAM (опционально): если задан network — IP выделяется из пула IPAM,
# иначе контейнер получает адрес по DHCP.
ipam_network_id: Optional[int] = None
ipam_pool_id: Optional[int] = None
def _can_admin(user: User) -> bool:
return bool(getattr(user, "is_admin", False)) or user.has_permission("app:manage")
def _get_owned(db: Session, app_id: int, user: User) -> InstalledApp:
ia = db.get(InstalledApp, app_id)
if not ia:
raise HTTPException(status_code=404, detail="Установленное приложение не найдено")
if not _can_admin(user) and ia.owner_id != user.id:
raise HTTPException(status_code=403, detail="Нет доступа к этому приложению")
return ia
@router.post("/api/appstore/apps/install")
def install_app(
req: InstallRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:install")),
):
try:
result = engine.install(
db, current_user,
app_id=req.app_id, name=req.name, form_answers=req.form_answers,
server_id=req.server_id, node=req.node, cores=req.cores,
memory=req.memory, disk=req.disk, storage=req.storage,
bridge=req.bridge, ostemplate=req.ostemplate, port=req.port,
ipam_network_id=req.ipam_network_id, ipam_pool_id=req.ipam_pool_id,
)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
# secrets показываются один раз (в т.ч. сгенерированные random-поля)
return {"success": True, **result}
@router.get("/api/appstore/apps")
def list_installed(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
q = db.query(InstalledApp)
if not _can_admin(current_user):
q = q.filter(InstalledApp.owner_id == current_user.id)
rows = q.order_by(InstalledApp.created_at.desc()).all()
return {"items": [a.to_dict() for a in rows]}
@router.get("/api/appstore/apps/{app_id}")
def get_installed(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
ia = _get_owned(db, app_id, current_user)
data = ia.to_dict()
catalog = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
if catalog:
data["catalog"] = catalog.to_dict(light=True)
data["update_available"] = bool(
catalog.tipi_version and ia.tipi_version_installed
and catalog.tipi_version > ia.tipi_version_installed
)
return data
@router.get("/api/appstore/apps/{app_id}/operations")
def app_operations(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
_get_owned(db, app_id, current_user)
ops = (
db.query(AppOperation)
.filter(AppOperation.installed_app_id == app_id)
.order_by(AppOperation.started_at.desc())
.limit(20)
.all()
)
return {"items": [o.to_dict() for o in ops]}
@router.get("/api/appstore/apps/{app_id}/credentials")
def app_credentials(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
"""Секреты из формы (password/random) — расшифрованные. Только владелец/админ.
Дополнительно: учётные данные по умолчанию из манифеста источника
(umbrel-app.yml → default_credentials) и сгенерированный APP_PASSWORD —
для многих приложений это единственный способ узнать логин/пароль админа.
"""
ia = _get_owned(db, app_id, current_user)
env = ia.env_dict()
catalog = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
secret_keys = set()
for f in (catalog.form_fields if catalog else []) or []:
if (f.get("type") or "").lower() in ("password", "random") and f.get("env_variable"):
secret_keys.add(f["env_variable"])
creds = {k: v for k, v in env.items() if k in secret_keys}
dc = (catalog.default_credentials if catalog else None) or {}
if dc.get("username"):
creds.setdefault("USERNAME", dc["username"])
default_pwd = env.get("APP_PASSWORD") if dc.get("deterministic") else dc.get("password")
if default_pwd:
creds.setdefault("PASSWORD", default_pwd)
if env.get("APP_PASSWORD"):
creds.setdefault("APP_PASSWORD", env["APP_PASSWORD"])
return {"credentials": creds}
@router.post("/api/appstore/apps/{app_id}/retry")
def retry_install(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.retry_install(db, current_user, app_id)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
class UpdateRequest(BaseModel):
form_answers: dict = {}
@router.post("/api/appstore/apps/{app_id}/update")
def update_app(
app_id: int,
req: UpdateRequest | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.update(db, current_user, app_id, (req.form_answers if req else {}))
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.post("/api/appstore/apps/{app_id}/rollback")
def rollback_app(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.rollback(db, current_user, app_id)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.post("/api/appstore/apps/{app_id}/action/{action}")
def lifecycle(
app_id: int,
action: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
"""action: start | stop | restart."""
_get_owned(db, app_id, current_user)
try:
result = engine.lifecycle_action(db, current_user, app_id, action)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, "app": result}
@router.get("/api/appstore/apps/{app_id}/logs")
def app_logs(
app_id: int,
tail: int = 200,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
_get_owned(db, app_id, current_user)
try:
logs = engine.get_logs(db, app_id, tail)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"logs": logs}
@router.post("/api/appstore/apps/reconcile")
def reconcile(
db: Session = Depends(get_db),
_: User = Depends(PermissionChecker("app:view")),
):
return engine.reconcile(db)
@router.delete("/api/appstore/apps/{app_id}")
def delete_app(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.delete_app(db, current_user, app_id)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}