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)
|
||||
|
||||
@@ -396,40 +396,64 @@ def discover_proxmox_templates(
|
||||
|
||||
# ==================== Auto-Import Templates ====================
|
||||
|
||||
# Маппинг ключевых слов на группы
|
||||
# Иконки используют CSS классы для адаптации к теме
|
||||
OS_GROUP_MAPPING = {
|
||||
'ubuntu': {'name': 'Ubuntu', 'icon': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>', 'order': 1},
|
||||
'debian': {'name': 'Debian', 'icon': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>', 'order': 2},
|
||||
'centos': {'name': 'CentOS', 'icon': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>', 'order': 3},
|
||||
'rocky': {'name': 'Rocky Linux', 'icon': '<span class="os-icon-rocky-svg"></span>', 'order': 4},
|
||||
'alma': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5},
|
||||
'almalinux': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5},
|
||||
'fedora': {'name': 'Fedora', 'icon': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>', 'order': 6},
|
||||
'rhel': {'name': 'RHEL', 'icon': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>', 'order': 7},
|
||||
'oracle': {'name': 'Oracle Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>', 'order': 8},
|
||||
'suse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9},
|
||||
'opensuse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9},
|
||||
'arch': {'name': 'Arch Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>', 'order': 10},
|
||||
'gentoo': {'name': 'Gentoo', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>', 'order': 11},
|
||||
'alpine': {'name': 'Alpine', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>', 'order': 12},
|
||||
'windows': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20},
|
||||
'win': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20},
|
||||
'freebsd': {'name': 'FreeBSD', 'icon': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>', 'order': 30},
|
||||
'openbsd': {'name': 'OpenBSD', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>', 'order': 31},
|
||||
'other': {'name': 'Other', 'icon': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>', 'order': 99},
|
||||
}
|
||||
_UBUNTU = {'name': 'Ubuntu', 'icon': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>', 'order': 1}
|
||||
_DEBIAN = {'name': 'Debian', 'icon': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>', 'order': 2}
|
||||
_CENTOS = {'name': 'CentOS', 'icon': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>', 'order': 3}
|
||||
_ROCKY = {'name': 'Rocky Linux', 'icon': '<span class="os-icon-rocky-svg"></span>', 'order': 4}
|
||||
_ALMA = {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5}
|
||||
_FEDORA = {'name': 'Fedora', 'icon': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>', 'order': 6}
|
||||
_RHEL = {'name': 'RHEL', 'icon': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>', 'order': 7}
|
||||
_ORACLE = {'name': 'Oracle Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>', 'order': 8}
|
||||
_SUSE = {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9}
|
||||
_ARCH = {'name': 'Arch Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>', 'order': 10}
|
||||
_GENTOO = {'name': 'Gentoo', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>', 'order': 11}
|
||||
_ALPINE = {'name': 'Alpine', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>', 'order': 12}
|
||||
_KALI = {'name': 'Kali Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-kali"></i>', 'order': 13}
|
||||
_MINT = {'name': 'Linux Mint', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-mint"></i>', 'order': 14}
|
||||
_WINDOWS = {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20}
|
||||
_FREEBSD = {'name': 'FreeBSD', 'icon': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>', 'order': 30}
|
||||
_OPENBSD = {'name': 'OpenBSD', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>', 'order': 31}
|
||||
_OTHER = {'name': 'Other', 'icon': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>', 'order': 99}
|
||||
|
||||
# Маппинг ключевых слов на группы. Порядок важен — более специфичные/длинные
|
||||
# ключи должны идти раньше (напр. 'oraclelinux' перед 'oracle', 'rockylinux'
|
||||
# перед 'rocky'), чтобы не перехватывались более общими. Включены частые
|
||||
# опечатки и варианты написания (alama/allma, redhat/red hat, sles/leap/...).
|
||||
_OS_KEYWORD_GROUPS: list[tuple[tuple[str, ...], dict]] = [
|
||||
(('ubuntu',), _UBUNTU),
|
||||
(('debian',), _DEBIAN),
|
||||
(('linuxmint', 'mint'), _MINT),
|
||||
(('centos',), _CENTOS),
|
||||
(('rockylinux', 'rocky'), _ROCKY),
|
||||
(('almalinux', 'alma', 'alama', 'allma', 'alamalinux'), _ALMA),
|
||||
(('fedora',), _FEDORA),
|
||||
(('oraclelinux', 'oracle'), _ORACLE),
|
||||
(('rhel', 'redhat', 'red hat', 'red-hat'), _RHEL),
|
||||
(('opensuse', 'sles', 'suse', 'leap', 'tumbleweed'), _SUSE),
|
||||
(('archlinux', 'arch'), _ARCH),
|
||||
(('gentoo',), _GENTOO),
|
||||
(('alpine',), _ALPINE),
|
||||
(('kali',), _KALI),
|
||||
(('windows', 'win2', 'win10', 'win11', 'winserver', 'win7', 'win8'), _WINDOWS),
|
||||
(('freebsd',), _FREEBSD),
|
||||
(('openbsd',), _OPENBSD),
|
||||
]
|
||||
|
||||
# Обратная совместимость: плоский маппинг keyword → group_info.
|
||||
OS_GROUP_MAPPING = {kw: info for keys, info in _OS_KEYWORD_GROUPS for kw in keys}
|
||||
OS_GROUP_MAPPING['other'] = _OTHER
|
||||
|
||||
|
||||
def detect_os_group(template_name: str) -> dict:
|
||||
"""Определить группу ОС по имени шаблона"""
|
||||
name_lower = template_name.lower()
|
||||
|
||||
for keyword, group_info in OS_GROUP_MAPPING.items():
|
||||
if keyword in name_lower:
|
||||
"""Определить группу ОС по имени шаблона (с учётом вариантов и опечаток)."""
|
||||
name_lower = (template_name or '').lower()
|
||||
|
||||
for keys, group_info in _OS_KEYWORD_GROUPS:
|
||||
if any(kw in name_lower for kw in keys):
|
||||
return group_info
|
||||
|
||||
return OS_GROUP_MAPPING['other']
|
||||
|
||||
return _OTHER
|
||||
|
||||
|
||||
def _do_auto_import(server_id: int, username: str):
|
||||
|
||||
@@ -1295,20 +1295,25 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
# Фильтруем разрешенные параметры
|
||||
allowed_params = {
|
||||
'cores', 'sockets', 'memory', 'balloon', 'name', 'description',
|
||||
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'boot', 'bootdisk',
|
||||
'agent', 'ostype', 'tablet', 'hotplug',
|
||||
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'startup', 'boot', 'bootdisk',
|
||||
'agent', 'ostype', 'tablet', 'hotplug', 'protection',
|
||||
'ciuser', 'cipassword', 'sshkeys',
|
||||
}
|
||||
indexed_re = re.compile(r'^(net|scsi|virtio|ide|sata|ipconfig)\d+$')
|
||||
delete_re = re.compile(r'^net\d+(,net\d+)*$')
|
||||
# Удаляемые ключи: сетевые интерфейсы + опциональные булевы флаги
|
||||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
|
||||
|
||||
filtered_config = {
|
||||
k: v for k, v in config.items()
|
||||
if k in allowed_params or indexed_re.match(k)
|
||||
}
|
||||
# Удаление интерфейсов: delete='net1' (или 'net1,net2')
|
||||
filtered_config = {}
|
||||
for k, v in config.items():
|
||||
if k in ('onboot', 'protection'):
|
||||
filtered_config[k] = 1 if v in (True, 1, '1') else 0
|
||||
elif k in allowed_params or indexed_re.match(k):
|
||||
filtered_config[k] = v
|
||||
# Удаление ключей: delete='net1' / 'startup' (или 'net1,startup')
|
||||
delete_value = config.get('delete')
|
||||
if isinstance(delete_value, str) and delete_re.match(delete_value):
|
||||
if isinstance(delete_value, str) and delete_value and all(
|
||||
delete_token_re.match(tok) for tok in delete_value.split(',')
|
||||
):
|
||||
filtered_config['delete'] = delete_value
|
||||
|
||||
if not filtered_config:
|
||||
@@ -1363,19 +1368,22 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
|
||||
allowed_params = {
|
||||
'hostname', 'memory', 'swap', 'cores', 'cpulimit', 'cpuunits',
|
||||
'onboot', 'description', 'nameserver', 'searchdomain', 'tags',
|
||||
'onboot', 'startup', 'protection', 'description', 'nameserver',
|
||||
'searchdomain', 'tags',
|
||||
}
|
||||
net_re = re.compile(r'^net\d+$')
|
||||
delete_re = re.compile(r'^net\d+(,net\d+)*$')
|
||||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
|
||||
|
||||
filtered_config = {}
|
||||
for k, v in config.items():
|
||||
if k == 'onboot':
|
||||
filtered_config['onboot'] = 1 if v in (True, 1, '1') else 0
|
||||
if k in ('onboot', 'protection'):
|
||||
filtered_config[k] = 1 if v in (True, 1, '1') else 0
|
||||
elif k in allowed_params or net_re.match(k):
|
||||
filtered_config[k] = v
|
||||
delete_value = config.get('delete')
|
||||
if isinstance(delete_value, str) and delete_re.match(delete_value):
|
||||
if isinstance(delete_value, str) and delete_value and all(
|
||||
delete_token_re.match(tok) for tok in delete_value.split(',')
|
||||
):
|
||||
filtered_config['delete'] = delete_value
|
||||
|
||||
if not filtered_config:
|
||||
@@ -2002,6 +2010,49 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.error(f"Ошибка клонирования LXC {source_vmid} -> {new_vmid}: {e}")
|
||||
return None
|
||||
|
||||
def migrate_vm(
|
||||
self,
|
||||
node: str,
|
||||
vmid: int,
|
||||
target_node: str,
|
||||
target_storage: str = None,
|
||||
online: bool = False,
|
||||
with_local_disks: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Мигрировать VM (qemu) на другую ноду.
|
||||
|
||||
Args:
|
||||
node: Текущая нода VM
|
||||
vmid: ID виртуальной машины
|
||||
target_node: Целевая нода
|
||||
target_storage: Целевое хранилище (опционально, storage migration)
|
||||
online: Онлайн (live) миграция для запущенной VM
|
||||
with_local_disks: Мигрировать локальные диски (обязательно для live
|
||||
миграции VM с дисками на local storage)
|
||||
|
||||
Returns:
|
||||
UPID задачи или None
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
params = {'target': target_node}
|
||||
if online:
|
||||
params['online'] = 1
|
||||
if with_local_disks:
|
||||
params['with-local-disks'] = 1
|
||||
if target_storage:
|
||||
params['targetstorage'] = target_storage
|
||||
|
||||
result = self.proxmox.nodes(node).qemu(vmid).migrate.post(**params)
|
||||
logger.info(f"Миграция VM {vmid} с {node} на {target_node}")
|
||||
return result if isinstance(result, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка миграции VM {vmid}: {e}")
|
||||
raise RuntimeError(f"Proxmox: {e}") from e
|
||||
|
||||
def migrate_container(
|
||||
self,
|
||||
node: str,
|
||||
@@ -2039,10 +2090,10 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
|
||||
result = self.proxmox.nodes(node).lxc(vmid).migrate.post(**params)
|
||||
logger.info(f"Миграция LXC {vmid} с {node} на {target_node}")
|
||||
return result
|
||||
return result if isinstance(result, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка миграции LXC {vmid}: {e}")
|
||||
return None
|
||||
raise RuntimeError(f"Proxmox: {e}") from e
|
||||
|
||||
def get_vm_termproxy(self, node: str, vmid: int) -> Dict:
|
||||
"""
|
||||
@@ -2711,3 +2762,43 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.error(f"Error fetching cluster backup jobs: {e}")
|
||||
return []
|
||||
|
||||
def create_cluster_backup_job(self, props: Dict) -> Dict:
|
||||
"""Создать нативное задание бэкапа Proxmox (POST /cluster/backup).
|
||||
|
||||
props — уже подготовленные параметры vzdump (schedule, storage, mode,
|
||||
compress, enabled, all/vmid/pool/node, prune-backups, comment, ...).
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.backup.post(**props)
|
||||
logger.info(f"Создано нативное задание бэкапа: {props.get('id') or '(auto id)'}")
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания задания бэкапа: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_cluster_backup_job(self, job_id: str, props: Dict) -> Dict:
|
||||
"""Обновить нативное задание бэкапа Proxmox (PUT /cluster/backup/{id})."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.backup(job_id).put(**props)
|
||||
logger.info(f"Обновлено нативное задание бэкапа: {job_id}")
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления задания бэкапа {job_id}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_cluster_backup_job(self, job_id: str) -> Dict:
|
||||
"""Удалить нативное задание бэкапа Proxmox (DELETE /cluster/backup/{id})."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.backup(job_id).delete()
|
||||
logger.info(f"Удалено нативное задание бэкапа: {job_id}")
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления задания бэкапа {job_id}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user