feat(instances): add VM/LXC migration, autostart/boot order, protection
- 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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user