""" API endpoints для деплоя на живые серверы Автор: Сергей Антропов Сайт: https://devops.org.ru """ from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect, HTTPException from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from pathlib import Path from typing import Dict, Optional, List import yaml import json from app.core.config import settings from app.services.deployment_service import DeploymentService router = APIRouter() templates_path = Path(__file__).parent.parent.parent.parent / "templates" templates = Jinja2Templates(directory=str(templates_path)) deployment_service = DeploymentService() @router.get("/deploy", response_class=HTMLResponse) async def deploy_page(request: Request): """Страница деплоя""" # Проверка наличия inventory inventory_file = settings.PROJECT_ROOT / "inventory" / "hosts.ini" inventory_exists = inventory_file.exists() # Чтение inventory если существует inventory_content = "" inventory_data = {} if inventory_exists: try: inventory_content = inventory_file.read_text() # Парсинг inventory для отображения групп и хостов inventory_data = parse_inventory(inventory_content) except Exception: pass # Получение списка ролей roles_dir = settings.PROJECT_ROOT / "roles" roles = [] if roles_dir.exists(): for role_dir in roles_dir.iterdir(): if role_dir.is_dir() and role_dir.name != "deploy.yml": roles.append(role_dir.name) return templates.TemplateResponse( "pages/deploy/index.html", { "request": request, "inventory_exists": inventory_exists, "inventory_content": inventory_content, "inventory_data": inventory_data, "roles": sorted(roles) } ) @router.get("/deploy/inventory", response_class=HTMLResponse) async def inventory_page(request: Request): """Страница управления inventory""" inventory_file = settings.PROJECT_ROOT / "inventory" / "hosts.ini" inventory_exists = inventory_file.exists() inventory_content = "" if inventory_exists: inventory_content = inventory_file.read_text() return templates.TemplateResponse( "pages/deploy/inventory.html", { "request": request, "inventory_exists": inventory_exists, "inventory_content": inventory_content } ) @router.post("/api/v1/deploy/inventory") async def save_inventory(request: Request): """Сохранение inventory файла""" form_data = await request.form() content = form_data.get("content", "") if not content: return JSONResponse( status_code=400, content={"success": False, "message": "Содержимое inventory не может быть пустым"} ) inventory_dir = settings.PROJECT_ROOT / "inventory" inventory_dir.mkdir(parents=True, exist_ok=True) inventory_file = inventory_dir / "hosts.ini" inventory_file.write_text(content) return JSONResponse( content={ "success": True, "message": "Inventory файл успешно сохранен" } ) @router.post("/api/v1/deploy/start") async def start_deploy( role_name: Optional[str] = None, tags: Optional[str] = None, limit: Optional[str] = None, check: bool = False, extra_vars: Optional[str] = None ): """Запуск деплоя""" # TODO: Запуск через Celery для фонового выполнения # Пока просто возвращаем информацию deploy_id = f"deploy-{role_name or 'all'}-{tags or 'none'}" return { "success": True, "deploy_id": deploy_id, "role_name": role_name, "tags": tags, "limit": limit, "check": check, "message": "Деплой запущен" } @router.websocket("/ws/deploy/{deploy_id}") async def deploy_websocket(websocket: WebSocket, deploy_id: str): """WebSocket для live логов деплоя""" await websocket.accept() try: # Ждем сообщение от клиента с параметрами деплоя message = await websocket.receive_json() if message.get("type") != "start": await websocket.send_json({ "type": "error", "data": "Ожидается сообщение типа 'start' с параметрами деплоя" }) await websocket.close() return # Получаем параметры из сообщения role_name = message.get("role_name") inventory = message.get("inventory", "inventory/hosts.ini") limit = message.get("limit") tags = message.get("tags") check = message.get("check", False) extra_vars = message.get("extra_vars") if not role_name: await websocket.send_json({ "type": "error", "data": "Не указано имя роли" }) await websocket.close() return # Отправка начального сообщения await websocket.send_json({ "type": "info", "data": f"🚀 Запуск деплоя роли '{role_name}'..." }) if check: await websocket.send_json({ "type": "warning", "data": "⚠️ Режим dry-run (--check) - изменения не будут применены" }) # Запуск деплоя через DeploymentService async for line in deployment_service.deploy_role( role_name=role_name, inventory=inventory, limit=limit, tags=tags, check=check, extra_vars=extra_vars, stream=True ): line = line.rstrip() if not line: continue # Определение типа лога log_type = deployment_service.detect_log_level(line) await websocket.send_json({ "type": "log", "level": log_type, "data": line }) # Завершение await websocket.send_json({ "type": "complete", "status": "success", "data": "✅ Деплой завершен успешно" }) except WebSocketDisconnect: pass except Exception as e: import traceback error_msg = f"❌ Ошибка: {str(e)}\n{traceback.format_exc()}" await websocket.send_json({ "type": "error", "data": error_msg }) finally: try: await websocket.close() except: pass def parse_inventory(content: str) -> Dict: """Парсинг inventory файла для отображения структуры""" result = { "groups": {}, "hosts": [] } current_group = None for line in content.split("\n"): line = line.strip() if not line or line.startswith("#"): continue # Группа if line.startswith("[") and line.endswith("]"): group_name = line[1:-1] if ":" in group_name: group_name = group_name.split(":")[0] current_group = group_name if current_group not in result["groups"]: result["groups"][current_group] = [] # Хост elif current_group and " " in line: host_parts = line.split() host_name = host_parts[0] result["groups"][current_group].append(host_name) result["hosts"].append({ "name": host_name, "group": current_group, "vars": " ".join(host_parts[1:]) if len(host_parts) > 1 else "" }) return result