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 .cluster import router as cluster_router
from .networks import router as networks_router from .networks import router as networks_router
from .lxc import router as lxc_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 from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
router = APIRouter() router = APIRouter()
@@ -20,3 +21,4 @@ router.include_router(backup_router)
router.include_router(cluster_router) router.include_router(cluster_router)
router.include_router(networks_router) router.include_router(networks_router)
router.include_router(lxc_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() 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 ==================== # ==================== Change Password ====================
def _do_change_password_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str, 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'])

View File

@@ -0,0 +1,3 @@
from .builtin import CATALOG, get_catalog_image
__all__ = ['CATALOG', 'get_catalog_image']

View File

@@ -0,0 +1,97 @@
"""
Встроенный curated-каталог cloud-образов ВМ (qcow2).
URL-ы ведут на «latest»/«current» зеркала дистрибутивов — образ всегда свежий
актуального point-релиза. checksum опционален (фиксированную сумму к плавающему
latest-URL прибить нельзя), поэтому проверка включается только если она задана.
LXC-шаблоны (vztmpl) здесь НЕ хардкодятся: их список берётся динамически из
репозитория Proxmox (aplinfo), плюс произвольные vztmpl-зеркала из ImageMirror.
Каждая запись разворачивается по архитектурам amd64/arm64. arm64-образ —
гость для ARM-ноды; UI фильтрует по arch выбранной ноды.
"""
from typing import Dict, List, Optional
# (id_base, os, version, name, icon, {arch: url})
_CLOUD_IMAGES = [
(
'ubuntu-22.04-cloud', 'ubuntu', '22.04', 'Ubuntu 22.04 LTS (Jammy) Cloud', 'ubuntu',
{
'amd64': 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img',
'arm64': 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img',
},
),
(
'ubuntu-24.04-cloud', 'ubuntu', '24.04', 'Ubuntu 24.04 LTS (Noble) Cloud', 'ubuntu',
{
'amd64': 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img',
'arm64': 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img',
},
),
(
'debian-11-cloud', 'debian', '11', 'Debian 11 (Bullseye) GenericCloud', 'debian',
{
'amd64': 'https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2',
'arm64': 'https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2',
},
),
(
'debian-12-cloud', 'debian', '12', 'Debian 12 (Bookworm) GenericCloud', 'debian',
{
'amd64': 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2',
'arm64': 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2',
},
),
(
'rocky-9-cloud', 'rocky', '9', 'Rocky Linux 9 GenericCloud', 'rocky',
{
'amd64': 'https://download.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2',
'arm64': 'https://download.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2',
},
),
(
'alma-9-cloud', 'almalinux', '9', 'AlmaLinux 9 GenericCloud', 'almalinux',
{
'amd64': 'https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2',
'arm64': 'https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2',
},
),
]
def _filename_for(url: str) -> str:
"""Имя файла, под которым образ ляжет в хранилище (из хвоста URL)."""
return url.rsplit('/', 1)[-1]
def _build() -> List[Dict]:
items: List[Dict] = []
for id_base, os_name, version, name, icon, urls in _CLOUD_IMAGES:
for arch, url in urls.items():
items.append({
'id': f'{id_base}-{arch}',
'source': 'builtin',
'kind': 'qcow2',
'os': os_name,
'version': version,
'arch': arch,
'name': name,
'url': url,
'filename': _filename_for(url),
'checksum': None,
'checksum_algorithm': None,
'icon': icon,
})
return items
CATALOG: List[Dict] = _build()
_BY_ID = {item['id']: item for item in CATALOG}
def get_catalog_image(image_id: str) -> Optional[Dict]:
"""Найти запись встроенного каталога по id."""
return _BY_ID.get(image_id)

View File

@@ -6,6 +6,7 @@ from .tasks import TaskQueue, ProxmoxTask, DeployTask
from .metrics import InstanceMetric, InstanceNicMetric from .metrics import InstanceMetric, InstanceNicMetric
from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer, WorkspaceUser from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer, WorkspaceUser
from .misc import Notification, NotificationPreference, BackupJob, AuditLog from .misc import Notification, NotificationPreference, BackupJob, AuditLog
from .catalog import ImageMirror
__all__ = [ __all__ = [
'Base', 'utcnow', 'Base', 'utcnow',
@@ -15,5 +16,6 @@ __all__ = [
'TaskQueue', 'ProxmoxTask', 'DeployTask', 'TaskQueue', 'ProxmoxTask', 'DeployTask',
'InstanceMetric', 'InstanceNicMetric', 'InstanceMetric', 'InstanceNicMetric',
'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser', 'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser',
'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog' 'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog',
'ImageMirror'
] ]

View File

@@ -0,0 +1,54 @@
from .base import *
class ImageMirror(Base):
"""
Кастомный источник образов (зеркало), добавленный администратором.
Дополняет встроенный curated-каталог (app/catalog/builtin.py).
kind:
'qcow2' — cloud-образ ВМ (url обязателен)
'vztmpl' — LXC-шаблон (url ИЛИ template — имя файла из репозитория)
"""
__tablename__ = "image_mirrors"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(150), nullable=False)
kind = Column(String(20), nullable=False, default='qcow2', index=True)
os = Column(String(50), nullable=True)
version = Column(String(50), nullable=True)
arch = Column(String(20), nullable=False, default='amd64')
url = Column(String(1000), nullable=True)
template = Column(String(255), nullable=True)
checksum = Column(String(150), nullable=True)
checksum_algorithm = Column(String(20), nullable=True)
description = Column(Text, nullable=True)
enabled = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), 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 to_dict(self) -> dict:
return {
'id': f'mirror-{self.id}',
'mirror_id': self.id,
'source': 'mirror',
'kind': self.kind,
'os': self.os,
'version': self.version,
'arch': self.arch,
'name': self.name,
'url': self.url,
'template': self.template,
'checksum': self.checksum,
'checksum_algorithm': self.checksum_algorithm,
'description': self.description,
'enabled': self.enabled,
}
def __repr__(self):
return f"<ImageMirror(id={self.id}, name='{self.name}', kind='{self.kind}')>"

View File

@@ -143,6 +143,8 @@ class DeployTask(Base):
'reinstall': f"Переустановка {self.name}", 'reinstall': f"Переустановка {self.name}",
'clone': f"Клонирование {self.name}", 'clone': f"Клонирование {self.name}",
'change_password': f"Смена пароля: {self.name}", 'change_password': f"Смена пароля: {self.name}",
'image_download': f"Загрузка образа: {self.name}",
'image_template': f"Шаблон из образа: {self.name}",
} }
return { return {
'id': self.id, 'id': self.id,

View File

@@ -80,6 +80,67 @@ class StorageMixin:
logger.error(f"Ошибка получения CT-шаблонов {self.host}: {e}") logger.error(f"Ошибка получения CT-шаблонов {self.host}: {e}")
return result return result
def download_url(self, node: str, storage: str, url: str, content: str,
filename: str, checksum: Optional[str] = None,
checksum_algorithm: Optional[str] = None) -> Optional[str]:
"""
Скачать файл (ISO / vztmpl / образ для импорта) по URL прямо на ноду
средствами Proxmox (aria2). Использует endpoint
POST /nodes/{node}/storage/{storage}/download-url.
Args:
node: имя ноды
storage: целевое хранилище
url: прямой URL к образу (.img/.qcow2/.iso/.tar.zst ...)
content: тип контента ('iso', 'vztmpl', 'import')
filename: имя файла в хранилище
checksum: контрольная сумма (опционально)
checksum_algorithm: алгоритм (md5/sha1/sha224/sha256/sha384/sha512)
Returns:
UPID задачи или None
"""
if not self.proxmox:
return None
try:
params = {
'url': url,
'content': content,
'filename': filename,
}
if checksum and checksum_algorithm:
params['checksum'] = checksum
params['checksum-algorithm'] = checksum_algorithm
# 'download-url' содержит дефис — обращаемся через call-форму proxmoxer
result = self.proxmox.nodes(node).storage(storage)('download-url').post(**params)
logger.info(f"Запущена загрузка {filename} ({content}) на {node}:{storage}, UPID: {result}")
return result
except Exception as e:
logger.error(f"Ошибка загрузки {url} на {node}:{storage}: {e}")
raise
def get_download_target_storages(self, node: str, content: str) -> List[Dict]:
"""
Получить активные хранилища ноды, поддерживающие указанный тип контента
(для выбора цели загрузки образов).
Args:
node: имя ноды
content: 'iso' | 'vztmpl' | 'images' | 'import'
"""
if not self.proxmox:
return []
result: List[Dict] = []
try:
for s in self.proxmox.nodes(node).storage.get():
if s.get('active', 1) == 0 or s.get('enabled', 1) == 0:
continue
if content in (s.get('content', '') or '').split(','):
result.append(s)
except Exception as e:
logger.error(f"Ошибка получения хранилищ ({content}) ноды {node}: {e}")
return result
def get_node_storages(self, node: str, content: str = None) -> List[Dict]: def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
"""Get storages available on a specific node, optionally filtered by content type""" """Get storages available on a specific node, optionally filtered by content type"""
if not self.proxmox: if not self.proxmox: