feat(instances): add VM/LXC migration, autostart/boot order, protection
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 52s
Deploy to Production / Trigger production update (push) Successful in 7s

- Migrate VMs/LXCs between cluster nodes with live progress tracking
- Configure autostart, boot order, startup/shutdown delays, and delete protection from the instance settings tab
- Group OS templates by detected OS (Ubuntu, AlmaLinux, etc.) instead of a single "Cloud Images" bucket
- Expand native Proxmox backup job management (schedule, compression, retention, guest selection, notifications)
- Add ru/en translations for the above
This commit is contained in:
markmorado
2026-07-03 02:40:58 +05:00
parent e8bce3e8ae
commit c12fbf13e5
30 changed files with 1411 additions and 111 deletions

View File

@@ -21,7 +21,7 @@ from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance, save
# Re-use deploy executor and update helper
from ..templates import _deploy_executor, _update_deploy_task # noqa: F401
from ..templates import _deploy_executor, _update_deploy_task, detect_os_group # noqa: F401
def _connect(server: ProxmoxServer) -> Optional[ProxmoxClient]:
@@ -519,6 +519,127 @@ def _do_clone_sync(task_id: int, server_id: int, src_vmid: int, node: str, vm_ty
db.close()
# ==================== Migrate ====================
def _do_migrate_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str,
target_node: str, target_storage: Optional[str], online: bool,
user_id: int, username: str):
"""Мигрировать VM/LXC на другую ноду кластера (фоновая задача с прогрессом)."""
db = SessionLocal()
server = None
is_lxc = (vm_type == 'lxc')
try:
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10,
vmid=vmid, 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', 10,
error='Не удалось подключиться к Proxmox серверу')
return
_update_deploy_task(task_id, 'running', f'Миграция → {target_node}...', 30,
vmid=vmid, node=node)
try:
if is_lxc:
upid = client.migrate_container(
node, vmid, target_node,
target_storage=target_storage or None, online=online,
)
else:
upid = client.migrate_vm(
node, vmid, target_node,
target_storage=target_storage or None, online=online,
)
except Exception as e:
_update_deploy_task(task_id, 'failed', 'Ошибка миграции', 30, error=f'Migrate failed: {e}')
return
if not upid:
_update_deploy_task(task_id, 'failed', 'Ошибка миграции', 30,
error='Proxmox не вернул UPID задачи миграции')
return
# Прогресс копирования диска (если есть) маппится в 30..95 из лога задачи
_update_deploy_task(task_id, 'running', 'Перенос данных...', 50, vmid=vmid, node=node)
ok = _wait_migrate_with_progress(client, task_id, node, upid, timeout=3600)
if not ok:
_update_deploy_task(task_id, 'failed', 'Миграция не удалась', 50,
error=f'Задача миграции завершилась с ошибкой. {_task_log_tail(client, node, upid)}'[:300])
return
# Обновляем ноду в локальной записи инстанса
try:
cached = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None),
).first()
if cached:
cached.node = target_node
db.commit()
except Exception as reg_err:
logger.warning(f"[MIGRATE #{task_id}] failed to update instance node: {reg_err}")
LoggingService.log_proxmox_action(
db=db, action='migrate',
resource_type='lxc' if is_lxc else 'vm', resource_id=vmid,
username=username, server_id=server_id,
server_name=server.name, node_name=node,
details={'target_node': target_node, 'target_storage': target_storage, 'online': online},
success=True,
)
_update_deploy_task(task_id, 'completed', f'Мигрировано на {target_node}', 100,
vmid=vmid, node=target_node)
logger.info(f"[MIGRATE #{task_id}] {vmid} {node} -> {target_node} by {username}")
except Exception as e:
err = str(e)
logger.error(f"[MIGRATE #{task_id}] failed: {err}")
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
finally:
db.close()
def _wait_migrate_with_progress(client, task_id: int, node: str, upid: str,
timeout: int = 3600) -> bool:
"""Дождаться задачи миграции, маппя % копирования диска из лога в 50..95."""
start = time.time()
log_pos = 0
last_reported = -1
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 = 50 + int(min(pct, 100) * 0.45) # 50..95
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
# ==================== Image download ====================
# Расширения, которые Proxmox принимает для content=import (диск-образы).
@@ -685,12 +806,24 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
db.close()
def _get_or_create_cloud_group(db) -> int:
"""Найти/создать группу шаблонов «Cloud Images» для импортированных образов."""
grp = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == 'Cloud Images').first()
def _get_or_create_os_group(db, hint: Optional[str]) -> int:
"""Найти/создать группу шаблонов по ОС (Ubuntu, AlmaLinux, ...).
Определяет группу по имени через detect_os_group(), чтобы импортированный
cloud-образ попадал в свою ОС-группу и фильтровался на странице шаблонов
(напр. AlmaLinux), а не в общую «Cloud Images».
"""
group_info = detect_os_group(hint or '')
group_name = group_info['name']
grp = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == group_name).first()
if not grp:
grp = OSTemplateGroup(name='Cloud Images', icon='cloud',
description='Импортированные cloud-образы', sort_order=50)
grp = OSTemplateGroup(
name=group_name,
icon=group_info['icon'],
description=f'Шаблоны {group_name}',
sort_order=group_info['order'],
is_active=True,
)
db.add(grp)
db.commit()
db.refresh(grp)
@@ -704,7 +837,8 @@ def _do_image_template_sync(task_id: int, server_id: int, node: str,
name: str, cores: int, memory: int, bridge: str,
ciuser: Optional[str], cipassword: Optional[str],
ssh_keys: Optional[str], os_name: Optional[str],
version: Optional[str], user_id: int, username: str):
version: Optional[str], user_id: int, username: str,
vmid: Optional[int] = None):
"""Скачать qcow2 cloud-образ и автоматически конвертировать в Proxmox VM-шаблон.
Пайплайн (без SSH, только API PVE 8.2+):
@@ -750,7 +884,15 @@ def _do_image_template_sync(task_id: int, server_id: int, node: str,
# 2) Аллокация VMID и создание ВМ с импортом диска
_update_deploy_task(task_id, 'running', 'Аллокация VMID...', 42, node=node)
new_vmid = client.get_next_vmid() or get_next_vmid(db, server_id)
if vmid:
# Пользователь указал VMID явно — проверяем, что он свободен
if client.get_vm_status(node, vmid):
_update_deploy_task(task_id, 'failed', 'VMID уже занят', 42,
error=f'VMID {vmid} уже используется на ноде {node}')
return
new_vmid = vmid
else:
new_vmid = client.get_next_vmid() or get_next_vmid(db, server_id)
if not new_vmid:
_update_deploy_task(task_id, 'failed', 'Не удалось выделить VMID', 42, error='Failed to allocate VMID')
return
@@ -804,7 +946,7 @@ def _do_image_template_sync(task_id: int, server_id: int, node: str,
# 5) Регистрация OSTemplate (становится доступен в деплое)
_update_deploy_task(task_id, 'running', 'Регистрация шаблона...', 98, vmid=new_vmid, node=node)
try:
group_id = _get_or_create_cloud_group(db)
group_id = _get_or_create_os_group(db, os_name or name)
tpl = OSTemplate(
group_id=group_id, server_id=server_id, name=name,
vmid=new_vmid, vm_type='qemu', node=node, source_node=node,

View File

@@ -541,6 +541,162 @@ def list_proxmox_native_jobs(
return JSONResponse(content={"jobs": [], "error": str(e)})
def _build_native_job_props(data: dict, *, for_create: bool) -> dict:
"""Собрать параметры vzdump для POST/PUT /cluster/backup из тела запроса."""
props: dict = {}
schedule = data.get("schedule")
if schedule:
props["schedule"] = schedule
storage = data.get("storage")
if storage:
props["storage"] = storage
if data.get("mode"):
props["mode"] = data["mode"]
if data.get("compress") is not None:
props["compress"] = data["compress"]
props["enabled"] = 1 if data.get("enabled", True) else 0
if data.get("comment"):
props["comment"] = data["comment"]
if data.get("mailto"):
props["mailto"] = data["mailto"]
if data.get("notes_template"):
props["notes-template"] = data["notes_template"]
if data.get("node"):
props["node"] = data["node"]
# Выбор гостей: все / список VMID / пул. Взаимоисключающие поля в PVE —
# при обновлении чистим неиспользуемые через delete, иначе PVE ругается.
selection = data.get("selection_mode", "all")
to_delete: list[str] = []
if selection == "all":
props["all"] = 1
to_delete += ["vmid", "pool"]
elif selection == "pool" and data.get("pool"):
props["pool"] = data["pool"]
to_delete += ["vmid", "all"]
elif selection == "vmid":
vmid = data.get("vmid")
if isinstance(vmid, list):
vmid = ",".join(str(v) for v in vmid)
if vmid:
props["vmid"] = vmid
to_delete += ["all", "pool"]
if not for_create and to_delete:
props["delete"] = ",".join(to_delete)
# Retention → prune-backups="keep-last=N,keep-daily=N,..."
keep_parts = []
for key, pve_key in (
("keep_last", "keep-last"), ("keep_daily", "keep-daily"),
("keep_weekly", "keep-weekly"), ("keep_monthly", "keep-monthly"),
):
val = data.get(key)
if val:
keep_parts.append(f"{pve_key}={int(val)}")
if keep_parts:
props["prune-backups"] = ",".join(keep_parts)
return props
@router.post("/api/backups/proxmox-jobs/{server_id}")
async def create_proxmox_native_job(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Создать нативное задание бэкапа Proxmox (cluster/backup)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
if not data.get("schedule") or not data.get("storage"):
raise HTTPException(status_code=400, detail="schedule and storage are required")
props = _build_native_job_props(data, for_create=True)
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.create_cluster_backup_job, props)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="create", resource_type="backup_job",
resource_id=str(props.get("id") or "native"), username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": True, "schedule": props.get("schedule")},
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/backups/proxmox-jobs/{server_id}/{job_id}")
async def update_proxmox_native_job(
server_id: int,
job_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Обновить нативное задание бэкапа Proxmox (cluster/backup/{id})."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
props = _build_native_job_props(data, for_create=False)
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.update_cluster_backup_job, job_id, props)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="update", resource_type="backup_job",
resource_id=job_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": True},
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/backups/proxmox-jobs/{server_id}/{job_id}")
async def delete_proxmox_native_job(
server_id: int,
job_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Удалить нативное задание бэкапа Proxmox (cluster/backup/{id})."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.delete_cluster_backup_job, job_id)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="delete", resource_type="backup_job",
resource_id=job_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": True},
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/backups/jobs")
def list_backup_jobs(
db: Session = Depends(get_db),

View File

@@ -47,6 +47,7 @@ class ImageDownloadRequest(BaseModel):
# Авто-конвертация qcow2 → VM-шаблон (Phase 2)
to_template: bool = False
vmid: Optional[int] = None # желаемый VMID шаблона; None → авто-выбор
disk_storage: Optional[str] = None # хранилище диска ВМ (content=images); по умолч. = storage
cores: Optional[int] = None
memory: Optional[int] = None # MB
@@ -331,7 +332,7 @@ async def download_image(
src['url'], src['filename'], src['checksum'], src['checksum_algorithm'],
src['name'], req.cores or 2, req.memory or 2048, req.bridge or 'vmbr0',
req.ciuser, req.cipassword, req.ssh_keys, src.get('os'), src.get('version'),
current_user.id, current_user.username,
current_user.id, current_user.username, req.vmid,
)
else:
loop.run_in_executor(

View File

@@ -381,11 +381,17 @@ def list_lxc_templates(
def list_lxc_storages(
server_id: int,
node: Optional[str] = None,
content: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")),
):
"""
Список хранилищ ноды, пригодных для rootfs LXC контейнера.
Список хранилищ ноды для дисков инстансов.
content:
'rootdir' — хранилища для rootfs LXC контейнера
'images' — хранилища для диска ВМ KVM (файлы диска)
None — обратная совместимость: rootdir ИЛИ images
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
@@ -402,19 +408,24 @@ def list_lxc_storages(
if not target_node:
raise HTTPException(status_code=400, detail="Не удалось определить ноду")
storages = client.get_node_storages(target_node)
result = []
for s in storages:
content = s.get("content", "")
if "rootdir" in content or "images" in content:
result.append({
"storage": s.get("storage"),
"type": s.get("type"),
"avail": s.get("avail"),
"total": s.get("total"),
"used": s.get("used"),
"active": s.get("active", 0),
})
if content in ("rootdir", "images", "vztmpl", "import", "iso"):
# Точная фильтрация по типу контента (активные/включённые хранилища)
storages = client.get_download_target_storages(target_node, content)
else:
# Без параметра — прежнее поведение: rootdir ИЛИ images
storages = [
s for s in client.get_node_storages(target_node)
if "rootdir" in (s.get("content", "") or "") or "images" in (s.get("content", "") or "")
]
result = [{
"storage": s.get("storage"),
"type": s.get("type"),
"avail": s.get("avail"),
"total": s.get("total"),
"used": s.get("used"),
"active": s.get("active", 0),
} for s in storages]
return JSONResponse(content={"storages": result})

View File

@@ -376,6 +376,12 @@ def _resolve_clone_owner(db: Session, current_user: User, owner_id: Optional[int
return current_user.id
class MigrateRequest(BaseModel):
target_node: str
target_storage: Optional[str] = None
online: bool = False
class ChangePasswordRequest(BaseModel):
username: str = "root"
password: str
@@ -461,6 +467,76 @@ def clone_container_endpoint(
return DeployTaskStartResponse(task_id=task.id, status='pending', name=body.new_name)
# -------- Migrate (move VM/LXC to another cluster node) --------
@router.post("/api/{server_id}/vm/{vmid}/migrate", status_code=202)
def migrate_vm_endpoint(
server_id: int,
vmid: int,
node: str,
body: MigrateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:migrate"))
):
"""Мигрировать VM (qemu) на другую ноду — фоновая задача."""
from ...models import DeployTask
from ..templates import _deploy_executor, DeployTaskStartResponse
from .async_ops import _do_migrate_sync
require_vm_access(db, current_user, server_id, vmid)
_resolve_server(db, server_id)
if body.target_node == node:
raise HTTPException(status_code=400, detail="Целевая нода совпадает с текущей")
task = DeployTask(
kind='migrate', name=f'migrate {vmid} → {body.target_node}', status='pending',
step='В очереди...', progress=0,
template_id=None, server_id=server_id, user_id=current_user.id, node=node,
)
db.add(task); db.commit(); db.refresh(task)
_deploy_executor.submit(
_do_migrate_sync, task.id, server_id, vmid, node, 'qemu',
body.target_node, body.target_storage, body.online,
current_user.id, current_user.username,
)
return DeployTaskStartResponse(task_id=task.id, status='pending', name=task.name)
@router.post("/api/{server_id}/container/{vmid}/migrate", status_code=202)
def migrate_container_endpoint(
server_id: int,
vmid: int,
node: str,
body: MigrateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("lxc:migrate"))
):
"""Мигрировать LXC контейнер на другую ноду — фоновая задача."""
from ...models import DeployTask
from ..templates import _deploy_executor, DeployTaskStartResponse
from .async_ops import _do_migrate_sync
require_vm_access(db, current_user, server_id, vmid)
_resolve_server(db, server_id)
if body.target_node == node:
raise HTTPException(status_code=400, detail="Целевая нода совпадает с текущей")
task = DeployTask(
kind='migrate', name=f'migrate {vmid} → {body.target_node}', status='pending',
step='В очереди...', progress=0,
template_id=None, server_id=server_id, user_id=current_user.id, node=node,
)
db.add(task); db.commit(); db.refresh(task)
_deploy_executor.submit(
_do_migrate_sync, task.id, server_id, vmid, node, 'lxc',
body.target_node, body.target_storage, body.online,
current_user.id, current_user.username,
)
return DeployTaskStartResponse(task_id=task.id, status='pending', name=task.name)
# -------- Reinstall (re-clone from saved template) --------
@router.post("/api/{server_id}/vm/{vmid}/reinstall", status_code=202)