Files
Proxmox-VPS-Panel/backend/app/routers/instances.py
T

300 lines
12 KiB
Python
Raw 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"])
# ---------- helpers ----------
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:
"""Имя для Proxmox: только латиница/цифры/дефис, в нижнем регистре."""
cleaned = "".join(c for c in raw.strip() if c.isalnum() or c == "-").lower()
return cleaned or "vps"
# ---------- list ----------
@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()
# ---------- create ----------
def _provision(instance_id: int, template_id: int, node: str) -> None:
"""Фоновое создание VPS через Proxmox API.
Запускается через BackgroundTasks. Внутри — своя сессия БД,
потому что основной запрос уже вернул ответ клиенту.
"""
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
instance.root_password = "ERROR: шаблон не найден"
db.commit()
return
try:
clean_name = _sanitize_name(instance.name)
if template.guest_type == models.GuestType.vm:
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:
password = pve.create_lxc(
new_vmid=instance.vmid,
name=clean_name,
template_volid=template.source_template,
cores=template.cores,
memory_mb=template.memory_mb,
disk_gb=template.disk_gb,
node=node,
)
instance.root_password = password
pve.guest_action("lxc", instance.vmid, "start", node)
instance.status = models.InstanceStatus.running
logger.info("instance %s (vmid=%s) успешно создан и запущен", instance.id, instance.vmid)
except Exception as exc:
logger.exception("Ошибка провижининга instance %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 берётся у Proxmox."""
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 у Proxmox. Если в БД уже есть запись с таким VMID
# в "мёртвом" статусе — удаляем её, иначе insert упадёт с unique violation.
vmid = None
for _ in range(100):
try:
vmid = 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 == vmid,
models.Instance.status != models.InstanceStatus.deleted,
)
.first()
)
if not active:
break
else:
raise HTTPException(status_code=409, detail="Не удалось найти свободный VMID")
if vmid is None:
raise HTTPException(status_code=502, detail="Не удалось получить VMID от Proxmox")
# Чистим «мёртвые» записи с тем же VMID.
stale = (
db.query(models.Instance)
.filter(
models.Instance.vmid == vmid,
models.Instance.status.in_([
models.InstanceStatus.error,
models.InstanceStatus.deleted,
models.InstanceStatus.deleting,
]),
)
.all()
)
for s in stale:
db.delete(s)
if stale:
db.commit()
clean_name = _sanitize_name(payload.name)
instance = models.Instance(
name=clean_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
# ---------- single-instance endpoints ----------
@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),
):
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: proxmox недоступен для 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-адрес инстанса (через QEMU Guest Agent или LXC-интерфейсы)."""
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:
ip = pve.get_instance_ip(instance.vmid, instance.node)
return schemas.InstanceIPOut(ip=ip)
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 и помечает запись как deleted."""
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.delete_guest(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc:
logger.warning("delete_instance: vmid=%s: %s", instance.vmid, exc)
raise HTTPException(status_code=502, detail=f"Ошибка удаления: {exc}")
instance.status = models.InstanceStatus.deleted
db.commit()
return {"ok": True}