"""Роуты для работы с инстансами (VM/LXC) — создание, действия, удаление, статус.""" import logging from typing import List from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy.orm import Session, joinedload from .. import models, proxmox_client as pve, schemas from ..config import settings 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), ): """Список инстансов. Админ видит все, клиент — только свои.""" 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: query = query.filter(models.Instance.owner_id == user.id) 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. Внутри — своя сессия БД, потому что основной запрос уже вернул ответ клиенту. """ 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: 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: 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.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, ) 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: {type(exc).__name__}: {exc}" db.commit() finally: db.close() @router.post("", response_model=schemas.InstanceOut) def create_instance( payload: schemas.InstanceCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db), user: models.User = Depends(get_current_user), ): """Создаёт инстанс из шаблона. 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}") 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 = ( 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, ciuser=payload.ciuser or None, root_password=payload.cipassword or None, ) db.add(instance) db.commit() db.refresh(instance) background_tasks.add_task(_provision, instance.id, template.id, settings.pve_node) 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), ): 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), ): 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", 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 schemas.InstanceIPOut(ip=ip) 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.""" 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), ): """Действие над инстансом: 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"): instance.status = models.InstanceStatus.stopped db.commit() return {"ok": True} @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.""" 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() return {"ok": True}