Files
2026-08-09 22:48:08 +03:00

287 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Роуты для работы с инстансами VM/LXC."""
import logging
from typing import List
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from .. import models, proxmox_client as pve, schemas
from ..config import settings
from ..database import SessionLocal, get_db
from ..deps import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/instances", tags=["instances"])
def _ensure_owner(instance: models.Instance, user: models.User) -> None:
"""Проверяет владельца инстанса."""
if user.role == models.Role.admin:
return
if instance.owner_id != user.id:
raise HTTPException(status_code=404, detail="VPS не найден")
def _sanitize_name(raw: str) -> str:
"""Оставляет в имени только латиницу, цифры и дефис."""
cleaned = "".join(c for c in raw.strip() if c.isalnum() or c == "-").lower()
return cleaned or "vps"
@router.get("", response_model=List[schemas.InstanceOut])
def list_my_instances(db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Возвращает список инстансов пользователя или всех инстансов для администратора."""
query = db.query(models.Instance).options(joinedload(models.Instance.template))
query = query.filter(models.Instance.status != models.InstanceStatus.deleted)
if user.role != models.Role.admin:
query = query.filter(models.Instance.owner_id == user.id)
return query.order_by(models.Instance.created_at.desc()).all()
def _provision(instance_id: int, template_id: int, node: str) -> None:
"""Создаёт VPS в фоновой задаче и сохраняет результат в базе."""
db = SessionLocal()
try:
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
logger.warning("provision: instance %s не найден", instance_id)
return
template = db.query(models.Template).filter(models.Template.id == template_id).first()
if not template:
instance.status = models.InstanceStatus.error
db.commit()
return
try:
clean_name = _sanitize_name(instance.name)
if template.guest_type == models.GuestType.vm:
if not template.source_vmid:
raise ValueError("У VM-шаблона не указан source_vmid")
pve.clone_vm(template.source_vmid, instance.vmid, clean_name, node)
pve.resize_vm(instance.vmid, template.cores, template.memory_mb, node)
pve.resize_disk(instance.vmid, template.disk_gb, node)
if instance.ciuser and instance.root_password:
pve.configure_cloud_init(instance.vmid, instance.ciuser, instance.root_password, node)
pve.guest_action("vm", instance.vmid, "start", node)
else:
# LXC создаётся ТОЛЬКО из архивного шаблона (vztmpl).
# Создание из существующего LXC по VMID не поддерживается.
if not template.source_template:
raise ValueError("У LXC-шаблона не указан source_template")
if not instance.root_password:
raise ValueError("Для LXC необходимо задать пароль")
# Пароль задаёт сам пользователь в форме — приходит через cipassword
# и сохраняется в instance.root_password. Передаём его в create_lxc.
pve.create_lxc(
new_vmid=instance.vmid,
name=clean_name,
template_volid=template.source_template,
password=instance.root_password,
cores=template.cores,
memory_mb=template.memory_mb,
disk_gb=template.disk_gb,
node=node,
)
instance.status = models.InstanceStatus.running
logger.info("Инстанс %s (vmid=%s) создан и запущен", instance.id, instance.vmid)
except Exception as exc:
logger.exception("Ошибка создания инстанса %s", instance_id)
instance.status = models.InstanceStatus.error
instance.root_password = f"ERROR: {type(exc).__name__}: {exc}"
db.commit()
finally:
db.close()
@router.post("", response_model=schemas.InstanceOut)
def create_instance(
payload: schemas.InstanceCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: models.User = Depends(get_current_user),
):
"""Регистрирует один инстанс и запускает его создание в фоне.
Бизнес-логика VMID:
1. Запрашиваем ``nextid`` у Proxmox (он возвращает следующий свободный VMID
в кластере — то есть тот, что реально не занят ни в Proxmox).
2. Проверяем в БД, что этот VMID не используется активным инстансом
(``status != deleted``). Если есть запись со статусом deleted — это
«мусор» от старых удалений, который мы НЕ должны учитывать (nextid
от Proxmox пришёл, значит в Proxmox этот VMID свободен).
3. Если конфликт — запрашиваем новый VMID через ``nextid``
(онлайн-цикл до 5 попыток).
"""
db.query(models.User).filter(models.User.id == user.id).with_for_update().one()
pending = (
db.query(models.Instance)
.filter(
models.Instance.owner_id == user.id,
models.Instance.status == models.InstanceStatus.creating,
)
.first()
)
if pending:
raise HTTPException(status_code=409, detail="Предыдущий VPS ещё создаётся. Дождитесь завершения операции.")
template = (
db.query(models.Template)
.filter(models.Template.id == payload.template_id, models.Template.is_active == True) # noqa: E712
.first()
)
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
# Получаем VMID, который точно свободен: nextid из Proxmox + проверка БД.
# Если в БД висит «удалённая» запись с этим VMID (мусор), пропускаем —
# запрашиваем следующий. Proxmox при повторном nextid может вернуть тот же
# VMID, потому что он сам не знает про нашу БД. Поэтому цикл по Proxmox
# ограничен сверху, а грязные записи в БД лучше чистить заранее
# (разовая операция, см. README).
vmid = None
for _ in range(5):
try:
candidate = pve.get_next_vmid()
except Exception as exc:
logger.error("Proxmox недоступен при запросе nextid: %s", exc)
raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}")
active = (
db.query(models.Instance)
.filter(
models.Instance.vmid == candidate,
models.Instance.status != models.InstanceStatus.deleted,
)
.first()
)
if not active:
vmid = candidate
break
if vmid is None:
raise HTTPException(
status_code=409,
detail="Не удалось получить свободный VMID — несколько попыток подряд вернули занятый VMID. "
"Очистите БД от записей со статусом 'deleted' или повторите позже.",
)
instance = models.Instance(
name=_sanitize_name(payload.name),
vmid=vmid,
node=settings.pve_node,
guest_type=template.guest_type,
status=models.InstanceStatus.creating,
owner_id=user.id,
template_id=template.id,
ciuser=payload.ciuser or None,
root_password=payload.cipassword or None,
)
db.add(instance)
db.commit()
db.refresh(instance)
background_tasks.add_task(_provision, instance.id, template.id, settings.pve_node)
return instance
@router.get("/{instance_id}", response_model=schemas.InstanceOut)
def get_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Возвращает один инстанс."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
return instance
@router.get("/{instance_id}/status")
def get_instance_status(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Возвращает фактический статус инстанса из Proxmox."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try:
return pve.get_status(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc:
logger.warning("get_status: vmid=%s: %s", instance.vmid, exc)
raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}")
@router.get("/{instance_id}/ip", response_model=schemas.InstanceIPOut)
def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Возвращает IP-адрес инстанса."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try:
return schemas.InstanceIPOut(ip=pve.get_instance_ip(instance.vmid, instance.node))
except Exception as exc:
logger.warning("get_instance_ip: vmid=%s: %s", instance.vmid, exc)
return schemas.InstanceIPOut(ip=f"ошибка: {exc}")
@router.get("/{instance_id}/live")
def get_instance_live(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Возвращает live-показатели CPU, RAM и uptime."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try:
return pve.get_live_stats(instance.vmid, instance.node)
except Exception as exc:
logger.warning("get_instance_live: vmid=%s: %s", instance.vmid, exc)
return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "error", "error": str(exc)}
@router.post("/{instance_id}/action")
def instance_action(instance_id: int, payload: schemas.InstanceAction, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Выполняет действие start, stop, shutdown или reboot."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try:
pve.guest_action(instance.guest_type.value, instance.vmid, payload.action, instance.node)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.warning("instance_action: vmid=%s action=%s: %s", instance.vmid, payload.action, exc)
raise HTTPException(status_code=502, detail=f"Ошибка: {exc}")
if payload.action == "start":
instance.status = models.InstanceStatus.running
elif payload.action in ("stop", "shutdown"):
instance.status = models.InstanceStatus.stopped
db.commit()
return {"ok": True}
@router.delete("/{instance_id}")
def delete_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Останавливает работающую VM/LXC и удаляет её из Proxmox.
После успешного удаления в Proxmox — физически удаляет запись из БД,
чтобы VMID можно было использовать повторно (``nextid`` от Proxmox всегда
вернёт свободный ID, а наша БД не должна блокировать его из-за мусора).
"""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
if instance.status == models.InstanceStatus.deleting:
raise HTTPException(status_code=409, detail="Инстанс уже удаляется")
vmid_for_log = instance.vmid # сохраняем для логов до удаления ORM-объекта
try:
pve.delete_guest(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc:
logger.exception("Ошибка удаления instance_id=%s", instance_id)
instance.status = models.InstanceStatus.error
db.commit()
raise HTTPException(status_code=502, detail=f"Ошибка удаления: {exc}")
# Физическое удаление записи — VMID освобождается для повторного использования.
db.delete(instance)
db.commit()
logger.info("Инстанс vmid=%s удалён из БД (был id=%s)", vmid_for_log, instance_id)
return {"ok": True}