From 6c2f8fcb0c6783ebaf5824eea90cc020802951d2 Mon Sep 17 00:00:00 2001 From: host Date: Sun, 9 Aug 2026 17:27:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D1=89=D0=B8=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20LXC=20templa?= =?UTF-8?q?te=20VMID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/instances.py | 205 ++++++++++++++----------------- 1 file changed, 90 insertions(+), 115 deletions(-) diff --git a/backend/app/routers/instances.py b/backend/app/routers/instances.py index bd88d74..56f2ed4 100644 --- a/backend/app/routers/instances.py +++ b/backend/app/routers/instances.py @@ -1,4 +1,4 @@ -"""Роуты для работы с инстансами (VM/LXC) — создание, действия, удаление, статус.""" +"""Роуты для работы с инстансами VM/LXC.""" import logging from typing import List @@ -12,14 +12,11 @@ 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: @@ -27,19 +24,14 @@ def _ensure_owner(instance: models.Instance, user: models.User) -> None: 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: @@ -47,14 +39,8 @@ def list_my_instances( 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. Внутри — своя сессия БД, - потому что основной запрос уже вернул ответ клиенту. - """ + """Создаёт VPS в фоновой задаче и сохраняет результат в базе.""" db = SessionLocal() try: instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() @@ -64,37 +50,51 @@ def _provision(instance_id: int, template_id: int, node: str) -> None: 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: + 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.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, - ) + 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( + 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, + ) + else: + raise ValueError("У LXC-шаблона не указан source_vmid или source_template") 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) + logger.info("Инстанс %s (vmid=%s) создан и запущен", instance.id, instance.vmid) except Exception as exc: - logger.exception("Ошибка провижининга instance %s", instance_id) + logger.exception("Ошибка создания инстанса %s", instance_id) instance.status = models.InstanceStatus.error instance.root_password = f"ERROR: {type(exc).__name__}: {exc}" db.commit() @@ -109,7 +109,28 @@ def create_instance( db: Session = Depends(get_db), 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 = ( db.query(models.Template) .filter(models.Template.id == payload.template_id, models.Template.is_active == True) # noqa: E712 @@ -118,49 +139,22 @@ def create_instance( 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}") + 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 = ( + active = ( db.query(models.Instance) .filter( models.Instance.vmid == vmid, - models.Instance.status.in_([ - models.InstanceStatus.error, - models.InstanceStatus.deleted, - models.InstanceStatus.deleting, - ]), + models.Instance.status != models.InstanceStatus.deleted, ) - .all() + .first() ) - for s in stale: - db.delete(s) - if stale: - db.commit() + if active: + raise HTTPException(status_code=409, detail=f"VMID {vmid} уже используется в панели") clean_name = _sanitize_name(payload.name) instance = models.Instance( @@ -181,14 +175,9 @@ 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 не найден") @@ -197,11 +186,8 @@ def get_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)): + """Возвращает фактический статус инстанса из Proxmox.""" instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first() if not instance: raise HTTPException(status_code=404, detail="VPS не найден") @@ -209,36 +195,27 @@ def get_instance_status( 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) + 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-адрес инстанса (через QEMU Guest Agent или LXC-интерфейсы).""" +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: - ip = pve.get_instance_ip(instance.vmid, instance.node) - return schemas.InstanceIPOut(ip=ip) + 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.""" +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 не найден") @@ -251,13 +228,8 @@ def get_instance_live( @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.""" +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 не найден") @@ -269,7 +241,6 @@ def instance_action( 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"): @@ -279,21 +250,25 @@ def instance_action( @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.""" +def delete_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): + """Останавливает работающую VM/LXC и удаляет её из 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) + if instance.status == models.InstanceStatus.deleting: + raise HTTPException(status_code=409, detail="Инстанс уже удаляется") + + instance.status = models.InstanceStatus.deleting + db.commit() 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) + logger.exception("Ошибка удаления instance_id=%s", instance_id) + instance.status = models.InstanceStatus.error + db.commit() raise HTTPException(status_code=502, detail=f"Ошибка удаления: {exc}") + instance.status = models.InstanceStatus.deleted db.commit() return {"ok": True}