Обновление файла

This commit is contained in:
2026-07-24 22:05:52 +03:00
parent be0df33ac7
commit b4c545d5b9
+173 -84
View File
@@ -1,16 +1,45 @@
"""Роуты для работы с инстансами (VM/LXC) — создание, действия, удаление, статус, консоль."""
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from .. import models, schemas, proxmox_client as pve
from .. import models, proxmox_client as pve, schemas
from ..config import settings
from ..database import get_db
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)):
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:
@@ -18,13 +47,19 @@ def list_my_instances(db: Session = Depends(get_db), user: models.User = Depends
return query.order_by(models.Instance.created_at.desc()).all()
def _provision(instance_id: int, template_id: int, node: str):
"""Фоновое создание VPS через Proxmox API."""
from ..database import SessionLocal
# ---------- 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:
@@ -32,29 +67,37 @@ def _provision(instance_id: int, template_id: int, node: str):
instance.root_password = "ERROR: шаблон не найден"
db.commit()
return
try:
clean_name = _sanitize_name(instance.name)
if template.guest_type == models.GuestType.vm:
clean_name = ''.join(c for c in instance.name.strip() if c.isalnum() or c == '-').lower() or '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.configure_cloud_init(
instance.vmid, instance.ciuser, instance.root_password, node
)
pve.guest_action("vm", instance.vmid, "start", node)
else:
clean_name = ''.join(c for c in instance.name.strip() if c.isalnum() or c == '-').lower() or 'lxc'
password = pve.create_lxc(
new_vmid=instance.vmid, name=clean_name,
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,
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: {exc}"
# Не возвращаем длинный trace в root_password — только краткое сообщение.
instance.root_password = f"ERROR: {type(exc).__name__}: {exc}"
db.commit()
finally:
db.close()
@@ -67,46 +110,68 @@ def create_instance(
db: Session = Depends(get_db),
user: models.User = Depends(get_current_user),
):
template = db.query(models.Template).filter(
models.Template.id == payload.template_id, models.Template.is_active == True
).first()
"""Создаёт инстанс из шаблона. 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}")
exists = db.query(models.Instance).filter(
models.Instance.vmid == vmid,
models.Instance.status != models.InstanceStatus.deleted,
).first()
if not exists:
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")
clean_name = ''.join(c for c in payload.name.strip() if c.isalnum() or c == '-').lower() or 'vps'
if vmid is None:
raise HTTPException(status_code=502, detail="Не удалось получить VMID от Proxmox")
# Proxmox переиспользует освободившиеся vmid — вычищаем мёртвые записи,
# иначе INSERT упадёт с duplicate key по ix_instances_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()
# Чистим «мёртвые» записи с тем же 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,
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,
)
@@ -117,46 +182,95 @@ def create_instance(
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)):
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)):
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")
def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
@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 {"ip": ip}
return schemas.InstanceIPOut(ip=ip)
except Exception as exc:
return {"ip": f"ошибка: {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)):
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"):
@@ -165,53 +279,21 @@ def instance_action(instance_id: int, payload: schemas.InstanceAction, db: Sessi
return {"ok": True}
@router.get("/{instance_id}/ip")
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 не найден")
if user.role != models.Role.admin and instance.owner_id != user.id:
raise HTTPException(status_code=403, detail="Нет доступа")
try:
ip = pve.get_instance_ip(instance.vmid, instance.node)
return {"ip": ip}
except Exception as exc:
return {"ip": f"ошибка: {exc}"}
@router.get("/{instance_id}/ip")
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 не найден")
if user.role != models.Role.admin and instance.owner_id != user.id:
raise HTTPException(status_code=403, detail="Нет доступа")
try:
ip = pve.get_instance_ip(instance.vmid, instance.node)
return {"ip": ip}
except Exception as exc:
return {"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/Memory показатели."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
try:
return pve.get_live_stats(instance.vmid, instance.node)
except Exception as exc:
return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "error", "error": str(exc)}
@router.delete("/{instance_id}")
def delete_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
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()
@@ -219,13 +301,20 @@ def delete_instance(instance_id: int, db: Session = Depends(get_db), user: model
@router.get("/{instance_id}/console")
def instance_console(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
def instance_console(
instance_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(get_current_user),
):
"""Возвращает данные для подключения noVNC-консоли через websocket-прокси."""
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:
ticket = pve.get_vnc_ticket(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc:
logger.warning("instance_console: vmid=%s: %s", instance.vmid, exc)
raise HTTPException(status_code=502, detail=f"Консоль недоступна: {exc}")
ticket["instance_id"] = instance.id
return ticket