Files
DevOpsLab/app/api/v1/endpoints/presets.py
Сергей Антропов d4b0d6f848 Исправление синтаксической ошибки в molecule_executor.py и обновление k8s preset'ов
- Исправлена незакрытая скобка в _build_test_command (строка 745)
- Добавлена поддержка k8s preset'ов: выполнение create_k8s_cluster.py перед create.yml
- Обновлены образы в k8s preset'ах: заменен недоступный ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy на inecs/ansible-lab:ubuntu22-latest
- Обновлены preset'ы в базе данных через SQL
- Обновлены файлы: k8s-single.yml, k8s-multi.yml, k8s-istio-full.yml
2026-02-16 00:31:09 +03:00

473 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
API endpoints для управления preset'ами
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from fastapi import APIRouter, Request, HTTPException, Form, Depends, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from typing import List, Dict, Optional
import yaml
import json
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.services.preset_service import PresetService
from app.db.session import get_async_db
from app.auth.deps import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
templates = Jinja2Templates(directory=str(templates_path))
@router.get("/presets", response_class=HTMLResponse)
async def list_presets(
request: Request,
page: int = 1,
per_page: int = 10,
search: Optional[str] = None,
category: Optional[str] = None,
db: AsyncSession = Depends(get_async_db)
):
"""Страница списка preset'ов с пагинацией"""
presets = await PresetService.get_all_presets(db, category=category)
# Фильтрация по поиску
if search:
search_lower = search.lower()
presets = [
p for p in presets
if search_lower in p.get("name", "").lower() or
search_lower in (p.get("description", "") or "").lower()
]
# Пагинация
total = len(presets)
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
page = max(1, min(page, total_pages))
start = (page - 1) * per_page
end = start + per_page
paginated_presets = presets[start:end]
return templates.TemplateResponse(
"pages/presets/list.html",
{
"request": request,
"presets": paginated_presets,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"search": search or "",
"category": category or ""
}
)
@router.get("/api/v1/presets", response_model=List[Dict])
async def get_presets_api(
category: Optional[str] = None,
db: AsyncSession = Depends(get_async_db)
):
"""API endpoint для получения списка preset'ов"""
return await PresetService.get_all_presets(db, category=category)
@router.get("/presets/create", response_class=HTMLResponse)
async def create_preset_page(
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""Страница создания preset'а"""
from app.services.dockerfile_service import DockerfileService
# Загружаем список Dockerfiles из БД
dockerfiles = await DockerfileService.list_dockerfiles(db, status="active")
dockerfiles_list = [
{
"id": d.id,
"name": d.name,
"description": d.description,
"base_image": d.base_image,
"tags": d.tags,
"status": d.status
}
for d in dockerfiles
]
return templates.TemplateResponse(
"pages/presets/create.html",
{
"request": request,
"dockerfiles": dockerfiles_list
}
)
@router.get("/api/v1/presets/{preset_name}")
async def get_preset_api(
preset_name: str,
category: str = "main",
db: AsyncSession = Depends(get_async_db)
):
"""Получение preset'а по имени"""
preset = await PresetService.get_preset(db, preset_name, category)
if not preset:
raise HTTPException(status_code=404, detail=f"Preset '{preset_name}' не найден")
return {
"name": preset.name,
"category": preset.category,
"description": preset.description,
"content": preset.content,
"data": yaml.safe_load(preset.content) if preset.content else {}
}
@router.get("/presets/{preset_name}", response_class=HTMLResponse)
async def preset_detail(
request: Request,
preset_name: str,
category: str = "main",
db: AsyncSession = Depends(get_async_db)
):
"""Страница деталей preset'а"""
try:
preset = await PresetService.get_preset_dict(db, preset_name, category)
return templates.TemplateResponse(
"pages/presets/detail.html",
{
"request": request,
"preset": preset
}
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/presets/create")
async def create_preset_api(
preset_name: str = Form(...),
description: str = Form(""),
category: str = Form("main"),
hosts: str = Form(""),
images: str = Form(""),
systemd_defaults: str = Form(""),
db: AsyncSession = Depends(get_async_db)
):
"""API endpoint для создания preset'а"""
try:
hosts_list = []
if hosts:
hosts_list = json.loads(hosts)
images_dict = {}
if images:
images_dict = json.loads(images)
systemd_defaults_dict = {}
if systemd_defaults:
systemd_defaults_dict = json.loads(systemd_defaults)
preset = await PresetService.create_preset(
db=db,
preset_name=preset_name,
description=description,
hosts=hosts_list,
category=category,
images=images_dict,
systemd_defaults=systemd_defaults_dict
)
return JSONResponse(content={
"success": True,
"preset_name": preset.name,
"message": f"Preset '{preset_name}' успешно создан"
}, status_code=201)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при создании preset'а: {str(e)}")
@router.get("/presets/{preset_name}/edit", response_class=HTMLResponse)
async def edit_preset_page(
request: Request,
preset_name: str,
category: str = "main",
db: AsyncSession = Depends(get_async_db)
):
"""Страница редактирования preset'а"""
try:
preset = await PresetService.get_preset_dict(db, preset_name, category)
return templates.TemplateResponse(
"pages/presets/edit.html",
{
"request": request,
"preset": preset
}
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/presets/{preset_name}/update")
async def update_preset_api(
preset_name: str,
description: str = Form(""),
category: str = Form("main"),
docker_network: str = Form("labnet"),
hosts: str = Form(""),
images: str = Form(""),
systemd_defaults: str = Form(""),
kind_clusters: str = Form(""),
content: Optional[str] = Form(None),
db: AsyncSession = Depends(get_async_db)
):
"""API endpoint для обновления preset'а"""
try:
# Если передан content (YAML), используем старый метод
if content:
preset = await PresetService.update_preset(
db=db,
preset_name=preset_name,
content=content,
category=category
)
return JSONResponse(content={
"success": True,
"preset_name": preset.name,
"message": f"Preset '{preset_name}' успешно обновлен"
})
# Новый метод - из формы
hosts_list = []
if hosts:
hosts_list = json.loads(hosts)
images_dict = {}
if images:
images_dict = json.loads(images)
systemd_defaults_dict = {}
if systemd_defaults:
systemd_defaults_dict = json.loads(systemd_defaults)
kind_clusters_list = []
if kind_clusters:
kind_clusters_list = json.loads(kind_clusters)
preset = await PresetService.update_preset_from_form(
db=db,
preset_name=preset_name,
description=description,
category=category,
docker_network=docker_network,
hosts=hosts_list,
images=images_dict,
systemd_defaults=systemd_defaults_dict,
kind_clusters=kind_clusters_list
)
return JSONResponse(content={
"success": True,
"preset_name": preset.name,
"message": f"Preset '{preset_name}' успешно обновлен"
})
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при обновлении preset'а: {str(e)}")
@router.delete("/api/v1/presets/{preset_name}")
async def delete_preset_api(
preset_name: str,
category: str = "main",
db: AsyncSession = Depends(get_async_db)
):
"""API endpoint для удаления preset'а"""
try:
await PresetService.delete_preset(db, preset_name, category)
return JSONResponse(content={
"success": True,
"preset_name": preset_name,
"message": f"Preset '{preset_name}' успешно удален"
})
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при удалении preset'а: {str(e)}")
@router.websocket("/ws/preset/test/{preset_name}")
async def preset_test_websocket(websocket: WebSocket, preset_name: str, category: str = "main"):
"""WebSocket для live логов тестирования preset'а"""
await websocket.accept()
# Используем класс для хранения ссылки на контейнер
class ContainerRef:
def __init__(self):
self.container = None
def set(self, container):
self.container = container
def get(self):
return self.container
container_ref = ContainerRef()
executor = None
stop_requested = False
try:
# Получаем preset из БД
async for db in get_async_db():
preset = await PresetService.get_preset(db, preset_name, category)
if not preset:
await websocket.send_json({
"type": "error",
"data": f"Preset '{preset_name}' не найден"
})
await websocket.close()
return
preset_content = preset.content
break
# Запуск тестирования preset'а
from app.core.molecule_executor import MoleculeExecutor
executor = MoleculeExecutor()
# Создаем временный файл preset'а из БД
executor.create_temp_preset_file(preset_name, preset_content, category)
await websocket.send_json({
"type": "info",
"data": f"🚀 Запуск тестирования preset'а '{preset_name}'..."
})
# Создаем задачу для мониторинга сообщений от клиента (стоп)
import asyncio
async def monitor_stop():
nonlocal stop_requested
try:
while True:
try:
data = await asyncio.wait_for(websocket.receive_json(), timeout=1.0)
action = data.get("action")
if action == "stop":
stop_requested = True
cont = container_ref.get()
if cont:
try:
cont.stop()
await websocket.send_json({
"type": "info",
"data": "⏹️ Остановка контейнера..."
})
except Exception as e:
logger.error(f"Error stopping container: {e}")
break
except asyncio.TimeoutError:
continue
except WebSocketDisconnect:
stop_requested = True
break
except Exception:
pass
monitor_task = asyncio.create_task(monitor_stop())
# Запускаем тест (без указания роли - тестируем все роли)
try:
async for line in executor.test_role(
role_name=None,
preset_name=preset_name,
preset_content=preset_content,
preset_category=category,
stream=True,
stop_event=lambda: stop_requested,
container_ref=container_ref
):
if stop_requested:
break
line = line.rstrip()
if not line:
continue
log_type = executor.detect_log_level(line)
try:
await websocket.send_json({
"type": "log",
"level": log_type,
"data": line
})
except (WebSocketDisconnect, Exception) as e:
# Соединение закрыто - не пытаемся больше отправлять
stop_requested = True
logger.debug(f"WebSocket closed during log send: {e}")
break
# Отправляем финальное сообщение только если соединение открыто
try:
if not stop_requested:
await websocket.send_json({
"type": "complete",
"status": "success",
"data": "✅ Тестирование preset'а завершено"
})
else:
await websocket.send_json({
"type": "complete",
"status": "stopped",
"data": "⏹️ Тестирование остановлено пользователем"
})
except (WebSocketDisconnect, Exception):
# Соединение уже закрыто - это нормально
pass
except GeneratorExit:
# Генератор закрыт, это нормально при закрытии WebSocket
stop_requested = True
finally:
monitor_task.cancel()
try:
await monitor_task
except asyncio.CancelledError:
pass
# Удаляем временный файл
if executor and preset_name in executor._temp_preset_files:
try:
executor._temp_preset_files[preset_name].unlink()
del executor._temp_preset_files[preset_name]
except:
pass
except WebSocketDisconnect:
# Соединение закрыто клиентом - это нормально
pass
except GeneratorExit:
# Генератор закрыт - это нормально
pass
except Exception as e:
import traceback
error_msg = f"❌ Ошибка: {str(e)}"
try:
await websocket.send_json({
"type": "error",
"data": error_msg
})
except:
pass
logger.error(f"Error in preset_test_websocket: {e}\n{traceback.format_exc()}")
finally:
try:
await websocket.close()
except:
pass