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)

View File

@@ -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):

View File

@@ -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)}