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:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
201
backend/app/api/proxmox/images.py
Normal file
201
backend/app/api/proxmox/images.py
Normal 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'])
|
||||||
3
backend/app/catalog/__init__.py
Normal file
3
backend/app/catalog/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .builtin import CATALOG, get_catalog_image
|
||||||
|
|
||||||
|
__all__ = ['CATALOG', 'get_catalog_image']
|
||||||
97
backend/app/catalog/builtin.py
Normal file
97
backend/app/catalog/builtin.py
Normal 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)
|
||||||
@@ -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'
|
||||||
]
|
]
|
||||||
|
|||||||
54
backend/app/models/catalog.py
Normal file
54
backend/app/models/catalog.py
Normal 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}')>"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user