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

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 typing import List
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload 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 ..config import settings
from ..database import get_db from ..database import SessionLocal, get_db
from ..deps import get_current_user from ..deps import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/instances", tags=["instances"]) 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]) @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 = db.query(models.Instance).options(joinedload(models.Instance.template))
query = query.filter(models.Instance.status != models.InstanceStatus.deleted) query = query.filter(models.Instance.status != models.InstanceStatus.deleted)
if user.role != models.Role.admin: 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() return query.order_by(models.Instance.created_at.desc()).all()
def _provision(instance_id: int, template_id: int, node: str): # ---------- create ----------
"""Фоновое создание VPS через Proxmox API."""
from ..database import SessionLocal def _provision(instance_id: int, template_id: int, node: str) -> None:
"""Фоновое создание VPS через Proxmox API.
Запускается через BackgroundTasks. Внутри — своя сессия БД,
потому что основной запрос уже вернул ответ клиенту.
"""
db = SessionLocal() db = SessionLocal()
try: try:
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance: if not instance:
logger.warning("provision: instance %s не найден", instance_id)
return return
template = db.query(models.Template).filter(models.Template.id == template_id).first() template = db.query(models.Template).filter(models.Template.id == template_id).first()
if not template: if not template:
@@ -32,29 +67,37 @@ def _provision(instance_id: int, template_id: int, node: str):
instance.root_password = "ERROR: шаблон не найден" instance.root_password = "ERROR: шаблон не найден"
db.commit() db.commit()
return return
try: try:
clean_name = _sanitize_name(instance.name)
if template.guest_type == models.GuestType.vm: 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.clone_vm(template.source_vmid, instance.vmid, clean_name, node)
pve.resize_vm(instance.vmid, template.cores, template.memory_mb, node) pve.resize_vm(instance.vmid, template.cores, template.memory_mb, node)
pve.resize_disk(instance.vmid, template.disk_gb, node) pve.resize_disk(instance.vmid, template.disk_gb, node)
if instance.ciuser and instance.root_password: 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) pve.guest_action("vm", instance.vmid, "start", node)
else: else:
clean_name = ''.join(c for c in instance.name.strip() if c.isalnum() or c == '-').lower() or 'lxc'
password = pve.create_lxc( password = pve.create_lxc(
new_vmid=instance.vmid, name=clean_name, new_vmid=instance.vmid,
name=clean_name,
template_volid=template.source_template, template_volid=template.source_template,
cores=template.cores, memory_mb=template.memory_mb, cores=template.cores,
disk_gb=template.disk_gb, node=node, memory_mb=template.memory_mb,
disk_gb=template.disk_gb,
node=node,
) )
instance.root_password = password instance.root_password = password
pve.guest_action("lxc", instance.vmid, "start", node) pve.guest_action("lxc", instance.vmid, "start", node)
instance.status = models.InstanceStatus.running instance.status = models.InstanceStatus.running
logger.info("instance %s (vmid=%s) успешно создан и запущен", instance.id, instance.vmid)
except Exception as exc: except Exception as exc:
logger.exception("Ошибка провижининга instance %s", instance_id)
instance.status = models.InstanceStatus.error 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() db.commit()
finally: finally:
db.close() db.close()
@@ -67,46 +110,68 @@ def create_instance(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
): ):
template = db.query(models.Template).filter( """Создаёт инстанс из шаблона. VMID берётся у Proxmox."""
models.Template.id == payload.template_id, models.Template.is_active == True template = (
).first() db.query(models.Template)
.filter(models.Template.id == payload.template_id, models.Template.is_active == True) # noqa: E712
.first()
)
if not template: if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден") raise HTTPException(status_code=404, detail="Шаблон не найден")
# Запрашиваем VMID у Proxmox. Если в БД уже есть запись с таким VMID
# в "мёртвом" статусе — удаляем её, иначе insert упадёт с unique violation.
vmid = None
for _ in range(100): for _ in range(100):
try: try:
vmid = pve.get_next_vmid() vmid = pve.get_next_vmid()
except Exception as exc: except Exception as exc:
logger.error("Proxmox недоступен при запросе nextid: %s", exc)
raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}") raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}")
exists = db.query(models.Instance).filter(
models.Instance.vmid == vmid, active = (
models.Instance.status != models.InstanceStatus.deleted, db.query(models.Instance)
).first() .filter(
if not exists: models.Instance.vmid == vmid,
models.Instance.status != models.InstanceStatus.deleted,
)
.first()
)
if not active:
break break
else: else:
raise HTTPException(status_code=409, detail="Не удалось найти свободный VMID") 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 — вычищаем мёртвые записи, # Чистим «мёртвые» записи с тем же VMID.
# иначе INSERT упадёт с duplicate key по ix_instances_vmid stale = (
stale = db.query(models.Instance).filter( db.query(models.Instance)
models.Instance.vmid == vmid, .filter(
models.Instance.status.in_([ models.Instance.vmid == vmid,
models.InstanceStatus.error, models.Instance.status.in_([
models.InstanceStatus.deleted, models.InstanceStatus.error,
models.InstanceStatus.deleting, models.InstanceStatus.deleted,
]), models.InstanceStatus.deleting,
).all() ]),
)
.all()
)
for s in stale: for s in stale:
db.delete(s) db.delete(s)
if stale: if stale:
db.commit() db.commit()
clean_name = _sanitize_name(payload.name)
instance = models.Instance( instance = models.Instance(
name=clean_name, vmid=vmid, node=settings.pve_node, name=clean_name,
guest_type=template.guest_type, status=models.InstanceStatus.creating, vmid=vmid,
owner_id=user.id, template_id=template.id, 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, ciuser=payload.ciuser or None,
root_password=payload.cipassword or None, root_password=payload.cipassword or None,
) )
@@ -117,46 +182,95 @@ def create_instance(
return instance return instance
# ---------- single-instance endpoints ----------
@router.get("/{instance_id}", response_model=schemas.InstanceOut) @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() instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance: if not instance:
raise HTTPException(status_code=404, detail="VPS не найден") raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
return instance return instance
@router.get("/{instance_id}/status") @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() instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance: if not instance:
raise HTTPException(status_code=404, detail="VPS не найден") raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try: try:
return pve.get_status(instance.guest_type.value, instance.vmid, instance.node) return pve.get_status(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc: except Exception as exc:
logger.warning("get_status: proxmox недоступен для vmid=%s: %s", instance.vmid, exc)
raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}") raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}")
@router.get("/{instance_id}/ip") @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)): 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() instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance: if not instance:
raise HTTPException(status_code=404, detail="VPS не найден") raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try: try:
ip = pve.get_instance_ip(instance.vmid, instance.node) ip = pve.get_instance_ip(instance.vmid, instance.node)
return {"ip": ip} return schemas.InstanceIPOut(ip=ip)
except Exception as exc: 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") @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() instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance: if not instance:
raise HTTPException(status_code=404, detail="VPS не найден") raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try: try:
pve.guest_action(instance.guest_type.value, instance.vmid, payload.action, instance.node) 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: 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}") raise HTTPException(status_code=502, detail=f"Ошибка: {exc}")
if payload.action == "start": if payload.action == "start":
instance.status = models.InstanceStatus.running instance.status = models.InstanceStatus.running
elif payload.action in ("stop", "shutdown"): elif payload.action in ("stop", "shutdown"):
@@ -165,53 +279,21 @@ def instance_action(instance_id: int, payload: schemas.InstanceAction, db: Sessi
return {"ok": True} 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}") @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() instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance: if not instance:
raise HTTPException(status_code=404, detail="VPS не найден") raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try: try:
pve.delete_guest(instance.guest_type.value, instance.vmid, instance.node) pve.delete_guest(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc: except Exception as exc:
logger.warning("delete_instance: vmid=%s: %s", instance.vmid, exc)
raise HTTPException(status_code=502, detail=f"Ошибка удаления: {exc}") raise HTTPException(status_code=502, detail=f"Ошибка удаления: {exc}")
instance.status = models.InstanceStatus.deleted instance.status = models.InstanceStatus.deleted
db.commit() db.commit()
@@ -219,13 +301,20 @@ def delete_instance(instance_id: int, db: Session = Depends(get_db), user: model
@router.get("/{instance_id}/console") @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() instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance: if not instance:
raise HTTPException(status_code=404, detail="VPS не найден") raise HTTPException(status_code=404, detail="VPS не найден")
_ensure_owner(instance, user)
try: try:
ticket = pve.get_vnc_ticket(instance.guest_type.value, instance.vmid, instance.node) ticket = pve.get_vnc_ticket(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc: except Exception as exc:
logger.warning("instance_console: vmid=%s: %s", instance.vmid, exc)
raise HTTPException(status_code=502, detail=f"Консоль недоступна: {exc}") raise HTTPException(status_code=502, detail=f"Консоль недоступна: {exc}")
ticket["instance_id"] = instance.id ticket["instance_id"] = instance.id
return ticket return ticket