Защищено повторное создание и добавлена поддержка LXC template VMID

This commit is contained in:
2026-08-09 17:27:13 +03:00
parent 89e44599cd
commit 6c2f8fcb0c
+73 -98
View File
@@ -1,4 +1,4 @@
"""Роуты для работы с инстансами (VM/LXC) — создание, действия, удаление, статус.""" """Роуты для работы с инстансами VM/LXC."""
import logging import logging
from typing import List from typing import List
@@ -12,14 +12,11 @@ from ..database import SessionLocal, get_db
from ..deps import get_current_user from ..deps import get_current_user
logger = logging.getLogger(__name__) 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: def _ensure_owner(instance: models.Instance, user: models.User) -> None:
"""Проверяет, что инстанс принадлежит пользователю (или пользователь — админ).""" """Проверяет владельца инстанса."""
if user.role == models.Role.admin: if user.role == models.Role.admin:
return return
if instance.owner_id != user.id: if instance.owner_id != user.id:
@@ -27,19 +24,14 @@ def _ensure_owner(instance: models.Instance, user: models.User) -> None:
def _sanitize_name(raw: str) -> str: def _sanitize_name(raw: str) -> str:
"""Имя для Proxmox: только латиница/цифры/дефис, в нижнем регистре.""" """Оставляет в имени только латиницу, цифры и дефис."""
cleaned = "".join(c for c in raw.strip() if c.isalnum() or c == "-").lower() cleaned = "".join(c for c in raw.strip() if c.isalnum() or c == "-").lower()
return cleaned or "vps" return cleaned or "vps"
# ---------- list ----------
@router.get("", response_model=List[schemas.InstanceOut]) @router.get("", response_model=List[schemas.InstanceOut])
def list_my_instances( def list_my_instances(db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
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:
@@ -47,14 +39,8 @@ def list_my_instances(
return query.order_by(models.Instance.created_at.desc()).all() return query.order_by(models.Instance.created_at.desc()).all()
# ---------- create ----------
def _provision(instance_id: int, template_id: int, node: str) -> None: def _provision(instance_id: int, template_id: int, node: str) -> None:
"""Фоновое создание VPS через Proxmox API. """Создаёт VPS в фоновой задаче и сохраняет результат в базе."""
Запускается через 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()
@@ -64,22 +50,33 @@ def _provision(instance_id: int, template_id: int, node: str) -> None:
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:
instance.status = models.InstanceStatus.error instance.status = models.InstanceStatus.error
instance.root_password = "ERROR: шаблон не найден"
db.commit() db.commit()
return return
try: try:
clean_name = _sanitize_name(instance.name) clean_name = _sanitize_name(instance.name)
if template.guest_type == models.GuestType.vm: 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.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( pve.configure_cloud_init(instance.vmid, instance.ciuser, instance.root_password, node)
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:
password = instance.root_password or pve.gen_password()
if template.source_vmid:
pve.clone_lxc(
source_vmid=template.source_vmid,
new_vmid=instance.vmid,
name=clean_name,
cores=template.cores,
memory_mb=template.memory_mb,
password=password,
node=node,
)
elif template.source_template:
password = pve.create_lxc( password = pve.create_lxc(
new_vmid=instance.vmid, new_vmid=instance.vmid,
name=clean_name, name=clean_name,
@@ -89,12 +86,15 @@ def _provision(instance_id: int, template_id: int, node: str) -> None:
disk_gb=template.disk_gb, disk_gb=template.disk_gb,
node=node, node=node,
) )
else:
raise ValueError("У LXC-шаблона не указан source_vmid или source_template")
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) logger.info("Инстанс %s (vmid=%s) создан и запущен", instance.id, instance.vmid)
except Exception as exc: except Exception as exc:
logger.exception("Ошибка провижининга instance %s", instance_id) logger.exception("Ошибка создания инстанса %s", instance_id)
instance.status = models.InstanceStatus.error instance.status = models.InstanceStatus.error
instance.root_password = f"ERROR: {type(exc).__name__}: {exc}" instance.root_password = f"ERROR: {type(exc).__name__}: {exc}"
db.commit() db.commit()
@@ -109,7 +109,28 @@ 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),
): ):
"""Создаёт инстанс из шаблона. VMID берётся у Proxmox.""" """Регистрирует один инстанс и запускает его создание в фоне.
Блокировка строки пользователя и проверка статуса creating не позволяют
повторному клику создать несколько VPS для одного пользователя.
"""
# Блокируем пользователя на время резервирования VMID и записи инстанса.
# Это защищает от двух почти одновременных POST-запросов из браузера.
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 = ( template = (
db.query(models.Template) db.query(models.Template)
.filter(models.Template.id == payload.template_id, models.Template.is_active == True) # noqa: E712 .filter(models.Template.id == payload.template_id, models.Template.is_active == True) # noqa: E712
@@ -118,10 +139,6 @@ def create_instance(
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):
try: try:
vmid = pve.get_next_vmid() vmid = pve.get_next_vmid()
except Exception as exc: except Exception as exc:
@@ -136,31 +153,8 @@ def create_instance(
) )
.first() .first()
) )
if not active: if active:
break raise HTTPException(status_code=409, detail=f"VMID {vmid} уже используется в панели")
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) clean_name = _sanitize_name(payload.name)
instance = models.Instance( instance = models.Instance(
@@ -181,14 +175,9 @@ 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( def get_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
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 не найден")
@@ -197,11 +186,8 @@ def get_instance(
@router.get("/{instance_id}/status") @router.get("/{instance_id}/status")
def get_instance_status( def get_instance_status(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance_id: int, """Возвращает фактический статус инстанса из Proxmox."""
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 не найден")
@@ -209,36 +195,27 @@ def get_instance_status(
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) logger.warning("get_status: 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", response_model=schemas.InstanceIPOut) @router.get("/{instance_id}/ip", response_model=schemas.InstanceIPOut)
def get_instance_ip( def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance_id: int, """Возвращает IP-адрес инстанса."""
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) _ensure_owner(instance, user)
try: try:
ip = pve.get_instance_ip(instance.vmid, instance.node) return schemas.InstanceIPOut(ip=pve.get_instance_ip(instance.vmid, instance.node))
return schemas.InstanceIPOut(ip=ip)
except Exception as exc: except Exception as exc:
logger.warning("get_instance_ip: vmid=%s: %s", instance.vmid, exc) logger.warning("get_instance_ip: vmid=%s: %s", instance.vmid, exc)
return schemas.InstanceIPOut(ip=f"ошибка: {exc}") return schemas.InstanceIPOut(ip=f"ошибка: {exc}")
@router.get("/{instance_id}/live") @router.get("/{instance_id}/live")
def get_instance_live( def get_instance_live(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance_id: int, """Возвращает live-показатели CPU, RAM и uptime."""
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() 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 не найден")
@@ -251,13 +228,8 @@ def get_instance_live(
@router.post("/{instance_id}/action") @router.post("/{instance_id}/action")
def instance_action( def instance_action(instance_id: int, payload: schemas.InstanceAction, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance_id: int, """Выполняет действие start, stop, shutdown или reboot."""
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 не найден")
@@ -269,7 +241,6 @@ def instance_action(
except Exception as exc: except Exception as exc:
logger.warning("instance_action: vmid=%s action=%s: %s", instance.vmid, payload.action, 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"):
@@ -279,21 +250,25 @@ def instance_action(
@router.delete("/{instance_id}") @router.delete("/{instance_id}")
def delete_instance( def delete_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance_id: int, """Останавливает работающую VM/LXC и удаляет её из Proxmox."""
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) _ensure_owner(instance, user)
if instance.status == models.InstanceStatus.deleting:
raise HTTPException(status_code=409, detail="Инстанс уже удаляется")
instance.status = models.InstanceStatus.deleting
db.commit()
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) logger.exception("Ошибка удаления instance_id=%s", instance_id)
instance.status = models.InstanceStatus.error
db.commit()
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()
return {"ok": True} return {"ok": True}