feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
1
app/api/v1/__init__.py
Normal file
1
app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API v1 package
|
||||
1
app/api/v1/endpoints/__init__.py
Normal file
1
app/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Endpoints package
|
||||
184
app/api/v1/endpoints/auth.py
Normal file
184
app/api/v1/endpoints/auth.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
API endpoints для аутентификации
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends, status, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from app.core.config import settings
|
||||
from app.auth.security import create_access_token
|
||||
from app.auth.deps import get_current_user
|
||||
from app.db.session import get_async_db
|
||||
from app.services.user_service import UserService
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
"""Страница входа"""
|
||||
return templates.TemplateResponse(
|
||||
"pages/auth/login.html",
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/login")
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""API endpoint для входа"""
|
||||
# Убеждаемся, что пользователь admin существует
|
||||
await UserService.ensure_admin_user(db)
|
||||
|
||||
# Получаем пользователя из БД
|
||||
user = await UserService.get_user_by_username(db, form_data.username)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Проверяем пароль
|
||||
if not await UserService.verify_user_password(user, form_data.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Пользователь отключен"
|
||||
)
|
||||
|
||||
# Если пароль был "admin" и не хеширован, обновляем его
|
||||
if user.hashed_password == "admin" or len(user.hashed_password) < 50:
|
||||
await UserService.update_password(db, user, form_data.password)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
response = JSONResponse(content={
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer"
|
||||
})
|
||||
# Устанавливаем токен в cookie
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
httponly=True,
|
||||
secure=False, # В продакшене должно быть True для HTTPS
|
||||
samesite="lax"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
@router.head("/logout") # Поддержка HEAD запросов
|
||||
async def logout():
|
||||
"""Выход (удаляет токен из cookie и перенаправляет на страницу входа)
|
||||
|
||||
Доступен по путям:
|
||||
- /logout (через прямой router в main.py)
|
||||
- /api/v1/auth/logout (через api_router с префиксом /auth)
|
||||
"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
response = RedirectResponse(url="/login", status_code=302)
|
||||
response.delete_cookie(
|
||||
key="access_token",
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/api/v1/auth/me")
|
||||
async def get_current_user_info(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Получение информации о текущем пользователе"""
|
||||
username = current_user.get("username")
|
||||
if username:
|
||||
user = await UserService.get_user_by_username(db, username)
|
||||
if user:
|
||||
return {
|
||||
"username": user.username,
|
||||
"is_active": user.is_active,
|
||||
"is_superuser": user.is_superuser,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||
}
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/change-password", response_class=HTMLResponse)
|
||||
async def change_password_page(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Страница смены пароля"""
|
||||
return templates.TemplateResponse(
|
||||
"pages/auth/change-password.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/change-password")
|
||||
async def change_password(
|
||||
current_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
new_password_confirm: str = Form(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Смена пароля пользователя"""
|
||||
# Проверка совпадения паролей
|
||||
if new_password != new_password_confirm:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Новые пароли не совпадают"
|
||||
)
|
||||
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Пользователь не авторизован"
|
||||
)
|
||||
|
||||
user = await UserService.get_user_by_username(db, username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
# Проверяем текущий пароль
|
||||
if not await UserService.verify_user_password(user, current_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Неверный текущий пароль"
|
||||
)
|
||||
|
||||
# Обновляем пароль
|
||||
await UserService.update_password(db, user, new_password)
|
||||
|
||||
return {"message": "Пароль успешно изменен"}
|
||||
254
app/api/v1/endpoints/deploy.py
Normal file
254
app/api/v1/endpoints/deploy.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
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
|
||||
116
app/api/v1/endpoints/docker.py
Normal file
116
app/api/v1/endpoints/docker.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
API endpoints для управления Docker образами
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import List, Dict
|
||||
from app.core.config import settings
|
||||
from app.core.docker_client import DockerClient
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Получение Docker клиента с обработкой ошибок"""
|
||||
try:
|
||||
return DockerClient()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/docker", response_class=HTMLResponse)
|
||||
async def docker_page(request: Request):
|
||||
"""Страница управления Docker образами"""
|
||||
docker_client = get_docker_client()
|
||||
images = []
|
||||
|
||||
if docker_client:
|
||||
try:
|
||||
images = docker_client.list_images()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/docker/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"images": images
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/docker/images", response_class=HTMLResponse)
|
||||
async def get_docker_images():
|
||||
"""API endpoint для получения списка Docker образов"""
|
||||
docker_client = get_docker_client()
|
||||
|
||||
if not docker_client:
|
||||
return """
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Docker недоступен</strong>
|
||||
<br><small class="text-muted">Убедитесь, что Docker запущен и доступен. Проверьте, что Docker socket доступен: <code>/var/run/docker.sock</code></small>
|
||||
</div>
|
||||
"""
|
||||
|
||||
try:
|
||||
images = docker_client.list_images()
|
||||
if not images:
|
||||
return """
|
||||
<div class="text-center py-5">
|
||||
<i class="fab fa-docker fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">Docker образы не найдены</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html = '<div class="table-responsive"><table class="table table-hover"><thead><tr><th>ID</th><th>Теги</th><th>Размер</th><th>Создан</th></tr></thead><tbody>'
|
||||
for img in images:
|
||||
size_mb = (img.get("size", 0) / 1024 / 1024) if img.get("size") else 0
|
||||
size_str = f"{size_mb:.2f} MB" if size_mb > 0 else "N/A"
|
||||
tags_list = img.get("tags", [])
|
||||
if tags_list:
|
||||
tags = " ".join([f'<span class="badge bg-info me-1">{tag}</span>' for tag in tags_list])
|
||||
else:
|
||||
tags = '<span class="text-muted">нет тегов</span>'
|
||||
img_id = img.get("id", "")[:12] if img.get("id") else "N/A"
|
||||
created = img.get("created", "")
|
||||
if created:
|
||||
try:
|
||||
# Парсим ISO формат даты или timestamp
|
||||
from datetime import datetime
|
||||
if isinstance(created, (int, float)):
|
||||
# Unix timestamp
|
||||
dt = datetime.fromtimestamp(created)
|
||||
created_str = dt.strftime('%d.%m.%Y %H:%M')
|
||||
elif isinstance(created, str):
|
||||
# ISO формат
|
||||
try:
|
||||
dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
|
||||
except:
|
||||
# Пробуем другой формат
|
||||
dt = datetime.strptime(created.split('.')[0], '%Y-%m-%dT%H:%M:%S')
|
||||
created_str = dt.strftime('%d.%m.%Y %H:%M')
|
||||
else:
|
||||
created_str = str(created)
|
||||
except Exception as e:
|
||||
created_str = str(created)
|
||||
else:
|
||||
created_str = "N/A"
|
||||
|
||||
html += f'<tr><td><code>{img_id}</code></td><td>{tags}</td><td>{size_str}</td><td>{created_str}</td></tr>'
|
||||
html += '</tbody></table></div>'
|
||||
return html
|
||||
except Exception as e:
|
||||
return f"""
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
<strong>Ошибка при получении списка образов:</strong> {str(e)}
|
||||
</div>
|
||||
"""
|
||||
1269
app/api/v1/endpoints/dockerfiles_api.py
Normal file
1269
app/api/v1/endpoints/dockerfiles_api.py
Normal file
File diff suppressed because it is too large
Load Diff
86
app/api/v1/endpoints/export.py
Normal file
86
app/api/v1/endpoints/export.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
API endpoints для экспорта ролей
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from app.core.config import settings
|
||||
from app.services.export_service import ExportService
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
export_service = ExportService()
|
||||
|
||||
|
||||
@router.get("/roles/{role_name}/export", response_class=HTMLResponse)
|
||||
async def export_role_page(request: Request, role_name: str):
|
||||
"""Страница экспорта роли"""
|
||||
# Проверка существования роли
|
||||
roles_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
if not roles_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
|
||||
# Получение доступных компонентов
|
||||
components = export_service.get_role_components(role_name)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/export.html",
|
||||
{
|
||||
"request": request,
|
||||
"role_name": role_name,
|
||||
"components": components
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/{role_name}/export")
|
||||
async def export_role_api(
|
||||
role_name: str,
|
||||
repo_url: str = Form(...),
|
||||
branch: str = Form("main"),
|
||||
version: Optional[str] = Form(None),
|
||||
components: str = Form(""), # JSON строка
|
||||
include_secrets: bool = Form(False),
|
||||
commit_message: Optional[str] = Form(None)
|
||||
):
|
||||
"""API endpoint для экспорта роли"""
|
||||
try:
|
||||
# Парсинг компонентов
|
||||
components_list = []
|
||||
if components:
|
||||
components_list = json.loads(components)
|
||||
|
||||
# Запуск экспорта через Celery (в будущем)
|
||||
# Пока синхронно
|
||||
result = await export_service.export_role(
|
||||
role_name=role_name,
|
||||
repo_url=repo_url,
|
||||
branch=branch,
|
||||
version=version,
|
||||
components=components_list if components_list else None,
|
||||
include_secrets=include_secrets,
|
||||
commit_message=commit_message
|
||||
)
|
||||
|
||||
return JSONResponse(content=result, status_code=200)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при экспорте роли: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/api/v1/roles/{role_name}/export/components")
|
||||
async def get_role_components_api(role_name: str):
|
||||
"""Получение списка компонентов роли"""
|
||||
try:
|
||||
components = export_service.get_role_components(role_name)
|
||||
return {"components": components}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
82
app/api/v1/endpoints/import.py
Normal file
82
app/api/v1/endpoints/import.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
API endpoints для импорта ролей
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from app.core.config import settings
|
||||
from app.services.import_service import ImportService
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
import_service = ImportService()
|
||||
|
||||
|
||||
@router.get("/roles/import", response_class=HTMLResponse)
|
||||
async def import_role_page(request: Request):
|
||||
"""Страница импорта роли"""
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/import.html",
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/import/git")
|
||||
async def import_from_git_api(
|
||||
repo_url: str = Form(...),
|
||||
role_name: Optional[str] = Form(None),
|
||||
branch: str = Form("main"),
|
||||
subdirectory: Optional[str] = Form(None)
|
||||
):
|
||||
"""API endpoint для импорта роли из Git репозитория"""
|
||||
try:
|
||||
result = await import_service.import_from_git(
|
||||
repo_url=repo_url,
|
||||
role_name=role_name,
|
||||
branch=branch,
|
||||
subdirectory=subdirectory
|
||||
)
|
||||
return JSONResponse(content=result, 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"Ошибка при импорте роли: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/import/galaxy")
|
||||
async def import_from_galaxy_api(
|
||||
role_name: str = Form(...),
|
||||
version: Optional[str] = Form(None),
|
||||
namespace: Optional[str] = Form(None)
|
||||
):
|
||||
"""API endpoint для импорта роли из Ansible Galaxy"""
|
||||
try:
|
||||
result = await import_service.import_from_galaxy(
|
||||
role_name=role_name,
|
||||
version=version,
|
||||
namespace=namespace
|
||||
)
|
||||
return JSONResponse(content=result, 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"Ошибка при импорте роли: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/import/validate")
|
||||
async def validate_repo_api(
|
||||
repo_url: str = Form(...),
|
||||
branch: str = Form("main")
|
||||
):
|
||||
"""Проверка доступности репозитория"""
|
||||
try:
|
||||
result = await import_service.validate_repo(repo_url, branch)
|
||||
return JSONResponse(content=result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
83
app/api/v1/endpoints/import_role.py
Normal file
83
app/api/v1/endpoints/import_role.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
API endpoints для импорта ролей
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from app.core.config import settings
|
||||
from app.services.import_service import ImportService
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
import_service = ImportService()
|
||||
|
||||
|
||||
@router.get("/roles/import", response_class=HTMLResponse)
|
||||
async def import_role_page(request: Request):
|
||||
"""Страница импорта роли"""
|
||||
# Этот роут должен быть зарегистрирован ПЕРЕД /roles/{role_name}
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/import.html",
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/import/git")
|
||||
async def import_from_git_api(
|
||||
repo_url: str = Form(...),
|
||||
role_name: Optional[str] = Form(None),
|
||||
branch: str = Form("main"),
|
||||
subdirectory: Optional[str] = Form(None)
|
||||
):
|
||||
"""API endpoint для импорта роли из Git репозитория"""
|
||||
try:
|
||||
result = await import_service.import_from_git(
|
||||
repo_url=repo_url,
|
||||
role_name=role_name,
|
||||
branch=branch,
|
||||
subdirectory=subdirectory
|
||||
)
|
||||
return JSONResponse(content=result, 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"Ошибка при импорте роли: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/import/galaxy")
|
||||
async def import_from_galaxy_api(
|
||||
role_name: str = Form(...),
|
||||
version: Optional[str] = Form(None),
|
||||
namespace: Optional[str] = Form(None)
|
||||
):
|
||||
"""API endpoint для импорта роли из Ansible Galaxy"""
|
||||
try:
|
||||
result = await import_service.import_from_galaxy(
|
||||
role_name=role_name,
|
||||
version=version,
|
||||
namespace=namespace
|
||||
)
|
||||
return JSONResponse(content=result, 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"Ошибка при импорте роли: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/import/validate")
|
||||
async def validate_repo_api(
|
||||
repo_url: str = Form(...),
|
||||
branch: str = Form("main")
|
||||
):
|
||||
"""Проверка доступности репозитория"""
|
||||
try:
|
||||
result = await import_service.validate_repo(repo_url, branch)
|
||||
return JSONResponse(content=result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
62
app/api/v1/endpoints/k8s.py
Normal file
62
app/api/v1/endpoints/k8s.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
API endpoints для управления Kubernetes
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import List, Dict
|
||||
from app.core.config import settings
|
||||
from app.core.make_executor import MakeExecutor
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
executor = MakeExecutor()
|
||||
|
||||
|
||||
@router.get("/k8s", response_class=HTMLResponse)
|
||||
async def k8s_page(request: Request):
|
||||
"""Страница управления Kubernetes"""
|
||||
return templates.TemplateResponse(
|
||||
"pages/k8s/index.html",
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/k8s/clusters", response_class=HTMLResponse)
|
||||
async def get_k8s_clusters():
|
||||
"""API endpoint для получения списка K8s кластеров"""
|
||||
try:
|
||||
# Попытка получить кластеры через kind
|
||||
result = executor.execute("kind get clusters", capture_output=True)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
clusters = [c.strip() for c in result.stdout.strip().split('\n') if c.strip()]
|
||||
if clusters:
|
||||
html = '<div class="table-responsive"><table class="table table-hover"><thead><tr><th>Имя кластера</th><th>Действия</th></tr></thead><tbody>'
|
||||
for cluster in clusters:
|
||||
html += f'<tr><td><code>{cluster}</code></td><td><button class="btn btn-sm btn-outline-primary">Детали</button></td></tr>'
|
||||
html += '</tbody></table></div>'
|
||||
return html
|
||||
|
||||
# Если кластеров нет или kind не установлен
|
||||
return """
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-cube fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted mb-2">Kubernetes кластеры не найдены</p>
|
||||
<p class="text-muted small">
|
||||
Используйте команду <code>make k8s create kubernetes</code> для создания кластера через Kind.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
except Exception as e:
|
||||
return f"""
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Ошибка при получении списка кластеров:</strong> {str(e)}
|
||||
<br><small class="text-muted">Убедитесь, что Kind установлен и доступен</small>
|
||||
</div>
|
||||
"""
|
||||
132
app/api/v1/endpoints/lint.py
Normal file
132
app/api/v1/endpoints/lint.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
API endpoints для проверки синтаксиса ролей (lint)
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from app.core.config import settings
|
||||
from app.services.lint_service import LintService
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
lint_service = LintService()
|
||||
|
||||
|
||||
@router.get("/lint", response_class=HTMLResponse)
|
||||
async def lint_page(request: Request):
|
||||
"""Страница проверки синтаксиса ролей"""
|
||||
# Получение списка ролей
|
||||
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({
|
||||
"name": role_dir.name,
|
||||
"path": str(role_dir)
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/lint/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"roles": sorted(roles, key=lambda x: x["name"])
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/roles/{role_name}/lint", response_class=HTMLResponse)
|
||||
async def lint_role_page(request: Request, role_name: str):
|
||||
"""Страница проверки синтаксиса конкретной роли"""
|
||||
# Проверка существования роли
|
||||
roles_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
if not roles_dir.exists():
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/lint/role.html",
|
||||
{
|
||||
"request": request,
|
||||
"role_name": role_name
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/lint/{role_name}")
|
||||
async def lint_websocket(websocket: WebSocket, role_name: str):
|
||||
"""WebSocket для live логов линтинга"""
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
# Проверка существования роли (если указана)
|
||||
if role_name != "all":
|
||||
roles_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
if not roles_dir.exists():
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": f"Роль '{role_name}' не найдена"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Отправка начального сообщения
|
||||
if role_name == "all":
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": "🔍 Запуск проверки синтаксиса всех ролей..."
|
||||
})
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": f"🔍 Запуск проверки синтаксиса роли '{role_name}'..."
|
||||
})
|
||||
|
||||
# Запуск линтинга
|
||||
role_name_param = None if role_name == "all" else role_name
|
||||
|
||||
async for line in lint_service.lint_role(
|
||||
role_name=role_name_param,
|
||||
stream=True
|
||||
):
|
||||
# Очистка строки от лишних символов
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Определение типа лога
|
||||
log_type = lint_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
|
||||
439
app/api/v1/endpoints/playbooks.py
Normal file
439
app/api/v1/endpoints/playbooks.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
API endpoints для управления playbook
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends, status, Form, WebSocket
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from app.db.session import get_async_db
|
||||
from app.services.playbook_service import PlaybookService
|
||||
from app.auth.deps import get_current_user
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.database import PlaybookTestRun, PlaybookDeployment
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
|
||||
|
||||
class PlaybookCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
roles: List[str]
|
||||
variables: Optional[Dict] = None
|
||||
inventory: Optional[str] = None
|
||||
|
||||
|
||||
class PlaybookUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
roles: Optional[List[str]] = None
|
||||
variables: Optional[Dict] = None
|
||||
inventory: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/playbooks", response_class=HTMLResponse)
|
||||
async def playbooks_list(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Список всех playbook"""
|
||||
playbooks = await PlaybookService.list_playbooks(db)
|
||||
return templates.TemplateResponse(
|
||||
"pages/playbooks/list.html",
|
||||
{
|
||||
"request": request,
|
||||
"playbooks": playbooks
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/playbooks/create", response_class=HTMLResponse)
|
||||
async def playbook_create_page(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Страница создания playbook"""
|
||||
from app.core.config import settings
|
||||
# Получаем список доступных ролей
|
||||
roles_dir = settings.PROJECT_ROOT / "roles"
|
||||
roles = []
|
||||
if roles_dir.exists():
|
||||
roles = [d.name for d in roles_dir.iterdir() if d.is_dir() and (d / "tasks").exists()]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/playbooks/create.html",
|
||||
{
|
||||
"request": request,
|
||||
"roles": sorted(roles)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/playbooks/{playbook_id}", response_class=HTMLResponse)
|
||||
async def playbook_detail(
|
||||
request: Request,
|
||||
playbook_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Детали playbook"""
|
||||
playbook = await PlaybookService.get_playbook(db, playbook_id)
|
||||
if not playbook:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
# Получаем историю тестов и деплоев
|
||||
test_runs = await db.execute(
|
||||
select(PlaybookTestRun)
|
||||
.where(PlaybookTestRun.playbook_id == playbook_id)
|
||||
.order_by(PlaybookTestRun.started_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
deployments = await db.execute(
|
||||
select(PlaybookDeployment)
|
||||
.where(PlaybookDeployment.playbook_id == playbook_id)
|
||||
.order_by(PlaybookDeployment.started_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/playbooks/detail.html",
|
||||
{
|
||||
"request": request,
|
||||
"playbook": playbook,
|
||||
"test_runs": test_runs.scalars().all(),
|
||||
"deployments": deployments.scalars().all()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/playbooks/{playbook_id}/edit", response_class=HTMLResponse)
|
||||
async def playbook_edit_page(
|
||||
request: Request,
|
||||
playbook_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Страница редактирования playbook"""
|
||||
playbook = await PlaybookService.get_playbook(db, playbook_id)
|
||||
if not playbook:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
from app.core.config import settings
|
||||
# Получаем список доступных ролей
|
||||
roles_dir = settings.PROJECT_ROOT / "roles"
|
||||
all_roles = []
|
||||
if roles_dir.exists():
|
||||
all_roles = [d.name for d in roles_dir.iterdir() if d.is_dir() and (d / "tasks").exists()]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/playbooks/edit.html",
|
||||
{
|
||||
"request": request,
|
||||
"playbook": playbook,
|
||||
"all_roles": sorted(all_roles)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/playbooks")
|
||||
async def create_playbook(
|
||||
playbook: PlaybookCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Создание нового playbook"""
|
||||
# Проверяем, что playbook с таким именем не существует
|
||||
existing = await PlaybookService.get_playbook_by_name(db, playbook.name)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Playbook с именем '{playbook.name}' уже существует"
|
||||
)
|
||||
|
||||
new_playbook = await PlaybookService.create_playbook(
|
||||
db=db,
|
||||
name=playbook.name,
|
||||
roles=playbook.roles,
|
||||
description=playbook.description,
|
||||
variables=playbook.variables,
|
||||
inventory=playbook.inventory,
|
||||
created_by=current_user.get("username")
|
||||
)
|
||||
|
||||
return {"id": new_playbook.id, "name": new_playbook.name, "message": "Playbook создан успешно"}
|
||||
|
||||
|
||||
@router.put("/api/v1/playbooks/{playbook_id}")
|
||||
async def update_playbook(
|
||||
playbook_id: int,
|
||||
playbook: PlaybookUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Обновление playbook"""
|
||||
updated = await PlaybookService.update_playbook(
|
||||
db=db,
|
||||
playbook_id=playbook_id,
|
||||
name=playbook.name,
|
||||
description=playbook.description,
|
||||
roles=playbook.roles,
|
||||
variables=playbook.variables,
|
||||
inventory=playbook.inventory,
|
||||
content=playbook.content,
|
||||
updated_by=current_user.get("username")
|
||||
)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
return {"message": "Playbook обновлен успешно"}
|
||||
|
||||
|
||||
@router.delete("/api/v1/playbooks/{playbook_id}")
|
||||
async def delete_playbook(
|
||||
playbook_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Удаление playbook"""
|
||||
deleted = await PlaybookService.delete_playbook(db, playbook_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
return {"message": "Playbook удален успешно"}
|
||||
|
||||
|
||||
@router.get("/api/v1/playbooks")
|
||||
async def list_playbooks_api(
|
||||
status_filter: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""API: Список всех playbook"""
|
||||
playbooks = await PlaybookService.list_playbooks(db, status=status_filter)
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"roles": p.roles,
|
||||
"status": p.status,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
"updated_at": p.updated_at.isoformat() if p.updated_at else None
|
||||
}
|
||||
for p in playbooks
|
||||
]
|
||||
|
||||
|
||||
@router.post("/api/v1/playbooks/{playbook_id}/test")
|
||||
async def test_playbook(
|
||||
playbook_id: int,
|
||||
preset: Optional[str] = Form("default"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Запуск тестирования playbook"""
|
||||
playbook = await PlaybookService.get_playbook(db, playbook_id)
|
||||
if not playbook:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
# Сохраняем запись о тесте
|
||||
test_run = await PlaybookService.save_test_run(
|
||||
db=db,
|
||||
playbook_id=playbook_id,
|
||||
preset_name=preset,
|
||||
status="running",
|
||||
user=current_user.get("username")
|
||||
)
|
||||
|
||||
# Запускаем тест в фоне через Celery
|
||||
from app.tasks.celery_tasks import run_playbook_test
|
||||
task = run_playbook_test.delay(playbook_id, preset, test_run.id)
|
||||
|
||||
return {
|
||||
"message": "Тест запущен",
|
||||
"test_run_id": test_run.id,
|
||||
"task_id": task.id,
|
||||
"websocket_url": f"/ws/playbook-test/{test_run.id}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/playbooks/{playbook_id}/deploy")
|
||||
async def deploy_playbook(
|
||||
playbook_id: int,
|
||||
inventory: Optional[str] = Form(None),
|
||||
limit: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
check: bool = Form(False),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Запуск деплоя playbook"""
|
||||
playbook = await PlaybookService.get_playbook(db, playbook_id)
|
||||
if not playbook:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
# Используем inventory из playbook или переданный
|
||||
deploy_inventory = inventory or playbook.inventory
|
||||
if not deploy_inventory:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Inventory не указан. Укажите inventory в playbook или передайте его в запросе."
|
||||
)
|
||||
|
||||
# Сохраняем запись о деплое
|
||||
deployment = await PlaybookService.save_deployment(
|
||||
db=db,
|
||||
playbook_id=playbook_id,
|
||||
inventory=deploy_inventory,
|
||||
hosts=None, # Будет заполнено после парсинга inventory
|
||||
status="running",
|
||||
user=current_user.get("username")
|
||||
)
|
||||
|
||||
# Запускаем деплой в фоне через Celery
|
||||
from app.tasks.celery_tasks import run_playbook_deploy
|
||||
task = run_playbook_deploy.delay(
|
||||
playbook_id,
|
||||
deploy_inventory,
|
||||
limit,
|
||||
tags,
|
||||
check,
|
||||
deployment.id
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Деплой запущен",
|
||||
"deployment_id": deployment.id,
|
||||
"task_id": task.id,
|
||||
"websocket_url": f"/ws/playbook-deploy/{deployment.id}"
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/playbook-test/{test_run_id}")
|
||||
async def playbook_test_websocket(websocket: WebSocket, test_run_id: int):
|
||||
"""WebSocket для live логов тестирования playbook"""
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
from app.db.session import get_async_db
|
||||
from app.services.playbook_service import PlaybookService
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# Получаем информацию о тесте
|
||||
async for db in get_async_db():
|
||||
test_run = await db.execute(
|
||||
select(PlaybookTestRun).where(PlaybookTestRun.id == test_run_id)
|
||||
)
|
||||
test_run = test_run.scalar_one_or_none()
|
||||
if not test_run:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": f"Тест #{test_run_id} не найден"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
playbook = await PlaybookService.get_playbook(db, test_run.playbook_id)
|
||||
if not playbook:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": "Playbook не найден"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Подключаемся к Redis для получения логов из Celery
|
||||
# Пока отправляем заглушку
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": f"Тестирование playbook '{playbook.name}' запущено..."
|
||||
})
|
||||
|
||||
# TODO: Реализовать получение логов из Celery task
|
||||
# Пока просто ждем и отправляем статус
|
||||
import asyncio
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"status": "running",
|
||||
"data": "Тест выполняется..."
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": f"Ошибка: {str(e)}"
|
||||
})
|
||||
await websocket.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/playbook-deploy/{deployment_id}")
|
||||
async def playbook_deploy_websocket(websocket: WebSocket, deployment_id: int):
|
||||
"""WebSocket для live логов деплоя playbook"""
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
from app.db.session import get_async_db
|
||||
from app.services.playbook_service import PlaybookService
|
||||
from sqlalchemy import select
|
||||
from app.models.database import PlaybookDeployment
|
||||
|
||||
# Получаем информацию о деплое
|
||||
async for db in get_async_db():
|
||||
deployment = await db.execute(
|
||||
select(PlaybookDeployment).where(PlaybookDeployment.id == deployment_id)
|
||||
)
|
||||
deployment = deployment.scalar_one_or_none()
|
||||
if not deployment:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": f"Деплой #{deployment_id} не найден"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
playbook = await PlaybookService.get_playbook(db, deployment.playbook_id)
|
||||
if not playbook:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": "Playbook не найден"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Подключаемся к Redis для получения логов из Celery
|
||||
# Пока отправляем заглушку
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": f"Деплой playbook '{playbook.name}' запущен..."
|
||||
})
|
||||
|
||||
# TODO: Реализовать получение логов из Celery task
|
||||
# Пока просто ждем и отправляем статус
|
||||
import asyncio
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"status": "running",
|
||||
"data": "Деплой выполняется..."
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": f"Ошибка: {str(e)}"
|
||||
})
|
||||
await websocket.close()
|
||||
359
app/api/v1/endpoints/presets.py
Normal file
359
app/api/v1/endpoints/presets.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
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):
|
||||
"""Страница создания preset'а"""
|
||||
return templates.TemplateResponse(
|
||||
"pages/presets/create.html",
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@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(""),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""API endpoint для создания preset'а"""
|
||||
try:
|
||||
hosts_list = []
|
||||
if hosts:
|
||||
hosts_list = json.loads(hosts)
|
||||
|
||||
preset = await PresetService.create_preset(
|
||||
db=db,
|
||||
preset_name=preset_name,
|
||||
description=description,
|
||||
hosts=hosts_list,
|
||||
category=category
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
# Получаем действие от клиента
|
||||
data = await websocket.receive_json()
|
||||
action = data.get("action", "start")
|
||||
|
||||
if action == "stop":
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": "⏹️ Остановка тестирования..."
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Запуск тестирования 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}'..."
|
||||
})
|
||||
|
||||
# Запускаем тест (без указания роли - тестируем все роли)
|
||||
async for line in executor.test_role(
|
||||
role_name=None,
|
||||
preset_name=preset_name,
|
||||
preset_content=preset_content,
|
||||
preset_category=category,
|
||||
stream=True
|
||||
):
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
log_type = executor.detect_log_level(line)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "log",
|
||||
"level": log_type,
|
||||
"data": line
|
||||
})
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"status": "success",
|
||||
"data": "✅ Тестирование preset'а завершено"
|
||||
})
|
||||
|
||||
# Удаляем временный файл
|
||||
if 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 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
|
||||
217
app/api/v1/endpoints/profile.py
Normal file
217
app/api/v1/endpoints/profile.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
API endpoints для управления профилем пользователя
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.db.session import get_async_db
|
||||
from app.services.user_service import UserService
|
||||
from app.models.user import User, UserProfile
|
||||
from app.models.database import Preset, Dockerfile, Playbook, CommandHistory
|
||||
from app.auth.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
|
||||
|
||||
@router.get("/profile", response_class=HTMLResponse)
|
||||
async def profile_page(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Страница профиля пользователя"""
|
||||
user = await UserService.get_user_by_username(db, current_user.get("username"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Получаем или создаем профиль
|
||||
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
|
||||
profile = result.scalar_one_or_none()
|
||||
|
||||
if not profile:
|
||||
profile = UserProfile(user_id=user.id)
|
||||
db.add(profile)
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
|
||||
# Получаем статистику пользователя
|
||||
username = user.username
|
||||
|
||||
# Количество созданных preset'ов
|
||||
presets_count = await db.execute(
|
||||
select(func.count(Preset.id)).where(Preset.created_by == username)
|
||||
)
|
||||
presets_count = presets_count.scalar() or 0
|
||||
|
||||
# Количество созданных Dockerfile'ов
|
||||
dockerfiles_count = await db.execute(
|
||||
select(func.count(Dockerfile.id)).where(Dockerfile.created_by == username)
|
||||
)
|
||||
dockerfiles_count = dockerfiles_count.scalar() or 0
|
||||
|
||||
# Количество созданных playbook'ов
|
||||
playbooks_count = await db.execute(
|
||||
select(func.count(Playbook.id)).where(Playbook.created_by == username)
|
||||
)
|
||||
playbooks_count = playbooks_count.scalar() or 0
|
||||
|
||||
# Количество выполненных команд
|
||||
commands_count = await db.execute(
|
||||
select(func.count(CommandHistory.id)).where(CommandHistory.user == username)
|
||||
)
|
||||
commands_count = commands_count.scalar() or 0
|
||||
|
||||
# Количество успешных тестов
|
||||
successful_tests = await db.execute(
|
||||
select(func.count(CommandHistory.id)).where(
|
||||
CommandHistory.user == username,
|
||||
CommandHistory.command_type == "test",
|
||||
CommandHistory.status == "success"
|
||||
)
|
||||
)
|
||||
successful_tests = successful_tests.scalar() or 0
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/profile/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"profile": profile,
|
||||
"stats": {
|
||||
"presets": presets_count,
|
||||
"dockerfiles": dockerfiles_count,
|
||||
"playbooks": playbooks_count,
|
||||
"commands": commands_count,
|
||||
"successful_tests": successful_tests
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/profile")
|
||||
async def update_profile(
|
||||
request: Request,
|
||||
email: Optional[str] = Form(None),
|
||||
full_name: Optional[str] = Form(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Обновление профиля пользователя"""
|
||||
user = await UserService.get_user_by_username(db, current_user.get("username"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Получаем или создаем профиль
|
||||
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
|
||||
profile = result.scalar_one_or_none()
|
||||
|
||||
if not profile:
|
||||
profile = UserProfile(user_id=user.id)
|
||||
db.add(profile)
|
||||
|
||||
if email:
|
||||
profile.email = email
|
||||
if full_name:
|
||||
profile.full_name = full_name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"message": "Профиль обновлен успешно"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/profile/docker-settings", response_class=HTMLResponse)
|
||||
async def docker_settings_page(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Страница настроек Docker (Harbor и Docker Hub)"""
|
||||
user = await UserService.get_user_by_username(db, current_user.get("username"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Получаем или создаем профиль
|
||||
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
|
||||
profile = result.scalar_one_or_none()
|
||||
|
||||
if not profile:
|
||||
profile = UserProfile(user_id=user.id)
|
||||
db.add(profile)
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/profile/docker-settings.html",
|
||||
{
|
||||
"request": request,
|
||||
"profile": profile
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/profile/docker-settings")
|
||||
async def update_docker_settings(
|
||||
request: Request,
|
||||
dockerhub_username: Optional[str] = Form(None),
|
||||
dockerhub_password: Optional[str] = Form(None),
|
||||
dockerhub_repository: Optional[str] = Form(None),
|
||||
harbor_url: Optional[str] = Form(None),
|
||||
harbor_username: Optional[str] = Form(None),
|
||||
harbor_password: Optional[str] = Form(None),
|
||||
harbor_project: Optional[str] = Form(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Обновление настроек Docker"""
|
||||
user = await UserService.get_user_by_username(db, current_user.get("username"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Получаем или создаем профиль
|
||||
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
|
||||
profile = result.scalar_one_or_none()
|
||||
|
||||
if not profile:
|
||||
profile = UserProfile(user_id=user.id)
|
||||
db.add(profile)
|
||||
|
||||
# Обновляем настройки Docker Hub
|
||||
if dockerhub_username is not None:
|
||||
profile.dockerhub_username = dockerhub_username
|
||||
if dockerhub_password:
|
||||
# TODO: Зашифровать пароль перед сохранением
|
||||
profile.dockerhub_password = dockerhub_password
|
||||
if dockerhub_repository is not None:
|
||||
profile.dockerhub_repository = dockerhub_repository
|
||||
|
||||
# Обновляем настройки Harbor
|
||||
if harbor_url is not None:
|
||||
profile.harbor_url = harbor_url
|
||||
if harbor_username is not None:
|
||||
profile.harbor_username = harbor_username
|
||||
if harbor_password:
|
||||
# TODO: Зашифровать пароль перед сохранением
|
||||
profile.harbor_password = harbor_password
|
||||
if harbor_project is not None:
|
||||
profile.harbor_project = harbor_project
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"message": "Настройки Docker обновлены успешно"
|
||||
})
|
||||
312
app/api/v1/endpoints/roles.py
Normal file
312
app/api/v1/endpoints/roles.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
API endpoints для управления ролями
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import yaml
|
||||
import json
|
||||
from app.core.config import settings
|
||||
from app.services.role_service import RoleService
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
role_service = RoleService()
|
||||
|
||||
|
||||
def get_roles_list() -> List[Dict]:
|
||||
"""Получение списка ролей"""
|
||||
roles_dir = settings.PROJECT_ROOT / "roles"
|
||||
|
||||
if not roles_dir.exists():
|
||||
return []
|
||||
|
||||
roles = []
|
||||
for role_dir in roles_dir.iterdir():
|
||||
if role_dir.is_dir():
|
||||
role_info = {
|
||||
"name": role_dir.name,
|
||||
"path": str(role_dir),
|
||||
"has_tasks": (role_dir / "tasks" / "main.yml").exists(),
|
||||
"has_defaults": (role_dir / "defaults" / "main.yml").exists(),
|
||||
"has_handlers": (role_dir / "handlers" / "main.yml").exists(),
|
||||
"has_meta": (role_dir / "meta" / "main.yml").exists(),
|
||||
"has_readme": (role_dir / "README.md").exists(),
|
||||
"description": ""
|
||||
}
|
||||
|
||||
# Чтение описания из meta/main.yml
|
||||
meta_file = role_dir / "meta" / "main.yml"
|
||||
if meta_file.exists():
|
||||
try:
|
||||
with open(meta_file) as f:
|
||||
meta_data = yaml.safe_load(f)
|
||||
if meta_data and isinstance(meta_data, dict):
|
||||
role_info["description"] = meta_data.get("galaxy_info", {}).get("description", "")
|
||||
role_info["author"] = meta_data.get("galaxy_info", {}).get("author", "")
|
||||
role_info["platforms"] = meta_data.get("galaxy_info", {}).get("platforms", [])
|
||||
except:
|
||||
pass
|
||||
|
||||
roles.append(role_info)
|
||||
|
||||
return sorted(roles, key=lambda x: x["name"])
|
||||
|
||||
|
||||
@router.get("/roles/create", response_class=HTMLResponse)
|
||||
async def create_role_page(request: Request):
|
||||
"""Страница создания роли"""
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/create.html",
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/roles/{role_name}/edit", response_class=HTMLResponse)
|
||||
async def edit_role_page(request: Request, role_name: str):
|
||||
"""Страница редактирования роли"""
|
||||
roles = get_roles_list()
|
||||
role = next((r for r in roles if r["name"] == role_name), None)
|
||||
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
|
||||
role_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
|
||||
# Чтение всех файлов роли
|
||||
files_content = {}
|
||||
for file_type in ["tasks", "handlers", "defaults", "vars", "meta"]:
|
||||
file_path = role_dir / file_type / "main.yml"
|
||||
if file_path.exists():
|
||||
files_content[file_type] = file_path.read_text()
|
||||
else:
|
||||
files_content[file_type] = ""
|
||||
|
||||
readme_content = ""
|
||||
readme_file = role_dir / "README.md"
|
||||
if readme_file.exists():
|
||||
readme_content = readme_file.read_text()
|
||||
else:
|
||||
readme_content = ""
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/edit.html",
|
||||
{
|
||||
"request": request,
|
||||
"role": role,
|
||||
"files_content": files_content,
|
||||
"readme_content": readme_content
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/roles", response_class=HTMLResponse)
|
||||
async def list_roles(
|
||||
request: Request,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
search: Optional[str] = None
|
||||
):
|
||||
"""Страница списка ролей с пагинацией"""
|
||||
"""Страница списка ролей с пагинацией"""
|
||||
roles = get_roles_list()
|
||||
|
||||
# Фильтрация по поиску
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
roles = [
|
||||
r for r in roles
|
||||
if search_lower in r["name"].lower() or
|
||||
search_lower in (r.get("description", "") or "").lower()
|
||||
]
|
||||
|
||||
# Пагинация
|
||||
total = len(roles)
|
||||
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_roles = roles[start:end]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/list.html",
|
||||
{
|
||||
"request": request,
|
||||
"roles": paginated_roles,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"search": search or ""
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/roles")
|
||||
async def get_roles_api():
|
||||
"""API endpoint для получения списка ролей"""
|
||||
return get_roles_list()
|
||||
|
||||
|
||||
@router.get("/api/v1/roles/{role_name}")
|
||||
async def get_role_info(role_name: str):
|
||||
"""API endpoint для получения информации о роли"""
|
||||
roles = get_roles_list()
|
||||
role = next((r for r in roles if r["name"] == role_name), None)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
return role
|
||||
|
||||
|
||||
@router.get("/roles/{role_name}", response_class=HTMLResponse)
|
||||
async def role_detail(request: Request, role_name: str):
|
||||
"""Страница деталей роли"""
|
||||
roles = get_roles_list()
|
||||
role = next((r for r in roles if r["name"] == role_name), None)
|
||||
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
|
||||
# Чтение содержимого файлов
|
||||
role_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
|
||||
tasks_content = ""
|
||||
tasks_file = role_dir / "tasks" / "main.yml"
|
||||
if tasks_file.exists():
|
||||
tasks_content = tasks_file.read_text()
|
||||
|
||||
defaults_content = ""
|
||||
defaults_file = role_dir / "defaults" / "main.yml"
|
||||
if defaults_file.exists():
|
||||
defaults_content = defaults_file.read_text()
|
||||
|
||||
readme_content = ""
|
||||
readme_file = role_dir / "README.md"
|
||||
if readme_file.exists():
|
||||
readme_content = readme_file.read_text()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/detail.html",
|
||||
{
|
||||
"request": request,
|
||||
"role": role,
|
||||
"tasks_content": tasks_content,
|
||||
"defaults_content": defaults_content,
|
||||
"readme_content": readme_content
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/create")
|
||||
async def create_role_api(
|
||||
role_name: str = Form(...),
|
||||
template: str = Form("default"),
|
||||
description: str = Form(""),
|
||||
platforms: str = Form(""), # JSON строка или через запятую
|
||||
variables: str = Form("") # JSON строка
|
||||
):
|
||||
"""API endpoint для создания роли"""
|
||||
try:
|
||||
# Парсинг platforms
|
||||
platforms_list = []
|
||||
if platforms:
|
||||
if platforms.startswith("["):
|
||||
platforms_list = json.loads(platforms)
|
||||
else:
|
||||
platforms_list = [p.strip() for p in platforms.split(",") if p.strip()]
|
||||
|
||||
# Парсинг variables
|
||||
variables_list = []
|
||||
if variables:
|
||||
variables_list = json.loads(variables)
|
||||
|
||||
result = role_service.create_role(
|
||||
role_name=role_name,
|
||||
template=template,
|
||||
description=description,
|
||||
platforms=platforms_list,
|
||||
variables=variables_list
|
||||
)
|
||||
|
||||
return JSONResponse(content=result, 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"Ошибка при создании роли: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/{role_name}/update")
|
||||
async def update_role_api(
|
||||
role_name: str,
|
||||
file_type: str = Form(...),
|
||||
content: str = Form(...)
|
||||
):
|
||||
"""API endpoint для обновления файла роли"""
|
||||
try:
|
||||
role_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
|
||||
if not role_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
|
||||
# Определение пути к файлу
|
||||
if file_type == "readme":
|
||||
file_path = role_dir / "README.md"
|
||||
elif file_type in ["tasks", "handlers", "defaults", "vars", "meta"]:
|
||||
file_path = role_dir / file_type / "main.yml"
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Неверный тип файла: {file_type}")
|
||||
|
||||
# Сохранение файла
|
||||
file_path.write_text(content)
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"message": f"Файл {file_type} успешно обновлен"
|
||||
})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при обновлении роли: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/roles/{role_name}/deploy", response_class=HTMLResponse)
|
||||
async def deploy_role_page(request: Request, role_name: str):
|
||||
"""Страница деплоя роли"""
|
||||
roles = get_roles_list()
|
||||
role = next((r for r in roles if r["name"] == role_name), None)
|
||||
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
|
||||
# Проверка наличия inventory
|
||||
inventory_file = settings.PROJECT_ROOT / "inventory" / "hosts.ini"
|
||||
inventory_exists = inventory_file.exists()
|
||||
|
||||
# Чтение inventory если существует
|
||||
inventory_content = ""
|
||||
if inventory_exists:
|
||||
try:
|
||||
inventory_content = inventory_file.read_text()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Проверка наличия deploy.yml
|
||||
deploy_playbook = settings.PROJECT_ROOT / "roles" / "deploy.yml"
|
||||
deploy_playbook_exists = deploy_playbook.exists()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/deploy.html",
|
||||
{
|
||||
"request": request,
|
||||
"role": role,
|
||||
"role_name": role_name,
|
||||
"inventory_exists": inventory_exists,
|
||||
"inventory_content": inventory_content,
|
||||
"deploy_playbook_exists": deploy_playbook_exists
|
||||
}
|
||||
)
|
||||
69
app/api/v1/endpoints/stats.py
Normal file
69
app/api/v1/endpoints/stats.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
API endpoints для статистики
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pathlib import Path
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_stats():
|
||||
"""Общая статистика"""
|
||||
roles_dir = settings.PROJECT_ROOT / "roles"
|
||||
roles_count = 0
|
||||
if roles_dir.exists():
|
||||
roles = [d for d in roles_dir.iterdir()
|
||||
if d.is_dir() and d.name != "deploy.yml"]
|
||||
roles_count = len(roles)
|
||||
|
||||
return {
|
||||
"roles": roles_count,
|
||||
"tests": 0, # TODO: из БД
|
||||
"success": 0, # TODO: из БД
|
||||
"docker": 0 # TODO: из Docker API
|
||||
}
|
||||
|
||||
|
||||
@router.get("/roles", response_class=HTMLResponse)
|
||||
async def get_roles_count():
|
||||
"""Количество ролей"""
|
||||
try:
|
||||
roles_dir = settings.PROJECT_ROOT / "roles"
|
||||
if not roles_dir.exists():
|
||||
return "0"
|
||||
|
||||
# Подсчет ролей (исключая deploy.yml)
|
||||
roles = [d for d in roles_dir.iterdir()
|
||||
if d.is_dir() and d.name != "deploy.yml" and not d.name.startswith(".")]
|
||||
|
||||
return str(len(roles))
|
||||
except Exception as e:
|
||||
# В случае ошибки возвращаем 0
|
||||
return "0"
|
||||
|
||||
|
||||
@router.get("/tests", response_class=HTMLResponse)
|
||||
async def get_tests_count():
|
||||
"""Количество тестов"""
|
||||
# TODO: Реализовать через БД
|
||||
return "0"
|
||||
|
||||
|
||||
@router.get("/success", response_class=HTMLResponse)
|
||||
async def get_success_count():
|
||||
"""Количество успешных тестов"""
|
||||
# TODO: Реализовать через БД
|
||||
return "0"
|
||||
|
||||
|
||||
@router.get("/docker", response_class=HTMLResponse)
|
||||
async def get_docker_count():
|
||||
"""Количество Docker образов"""
|
||||
# TODO: Реализовать через Docker API
|
||||
return "0"
|
||||
273
app/api/v1/endpoints/tests.py
Normal file
273
app/api/v1/endpoints/tests.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
API endpoints для тестирования ролей
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect, Depends
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
import json
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.core.molecule_executor import MoleculeExecutor
|
||||
from app.services.preset_service import PresetService
|
||||
from app.db.session import get_async_db
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
molecule_executor = MoleculeExecutor()
|
||||
|
||||
|
||||
@router.get("/roles/{role_name}/test", response_class=HTMLResponse)
|
||||
async def test_role_page(
|
||||
request: Request,
|
||||
role_name: str,
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Страница тестирования роли"""
|
||||
# Проверка существования роли
|
||||
roles_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
if not roles_dir.exists():
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail=f"Роль '{role_name}' не найдена")
|
||||
|
||||
# Получение списка preset'ов из БД
|
||||
presets = await PresetService.get_all_presets(db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/roles/test.html",
|
||||
{
|
||||
"request": request,
|
||||
"role_name": role_name,
|
||||
"presets": presets
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/roles/{role_name}/test")
|
||||
async def start_role_test(
|
||||
role_name: str,
|
||||
preset: str = "default",
|
||||
variables: Optional[str] = None
|
||||
):
|
||||
"""Запуск теста роли"""
|
||||
# TODO: Запуск через Celery для фонового выполнения
|
||||
# Пока просто возвращаем информацию
|
||||
return {
|
||||
"success": True,
|
||||
"role_name": role_name,
|
||||
"preset": preset,
|
||||
"message": "Тест запущен",
|
||||
"test_id": f"{role_name}-{preset}"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tests", response_class=HTMLResponse)
|
||||
async def tests_history_page(
|
||||
request: Request,
|
||||
role_name: Optional[str] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Страница истории тестов"""
|
||||
try:
|
||||
from app.services.history_service import HistoryService
|
||||
from sqlalchemy import select, func
|
||||
from app.models.database import CommandHistory, TestResult
|
||||
|
||||
history_service = HistoryService()
|
||||
|
||||
# Получение тестов из БД
|
||||
query = select(CommandHistory).where(
|
||||
CommandHistory.command_type == "test"
|
||||
)
|
||||
|
||||
if role_name:
|
||||
query = query.where(CommandHistory.command.like(f"%{role_name}%"))
|
||||
|
||||
query = query.order_by(CommandHistory.created_at.desc())
|
||||
|
||||
# Подсчет общего количества
|
||||
count_query = select(func.count(CommandHistory.id)).where(
|
||||
CommandHistory.command_type == "test"
|
||||
)
|
||||
if role_name:
|
||||
count_query = count_query.where(CommandHistory.command.like(f"%{role_name}%"))
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
page = max(1, min(page, total_pages))
|
||||
|
||||
# Пагинация
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
tests = result.scalars().all()
|
||||
|
||||
# Получение списка ролей для фильтра
|
||||
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/tests/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"tests": tests,
|
||||
"roles": sorted(roles),
|
||||
"role_name": role_name or "",
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Error loading tests history: {e}", exc_info=True)
|
||||
# Если база данных не настроена, показываем пустую страницу
|
||||
return templates.TemplateResponse(
|
||||
"pages/tests/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"tests": [],
|
||||
"roles": [],
|
||||
"role_name": role_name or "",
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"per_page": per_page,
|
||||
"total_pages": 1,
|
||||
"error": "База данных не настроена или недоступна"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/tests/recent")
|
||||
async def get_recent_tests(limit: int = 10):
|
||||
"""Получение последних тестов"""
|
||||
try:
|
||||
from app.services.history_service import HistoryService
|
||||
history_service = HistoryService()
|
||||
tests = history_service.get_command_history(
|
||||
limit=limit,
|
||||
command_type="test"
|
||||
)
|
||||
return tests
|
||||
except Exception:
|
||||
# Если база данных не настроена, возвращаем пустой список
|
||||
return []
|
||||
|
||||
|
||||
@router.websocket("/ws/test/{test_id}")
|
||||
async def test_websocket(websocket: WebSocket, test_id: str):
|
||||
"""WebSocket для live логов тестирования"""
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
# Парсинг test_id (формат: role_name-preset или role_name-preset-category)
|
||||
parts = test_id.rsplit("-", 2)
|
||||
if len(parts) < 2:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": "Неверный формат test_id. Ожидается: role_name-preset или role_name-preset-category"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
role_name = parts[0]
|
||||
preset_name = parts[1] if len(parts) > 1 else "default"
|
||||
preset_category = parts[2] if len(parts) > 2 else "main"
|
||||
|
||||
# Проверка существования роли
|
||||
roles_dir = settings.PROJECT_ROOT / "roles" / role_name
|
||||
if not roles_dir.exists():
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": f"Роль '{role_name}' не найдена"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Получаем preset из БД
|
||||
async for db in get_async_db():
|
||||
preset = await PresetService.get_preset(db, preset_name, preset_category)
|
||||
if not preset:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": f"Preset '{preset_name}' не найден"
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
preset_content = preset.content
|
||||
break
|
||||
|
||||
# Отправка начального сообщения
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": f"🚀 Запуск теста роли '{role_name}' с preset '{preset_name}'..."
|
||||
})
|
||||
|
||||
# Создаем временный файл preset'а из БД
|
||||
molecule_executor.create_temp_preset_file(preset_name, preset_content, preset_category)
|
||||
|
||||
# Запуск теста через MoleculeExecutor (без Makefile)
|
||||
async for line in molecule_executor.test_role(
|
||||
role_name=role_name,
|
||||
preset_name=preset_name,
|
||||
preset_content=preset_content,
|
||||
preset_category=preset_category,
|
||||
stream=True
|
||||
):
|
||||
# Очистка строки от лишних символов
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Определение типа лога
|
||||
log_type = molecule_executor.detect_log_level(line)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "log",
|
||||
"level": log_type,
|
||||
"data": line
|
||||
})
|
||||
|
||||
# Завершение
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"status": "success",
|
||||
"data": "✅ Тест завершен успешно"
|
||||
})
|
||||
|
||||
# Удаляем временный файл
|
||||
if preset_name in molecule_executor._temp_preset_files:
|
||||
try:
|
||||
molecule_executor._temp_preset_files[preset_name].unlink()
|
||||
del molecule_executor._temp_preset_files[preset_name]
|
||||
except:
|
||||
pass
|
||||
|
||||
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
|
||||
113
app/api/v1/endpoints/vault.py
Normal file
113
app/api/v1/endpoints/vault.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
API endpoints для управления Ansible Vault
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from typing import List, Dict
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
|
||||
|
||||
@router.get("/vault", response_class=HTMLResponse)
|
||||
async def vault_page(request: Request):
|
||||
"""Страница управления Ansible Vault"""
|
||||
vault_dir = settings.PROJECT_ROOT / "vault"
|
||||
vault_files = []
|
||||
|
||||
if vault_dir.exists():
|
||||
# Ищем все .yml и .yaml файлы
|
||||
for pattern in ["*.yml", "*.yaml"]:
|
||||
for vault_file in vault_dir.glob(pattern):
|
||||
if vault_file.is_file():
|
||||
try:
|
||||
vault_files.append({
|
||||
"name": vault_file.name,
|
||||
"path": str(vault_file.relative_to(settings.PROJECT_ROOT)),
|
||||
"size": vault_file.stat().st_size
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/vault/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"vault_files": vault_files,
|
||||
"vault_dir": str(vault_dir.relative_to(settings.PROJECT_ROOT)) if vault_dir.exists() else None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/vault/files")
|
||||
async def get_vault_files():
|
||||
"""API endpoint для получения списка Vault файлов"""
|
||||
vault_dir = settings.PROJECT_ROOT / "vault"
|
||||
vault_files = []
|
||||
|
||||
if vault_dir.exists():
|
||||
for vault_file in vault_dir.glob("*.yml"):
|
||||
vault_files.append({
|
||||
"name": vault_file.name,
|
||||
"path": str(vault_file),
|
||||
"size": vault_file.stat().st_size
|
||||
})
|
||||
|
||||
return {"files": vault_files}
|
||||
|
||||
|
||||
@router.post("/api/v1/vault/encrypt")
|
||||
async def encrypt_string(request: Request):
|
||||
"""API endpoint для шифрования строки через Vault"""
|
||||
from fastapi import Body
|
||||
from app.services.vault_service import VaultService
|
||||
|
||||
data = await request.json()
|
||||
content = data.get("content", "")
|
||||
|
||||
if not content:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="Содержимое не указано")
|
||||
|
||||
try:
|
||||
vault_service = VaultService()
|
||||
result = vault_service.encrypt_string(content)
|
||||
return result
|
||||
except ValueError as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка шифрования: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/api/v1/vault/decrypt")
|
||||
async def decrypt_string(request: Request):
|
||||
"""API endpoint для расшифровки строки из Vault"""
|
||||
from fastapi import Body
|
||||
from app.services.vault_service import VaultService
|
||||
|
||||
data = await request.json()
|
||||
content = data.get("content", "")
|
||||
|
||||
if not content:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="Содержимое не указано")
|
||||
|
||||
try:
|
||||
vault_service = VaultService()
|
||||
result = vault_service.decrypt_string(content)
|
||||
return result
|
||||
except ValueError as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка расшифровки: {str(e)}")
|
||||
36
app/api/v1/router.py
Normal file
36
app/api/v1/router.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
API роутер v1
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Создание основного роутера
|
||||
api_router = APIRouter(prefix="/api/v1", tags=["api"])
|
||||
|
||||
# Импорт endpoints
|
||||
from app.api.v1.endpoints import stats, roles, tests, presets, deploy, export, auth, docker, vault, k8s, playbooks, lint, profile
|
||||
from app.api.v1.endpoints.import_role import router as import_router
|
||||
from app.api.v1.endpoints.dockerfiles_api import router as dockerfiles_router
|
||||
|
||||
# Подключение роутеров
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
api_router.include_router(roles.router, tags=["roles"])
|
||||
api_router.include_router(tests.router, tags=["tests"])
|
||||
api_router.include_router(presets.router, tags=["presets"])
|
||||
api_router.include_router(deploy.router, tags=["deploy"])
|
||||
api_router.include_router(export.router, tags=["export"])
|
||||
api_router.include_router(import_router, tags=["import"])
|
||||
api_router.include_router(docker.router, tags=["docker"])
|
||||
api_router.include_router(vault.router, tags=["vault"])
|
||||
api_router.include_router(k8s.router, tags=["k8s"])
|
||||
api_router.include_router(playbooks.router, tags=["playbooks"])
|
||||
api_router.include_router(dockerfiles_router, tags=["dockerfiles"])
|
||||
api_router.include_router(lint.router, tags=["lint"])
|
||||
# profile.router подключен напрямую к app в main.py, чтобы маршруты были доступны по /profile, а не /api/v1/profile
|
||||
|
||||
# TODO: Добавить остальные роутеры по мере реализации
|
||||
# api_router.include_router(docker.router, prefix="/docker", tags=["docker"])
|
||||
# api_router.include_router(vault.router, prefix="/vault", tags=["vault"])
|
||||
Reference in New Issue
Block a user