287 lines
14 KiB
Python
287 lines
14 KiB
Python
"""Роуты для работы с инстансами 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}
|