diff --git a/backend/app/routers/instances.py b/backend/app/routers/instances.py index c09a3bb..e79be57 100644 --- a/backend/app/routers/instances.py +++ b/backend/app/routers/instances.py @@ -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