feat(images): backend catalog + mirror model + download API/worker

Phase 1 of VM image mirror feature:
- StorageMixin.download_url (PVE download-url/aria2) + get_download_target_storages
- builtin curated cloud-image catalog (Ubuntu/Debian/Rocky/Alma, amd64+arm64)
- ImageMirror model for admin custom mirrors
- /proxmox/api/images: catalog, lxc-templates proxy, storages, download
- _do_image_download_sync worker with log-tailing progress (DeployTask)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:51:21 +05:00
parent 4d8cdc0143
commit 7aed5dd97e
9 changed files with 546 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ from .backup import router as backup_router
from .cluster import router as cluster_router
from .networks import router as networks_router
from .lxc import router as lxc_router
from .images import router as images_router
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
router = APIRouter()
@@ -20,3 +21,4 @@ router.include_router(backup_router)
router.include_router(cluster_router)
router.include_router(networks_router)
router.include_router(lxc_router)
router.include_router(images_router)

View File

@@ -519,6 +519,129 @@ def _do_clone_sync(task_id: int, server_id: int, src_vmid: int, node: str, vm_ty
db.close()
# ==================== Image download ====================
def _wait_download_with_progress(client, task_id: int, node: str, upid: str,
lo: int = 15, hi: int = 100,
timeout: int = 3600) -> bool:
"""Дождаться задачи загрузки образа (download-url/aplinfo), маппя % из лога
в прогресс DeployTask диапазона lo..hi.
aria2 пишет прогресс в лог задачи строками вида "...(60%)...", что ловится
тем же регэкспом, что и копирование диска при клонировании.
"""
start = time.time()
log_pos = 0
last_reported = -1
span = max(1, hi - lo)
while time.time() - start < timeout:
try:
lines = client.get_task_log(node, upid, start=log_pos, limit=500) or []
except Exception:
lines = []
pct = None
for line in lines:
log_pos = max(log_pos, line.get('n', log_pos) or log_pos)
m = _PCT_RE.search(line.get('t', '') or '')
if m:
try:
pct = float(m.group(1))
except ValueError:
pass
if pct is not None:
progress = lo + int(min(pct, 100) * span / 100)
if progress != last_reported:
last_reported = progress
_update_deploy_task(task_id, 'running', f'Загрузка... {pct:.0f}%', progress)
status = client.get_task_status(node, upid)
if status and status.get('status') == 'stopped':
exit_status = status.get('exitstatus')
return exit_status == 'OK' or (
isinstance(exit_status, str) and exit_status.startswith('WARNINGS:'))
time.sleep(2)
logger.warning(f"Таймаут ожидания задачи загрузки {upid}")
return False
def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: str,
kind: str, filename: str,
url: Optional[str], template: Optional[str],
checksum: Optional[str], checksum_algorithm: Optional[str],
user_id: int, username: str):
"""Скачать образ (qcow2 или vztmpl) в хранилище ноды с прогрессом.
kind:
'qcow2' — cloud-образ ВМ, content=import (url обязателен)
'vztmpl' — LXC-шаблон: либо template (репозиторий Proxmox, aplinfo),
либо url (произвольное зеркало, content=vztmpl)
"""
db = SessionLocal()
server = None
try:
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 5, node=node)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
_update_deploy_task(task_id, 'failed', 'Сервер не найден', 0, error='Proxmox сервер не найден')
return
client = _connect(server)
if not client:
_update_deploy_task(task_id, 'failed', 'Ошибка подключения к Proxmox', 5,
error='Не удалось подключиться к Proxmox серверу')
return
_update_deploy_task(task_id, 'running', f'Старт загрузки {filename}...', 15, node=node)
try:
if kind == 'vztmpl' and template and not url:
# Шаблон из репозитория Proxmox (aplinfo)
upid = client.download_lxc_template(node, storage, template)
else:
content = 'vztmpl' if kind == 'vztmpl' else 'import'
upid = client.download_url(
node, storage, url, content, filename,
checksum=checksum, checksum_algorithm=checksum_algorithm,
)
except Exception as e:
_update_deploy_task(task_id, 'failed', 'Ошибка запуска загрузки', 15,
error=f'Download start failed: {e}')
return
if not upid:
_update_deploy_task(task_id, 'failed', 'Ошибка запуска загрузки', 15,
error='Proxmox не вернул UPID задачи загрузки')
return
ok = _wait_download_with_progress(client, task_id, node, upid, lo=15, hi=100)
if not ok:
# Подтянуть хвост лога с причиной
tail = ''
try:
tail_lines = client.get_task_log(node, upid, start=0, limit=20) or []
tail = ' | '.join((l.get('t') or '') for l in tail_lines[-5:])
except Exception:
pass
_update_deploy_task(task_id, 'failed', 'Загрузка не удалась', 15,
error=f'Загрузка завершилась с ошибкой. {tail}'[:300])
return
LoggingService.log_proxmox_action(
db=db, action='download_image',
resource_type='storage', resource_id=0,
username=username, resource_name=filename,
server_id=server_id, server_name=server.name, node_name=node,
details={'kind': kind, 'storage': storage, 'filename': filename}, success=True,
)
_update_deploy_task(task_id, 'completed', f'Образ {filename} загружен', 100, node=node)
logger.info(f"[IMG DL #{task_id}] {filename} -> {node}:{storage} by {username}")
except Exception as e:
err = str(e)
logger.error(f"[IMG DL #{task_id}] failed: {err}")
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
finally:
db.close()
# ==================== Change Password ====================
def _do_change_password_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str,

View File

@@ -0,0 +1,201 @@
"""
API: каталог образов (зеркала) и загрузка образов ВМ/шаблонов на ноду Proxmox.
Phase 1: просмотр встроенного каталога + кастомных зеркал, динамический список
LXC-шаблонов из репозитория Proxmox, выбор целевого хранилища и фоновая загрузка
qcow2 (content=import) / vztmpl с прогрессом через DeployTask + WebSocket.
"""
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, model_validator
from sqlalchemy.orm import Session
from loguru import logger
from ...db import get_db
from ...models import ProxmoxServer, User, DeployTask, ImageMirror
from ...auth import PermissionChecker
from ...catalog import CATALOG, get_catalog_image
from ._helpers import _get_proxmox_client
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class ImageDownloadResponse(BaseModel):
task_id: int
status: str
name: str
class ImageDownloadRequest(BaseModel):
node: str
storage: str
# Либо ссылка на запись каталога/зеркала...
source_id: Optional[str] = None
# ...либо явные параметры произвольного образа:
kind: Optional[str] = None # 'qcow2' | 'vztmpl'
url: Optional[str] = None
template: Optional[str] = None # имя шаблона из репозитория (aplinfo)
filename: Optional[str] = None
checksum: Optional[str] = None
checksum_algorithm: Optional[str] = None
@model_validator(mode='after')
def _check(self):
if not self.source_id and not (self.url or self.template):
raise ValueError('Нужен source_id, либо url/template')
return self
# ── Catalog ──────────────────────────────────────────────────────────────────
@router.get("/api/images/catalog")
def get_image_catalog(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view")),
):
"""Встроенный каталог cloud-образов + включённые кастомные зеркала."""
mirrors = db.query(ImageMirror).filter(ImageMirror.enabled.is_(True)).all()
return {
"builtin": CATALOG,
"mirrors": [m.to_dict() for m in mirrors],
}
@router.get("/api/{server_id}/images/lxc-templates")
def get_lxc_repo_templates(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view")),
):
"""Динамический список LXC-шаблонов из репозитория Proxmox (aplinfo)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Cannot connect to Proxmox server")
try:
return JSONResponse(content=client.get_available_lxc_templates(node))
except Exception as e:
logger.error(f"Error listing LXC repo templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/{server_id}/images/storages")
def get_image_target_storages(
server_id: int,
node: str,
content: str = Query(..., description="iso|vztmpl|images|import"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view")),
):
"""Целевые хранилища ноды, поддерживающие указанный тип контента."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Cannot connect to Proxmox server")
try:
return JSONResponse(content=client.get_download_target_storages(node, content))
except Exception as e:
logger.error(f"Error listing target storages: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ── Download ─────────────────────────────────────────────────────────────────
def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
"""Свести запрос к набору параметров загрузки: kind/url/template/filename/checksum."""
if req.source_id:
# Встроенный каталог
item = get_catalog_image(req.source_id)
if item:
return {
'kind': item['kind'], 'url': item.get('url'), 'template': None,
'filename': item.get('filename') or (item.get('url') or '').rsplit('/', 1)[-1],
'checksum': item.get('checksum'),
'checksum_algorithm': item.get('checksum_algorithm'),
'name': item['name'],
}
# Кастомное зеркало: source_id вида "mirror-<id>"
if req.source_id.startswith('mirror-'):
try:
mid = int(req.source_id.split('-', 1)[1])
except ValueError:
mid = None
mirror = db.query(ImageMirror).filter(ImageMirror.id == mid).first() if mid else None
if mirror:
fname = (mirror.url or '').rsplit('/', 1)[-1] or mirror.template
return {
'kind': mirror.kind, 'url': mirror.url, 'template': mirror.template,
'filename': fname,
'checksum': mirror.checksum,
'checksum_algorithm': mirror.checksum_algorithm,
'name': mirror.name,
}
raise HTTPException(status_code=404, detail="Источник образа не найден")
# Произвольный образ из тела запроса
kind = req.kind or 'qcow2'
filename = req.filename or (req.url or '').rsplit('/', 1)[-1] or req.template
if not filename:
raise HTTPException(status_code=400, detail="Не удалось определить имя файла")
return {
'kind': kind, 'url': req.url, 'template': req.template, 'filename': filename,
'checksum': req.checksum, 'checksum_algorithm': req.checksum_algorithm,
'name': filename,
}
@router.post("/api/{server_id}/images/download", response_model=ImageDownloadResponse)
async def download_image(
server_id: int,
req: ImageDownloadRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage")),
):
"""Запустить фоновую загрузку образа в хранилище ноды."""
# Ленивый импорт во избежание циклической зависимости templates <-> api.proxmox
from ..templates import _deploy_executor
from .async_ops import _do_image_download_sync
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
src = _resolve_source(db, req)
if src['kind'] == 'vztmpl' and not src['template'] and not src['url']:
raise HTTPException(status_code=400, detail="Для vztmpl нужен url или template")
if src['kind'] != 'vztmpl' and not src['url']:
raise HTTPException(status_code=400, detail="Для qcow2 нужен url")
task = DeployTask(
status='pending', step='В очереди...', progress=0,
kind='image_download', name=src['name'],
server_id=server_id, user_id=current_user.id, node=req.node,
)
db.add(task)
db.commit()
db.refresh(task)
task_id = task.id
loop = asyncio.get_event_loop()
loop.run_in_executor(
_deploy_executor,
_do_image_download_sync,
task_id, server_id, req.node, req.storage,
src['kind'], src['filename'], src['url'], src['template'],
src['checksum'], src['checksum_algorithm'],
current_user.id, current_user.username,
)
logger.info(f"[IMG DL] Task #{task_id} queued: {src['filename']} by {current_user.username}")
return ImageDownloadResponse(task_id=task_id, status='pending', name=src['name'])