- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
1270 lines
51 KiB
Python
1270 lines
51 KiB
Python
"""
|
||
API endpoints для управления Dockerfile
|
||
Автор: Сергей Антропов
|
||
Сайт: https://devops.org.ru
|
||
"""
|
||
|
||
from fastapi import APIRouter, Request, HTTPException, Depends, status, WebSocket, WebSocketDisconnect, Form
|
||
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
|
||
import json
|
||
from app.db.session import get_async_db
|
||
from app.services.dockerfile_service import DockerfileService
|
||
from app.services.docker_build_service import DockerBuildService
|
||
from app.services.docker_builder_service import docker_builder_service, DockerBuilderService
|
||
from app.services.user_service import UserService
|
||
from app.auth.deps import get_current_user, get_current_user_optional
|
||
from app.core.config import settings
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from app.models.database import DockerfileBuildLog
|
||
from sqlalchemy import select, desc, func, delete
|
||
from datetime import datetime
|
||
import asyncio
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||
templates = Jinja2Templates(directory=str(templates_path))
|
||
|
||
|
||
class DockerfileCreate(BaseModel):
|
||
name: str
|
||
description: Optional[str] = None
|
||
content: str
|
||
base_image: Optional[str] = None
|
||
tags: Optional[List[str]] = None
|
||
platforms: Optional[List[str]] = None
|
||
|
||
|
||
class DockerfileUpdate(BaseModel):
|
||
name: Optional[str] = None
|
||
|
||
|
||
class PushImageRequest(BaseModel):
|
||
"""Запрос на отправку Docker образа"""
|
||
image_name: str
|
||
tag: str
|
||
registry: Optional[str] = "docker.io" # Registry (docker.io или harbor)
|
||
description: Optional[str] = None
|
||
content: Optional[str] = None
|
||
base_image: Optional[str] = None
|
||
tags: Optional[List[str]] = None
|
||
platforms: Optional[List[str]] = None
|
||
|
||
|
||
@router.get("/dockerfiles", response_class=HTMLResponse)
|
||
async def dockerfiles_list(
|
||
request: Request,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Список всех Dockerfile"""
|
||
dockerfiles = await DockerfileService.list_dockerfiles(db)
|
||
return templates.TemplateResponse(
|
||
"pages/dockerfiles/list.html",
|
||
{
|
||
"request": request,
|
||
"dockerfiles": dockerfiles
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/dockerfiles/create", response_class=HTMLResponse)
|
||
async def dockerfile_create_page(
|
||
request: Request,
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""Страница создания Dockerfile"""
|
||
return templates.TemplateResponse(
|
||
"pages/dockerfiles/create.html",
|
||
{
|
||
"request": request
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/dockerfiles/build-logs", response_class=HTMLResponse)
|
||
async def get_all_build_logs(
|
||
request: Request,
|
||
page: int = 1,
|
||
per_page: int = 50,
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Страница со всеми логами сборки всех Dockerfile"""
|
||
# Получаем все логи сборки
|
||
offset = (page - 1) * per_page
|
||
query = select(DockerfileBuildLog).order_by(
|
||
desc(DockerfileBuildLog.started_at)
|
||
).offset(offset).limit(per_page)
|
||
|
||
result = await db.execute(query)
|
||
logs = result.scalars().all()
|
||
|
||
# Получаем информацию о Dockerfile для каждого лога
|
||
dockerfile_ids = {log.dockerfile_id for log in logs}
|
||
dockerfiles_map = {}
|
||
if dockerfile_ids:
|
||
dockerfiles = await DockerfileService.list_dockerfiles(db)
|
||
dockerfiles_map = {d.id: d for d in dockerfiles if d.id in dockerfile_ids}
|
||
|
||
# Подсчитываем общее количество
|
||
count_query = select(func.count(DockerfileBuildLog.id))
|
||
total_result = await db.execute(count_query)
|
||
total = total_result.scalar()
|
||
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||
|
||
return templates.TemplateResponse(
|
||
"pages/dockerfiles/all-build-logs.html",
|
||
{
|
||
"request": request,
|
||
"logs": logs,
|
||
"dockerfiles_map": dockerfiles_map,
|
||
"page": page,
|
||
"per_page": per_page,
|
||
"total": total,
|
||
"total_pages": total_pages
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/dockerfiles/{dockerfile_id}", response_class=HTMLResponse)
|
||
async def dockerfile_detail(
|
||
request: Request,
|
||
dockerfile_id: int,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Детали Dockerfile"""
|
||
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
|
||
if not dockerfile:
|
||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||
|
||
# Получаем последние логи сборки для этого Dockerfile
|
||
build_logs_query = select(DockerfileBuildLog).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
).order_by(desc(DockerfileBuildLog.started_at)).limit(10)
|
||
|
||
result = await db.execute(build_logs_query)
|
||
build_logs = result.scalars().all()
|
||
|
||
return templates.TemplateResponse(
|
||
"pages/dockerfiles/detail.html",
|
||
{
|
||
"request": request,
|
||
"dockerfile": dockerfile,
|
||
"build_logs": build_logs
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/dockerfiles/{dockerfile_id}/edit", response_class=HTMLResponse)
|
||
async def dockerfile_edit_page(
|
||
request: Request,
|
||
dockerfile_id: int,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Страница редактирования Dockerfile"""
|
||
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
|
||
if not dockerfile:
|
||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||
|
||
return templates.TemplateResponse(
|
||
"pages/dockerfiles/edit.html",
|
||
{
|
||
"request": request,
|
||
"dockerfile": dockerfile
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/dockerfiles/{dockerfile_id}/build", response_class=HTMLResponse)
|
||
async def dockerfile_build_page(
|
||
request: Request,
|
||
dockerfile_id: int,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Страница сборки Dockerfile"""
|
||
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
|
||
if not dockerfile:
|
||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||
|
||
# Получаем последние логи сборки (для истории справа)
|
||
recent_logs_query = select(DockerfileBuildLog).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
).order_by(desc(DockerfileBuildLog.started_at)).limit(5)
|
||
|
||
recent_logs_result = await db.execute(recent_logs_query)
|
||
recent_logs = recent_logs_result.scalars().all()
|
||
|
||
return templates.TemplateResponse(
|
||
"pages/dockerfiles/build.html",
|
||
{
|
||
"request": request,
|
||
"dockerfile": dockerfile,
|
||
"recent_logs": recent_logs
|
||
}
|
||
)
|
||
|
||
|
||
@router.post("/api/v1/dockerfiles")
|
||
async def create_dockerfile(
|
||
request: Request,
|
||
name: str = Form(...),
|
||
description: Optional[str] = Form(None),
|
||
content: str = Form(...),
|
||
base_image: Optional[str] = Form(None),
|
||
tags: Optional[str] = Form(None),
|
||
platforms_json: Optional[str] = Form(None),
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Создание нового Dockerfile"""
|
||
# Проверяем, что Dockerfile с таким именем не существует
|
||
existing = await DockerfileService.get_dockerfile_by_name(db, name)
|
||
if existing:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Dockerfile с именем '{name}' уже существует"
|
||
)
|
||
|
||
# Парсим platforms из JSON строки
|
||
platforms = None
|
||
if platforms_json:
|
||
try:
|
||
platforms = json.loads(platforms_json)
|
||
except json.JSONDecodeError:
|
||
platforms = None
|
||
|
||
# Парсим tags из строки через запятую
|
||
tags_list = None
|
||
if tags:
|
||
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||
|
||
new_dockerfile = await DockerfileService.create_dockerfile(
|
||
db=db,
|
||
name=name,
|
||
content=content,
|
||
description=description,
|
||
base_image=base_image,
|
||
tags=tags_list,
|
||
platforms=platforms,
|
||
created_by=current_user.get("username")
|
||
)
|
||
|
||
return {"id": new_dockerfile.id, "name": new_dockerfile.name, "message": "Dockerfile создан успешно"}
|
||
|
||
|
||
@router.put("/api/v1/dockerfiles/{dockerfile_id}")
|
||
async def update_dockerfile(
|
||
dockerfile_id: int,
|
||
request: Request,
|
||
name: Optional[str] = Form(None),
|
||
description: Optional[str] = Form(None),
|
||
content: Optional[str] = Form(None),
|
||
base_image: Optional[str] = Form(None),
|
||
tags: Optional[str] = Form(None),
|
||
platforms_json: Optional[str] = Form(None),
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Обновление Dockerfile"""
|
||
# Парсим platforms из JSON строки
|
||
platforms = None
|
||
if platforms_json:
|
||
try:
|
||
platforms = json.loads(platforms_json)
|
||
except json.JSONDecodeError:
|
||
platforms = None
|
||
|
||
# Парсим tags из строки через запятую
|
||
tags_list = None
|
||
if tags:
|
||
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||
|
||
updated = await DockerfileService.update_dockerfile(
|
||
db=db,
|
||
dockerfile_id=dockerfile_id,
|
||
name=name,
|
||
description=description,
|
||
content=content,
|
||
base_image=base_image,
|
||
tags=tags_list,
|
||
platforms=platforms,
|
||
updated_by=current_user.get("username")
|
||
)
|
||
|
||
if not updated:
|
||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||
|
||
return JSONResponse(content={"message": "Dockerfile обновлен успешно"})
|
||
|
||
|
||
@router.delete("/api/v1/dockerfiles/{dockerfile_id}")
|
||
async def delete_dockerfile(
|
||
dockerfile_id: int,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Удаление Dockerfile"""
|
||
deleted = await DockerfileService.delete_dockerfile(db, dockerfile_id)
|
||
if not deleted:
|
||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||
|
||
return {"message": "Dockerfile удален успешно"}
|
||
|
||
|
||
@router.get("/api/v1/dockerfiles")
|
||
async def list_dockerfiles_api(
|
||
status_filter: Optional[str] = None,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""API: Список всех Dockerfile"""
|
||
dockerfiles = await DockerfileService.list_dockerfiles(db, status=status_filter)
|
||
return [
|
||
{
|
||
"id": d.id,
|
||
"name": d.name,
|
||
"description": d.description,
|
||
"base_image": d.base_image,
|
||
"tags": d.tags,
|
||
"status": d.status,
|
||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||
"updated_at": d.updated_at.isoformat() if d.updated_at else None
|
||
}
|
||
for d in dockerfiles
|
||
]
|
||
|
||
|
||
# Эндпоинт загрузки из файловой системы отключен, так как Dockerfile загружаются автоматически через миграции
|
||
# @router.post("/api/v1/dockerfiles/load-from-fs")
|
||
# async def load_dockerfiles_from_fs(
|
||
# current_user: dict = Depends(get_current_user),
|
||
# db: AsyncSession = Depends(get_async_db)
|
||
# ):
|
||
# """Загрузка Dockerfile из файловой системы в БД"""
|
||
# loaded = await DockerfileService.load_from_filesystem(
|
||
# db=db,
|
||
# project_root=settings.PROJECT_ROOT,
|
||
# created_by=current_user.get("username")
|
||
# )
|
||
#
|
||
# return {
|
||
# "message": f"Загружено {len(loaded)} Dockerfile",
|
||
# "loaded": [{"id": d.id, "name": d.name} for d in loaded]
|
||
# }
|
||
|
||
|
||
@router.websocket("/ws/dockerfile/build/{dockerfile_id}")
|
||
async def dockerfile_build_websocket(websocket: WebSocket, dockerfile_id: int):
|
||
"""WebSocket для live логов сборки Dockerfile через Celery с чтением из БД"""
|
||
await websocket.accept()
|
||
|
||
import asyncio
|
||
from app.tasks.celery_tasks import build_dockerfile as celery_build_task
|
||
|
||
# Получаем текущего пользователя
|
||
current_user = None
|
||
try:
|
||
token = websocket.cookies.get("access_token") or websocket.headers.get("authorization", "").replace("Bearer ", "")
|
||
if token:
|
||
from app.auth.security import decode_access_token
|
||
payload = decode_access_token(token)
|
||
if payload:
|
||
current_user = payload.get("sub")
|
||
except:
|
||
pass
|
||
|
||
build_log_id = None
|
||
celery_task_id = None
|
||
last_log_length = 0
|
||
|
||
try:
|
||
# Получаем параметры сборки
|
||
build_data = await websocket.receive_json()
|
||
build_log_id = build_data.get("build_log_id") # Если переподключение к существующей сборке
|
||
|
||
if build_log_id:
|
||
# Переподключение к существующей сборке - читаем логи из БД
|
||
async for db in get_async_db():
|
||
build_log = await db.get(DockerfileBuildLog, build_log_id)
|
||
if not build_log:
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": f"Лог сборки #{build_log_id} не найден"
|
||
})
|
||
await websocket.close()
|
||
return
|
||
|
||
# Отправляем уже сохраненные логи
|
||
if build_log.logs:
|
||
logs_lines = build_log.logs.split('\n')
|
||
for line in logs_lines:
|
||
if line.strip():
|
||
build_service = DockerBuildService()
|
||
log_type = build_service.detect_log_level(line)
|
||
await websocket.send_json({
|
||
"type": "log",
|
||
"level": log_type,
|
||
"data": line
|
||
})
|
||
last_log_length = len(build_log.logs)
|
||
|
||
# Если сборка еще идет, продолжаем читать логи
|
||
if build_log.status == "running":
|
||
# Пытаемся найти задачу Celery по build_log_id
|
||
# TODO: Сохранять celery_task_id в extra_data при создании
|
||
pass
|
||
else:
|
||
# Сборка завершена
|
||
await websocket.send_json({
|
||
"type": "complete",
|
||
"status": build_log.status,
|
||
"data": "✅ Сборка завершена" if build_log.status == "success" else "❌ Сборка завершена с ошибкой",
|
||
"image_name": build_log.image_name,
|
||
"tag": build_log.tag
|
||
})
|
||
await websocket.close()
|
||
return
|
||
break
|
||
else:
|
||
# Новая сборка - создаем запись и запускаем Celery задачу
|
||
image_name = build_data.get("image_name")
|
||
tag = build_data.get("tag", "latest")
|
||
platforms = build_data.get("platforms")
|
||
dockerfile_content = build_data.get("dockerfile_content")
|
||
no_cache = build_data.get("no_cache", False)
|
||
|
||
# Получаем Dockerfile из БД
|
||
async for db in get_async_db():
|
||
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
|
||
if not dockerfile:
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": f"Dockerfile #{dockerfile_id} не найден"
|
||
})
|
||
await websocket.close()
|
||
return
|
||
|
||
# Используем сохраненные платформы, если не переданы
|
||
if not platforms:
|
||
platforms = dockerfile.platforms or ["linux/amd64", "linux/386", "linux/arm64"]
|
||
|
||
if not dockerfile_content:
|
||
dockerfile_content = dockerfile.content
|
||
|
||
# Создаем запись лога в БД
|
||
start_time = datetime.utcnow()
|
||
build_log = DockerfileBuildLog(
|
||
dockerfile_id=dockerfile_id,
|
||
image_name=image_name,
|
||
tag=tag,
|
||
platforms=platforms,
|
||
status="running",
|
||
logs="🚀 Запуск сборки...\n",
|
||
started_at=start_time,
|
||
user=current_user
|
||
)
|
||
db.add(build_log)
|
||
await db.commit()
|
||
build_log_id = build_log.id
|
||
|
||
# Отправляем начальный лог через WebSocket
|
||
await websocket.send_json({
|
||
"type": "log",
|
||
"level": "info",
|
||
"data": "🚀 Запуск сборки..."
|
||
})
|
||
break
|
||
|
||
# Запускаем Celery задачу
|
||
# Передаем dockerfile_id вместо dockerfile_content - билдер сам получит содержимое из БД
|
||
celery_task = celery_build_task.delay(
|
||
build_log_id=build_log_id,
|
||
dockerfile_id=dockerfile_id, # Передаем ID - билдер получит содержимое из БД
|
||
image_name=image_name,
|
||
tag=tag,
|
||
platforms=platforms,
|
||
no_cache=no_cache
|
||
)
|
||
celery_task_id = celery_task.id
|
||
|
||
# Сохраняем celery_task_id в extra_data
|
||
async for db in get_async_db():
|
||
from sqlalchemy import update
|
||
await db.execute(
|
||
update(DockerfileBuildLog)
|
||
.where(DockerfileBuildLog.id == build_log_id)
|
||
.values(extra_data={"celery_task_id": celery_task_id})
|
||
)
|
||
await db.commit()
|
||
break
|
||
|
||
await websocket.send_json({
|
||
"type": "info",
|
||
"data": f"🚀 Сборка запущена в фоне (ID задачи: {celery_task_id})",
|
||
"build_log_id": build_log_id,
|
||
"celery_task_id": celery_task_id
|
||
})
|
||
|
||
# Сохраняем build_log_id для использования в цикле чтения логов
|
||
# (уже сохранен выше, но убеждаемся что он доступен)
|
||
|
||
# Читаем логи из БД в реальном времени
|
||
retry_count = 0
|
||
max_retries = 3
|
||
|
||
while True:
|
||
await asyncio.sleep(1) # Опрашиваем БД каждую секунду
|
||
|
||
try:
|
||
async for db in get_async_db():
|
||
# Получаем объект из БД заново по ID
|
||
build_log = await db.get(DockerfileBuildLog, build_log_id)
|
||
if not build_log:
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": f"Лог сборки #{build_log_id} не найден"
|
||
})
|
||
await websocket.close()
|
||
return
|
||
|
||
# Отправляем новые строки логов
|
||
if build_log.logs:
|
||
current_log_length = len(build_log.logs)
|
||
if current_log_length > last_log_length:
|
||
new_logs = build_log.logs[last_log_length:]
|
||
logs_lines = new_logs.split('\n')
|
||
for line in logs_lines:
|
||
if line.strip():
|
||
build_service = DockerBuildService()
|
||
log_type = build_service.detect_log_level(line)
|
||
try:
|
||
await websocket.send_json({
|
||
"type": "log",
|
||
"level": log_type,
|
||
"data": line
|
||
})
|
||
except:
|
||
# Соединение закрыто
|
||
return
|
||
last_log_length = current_log_length
|
||
elif current_log_length < last_log_length:
|
||
# Логи были очищены или перезаписаны, начинаем заново
|
||
last_log_length = 0
|
||
if build_log.logs:
|
||
logs_lines = build_log.logs.split('\n')
|
||
for line in logs_lines:
|
||
if line.strip():
|
||
build_service = DockerBuildService()
|
||
log_type = build_service.detect_log_level(line)
|
||
try:
|
||
await websocket.send_json({
|
||
"type": "log",
|
||
"level": log_type,
|
||
"data": line
|
||
})
|
||
except:
|
||
return
|
||
last_log_length = len(build_log.logs)
|
||
|
||
# Проверяем статус сборки
|
||
if build_log.status != "running":
|
||
# Сборка завершена
|
||
await websocket.send_json({
|
||
"type": "complete",
|
||
"status": build_log.status,
|
||
"data": "✅ Сборка завершена" if build_log.status == "success" else "❌ Сборка завершена с ошибкой",
|
||
"image_name": build_log.image_name,
|
||
"tag": build_log.tag
|
||
})
|
||
await websocket.close()
|
||
return
|
||
break
|
||
|
||
# Сбрасываем счетчик повторов при успешном запросе
|
||
retry_count = 0
|
||
|
||
except Exception as e:
|
||
import logging
|
||
error_msg = str(e)
|
||
if "connection is closed" in error_msg.lower() or "InterfaceError" in error_msg:
|
||
retry_count += 1
|
||
if retry_count >= max_retries:
|
||
import logging
|
||
logging.error(f"Превышено количество попыток переподключения к БД: {e}")
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": f"Ошибка подключения к БД: {error_msg}"
|
||
})
|
||
await websocket.close()
|
||
return
|
||
# Ждем перед повторной попыткой
|
||
await asyncio.sleep(2)
|
||
else:
|
||
# Другие ошибки - логируем и продолжаем
|
||
import logging
|
||
logging.error(f"Ошибка при чтении логов из БД: {e}")
|
||
await asyncio.sleep(2)
|
||
|
||
except WebSocketDisconnect:
|
||
# При разрыве соединения сборка продолжается в Celery
|
||
# Логи уже сохраняются в БД, просто закрываем соединение
|
||
pass
|
||
except Exception as e:
|
||
import traceback
|
||
import logging
|
||
error_msg = f"❌ Ошибка WebSocket: {str(e)}\n{traceback.format_exc()}"
|
||
logging.error(error_msg)
|
||
|
||
try:
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": error_msg
|
||
})
|
||
except:
|
||
pass
|
||
finally:
|
||
try:
|
||
await websocket.close()
|
||
except:
|
||
pass
|
||
|
||
|
||
@router.websocket("/api/v1/dockerfiles/{dockerfile_id}/push/ws")
|
||
async def dockerfile_push_websocket(websocket: WebSocket, dockerfile_id: int):
|
||
"""WebSocket для live логов пуша Docker образа через webhook"""
|
||
await websocket.accept()
|
||
|
||
try:
|
||
# Получаем параметры пуша
|
||
data = await websocket.receive_json()
|
||
image_name = data.get("image_name")
|
||
tag = data.get("tag", "latest")
|
||
registry = data.get("registry", "docker.io")
|
||
|
||
# Формируем полное имя образа
|
||
if ":" in image_name:
|
||
full_image_name = image_name
|
||
else:
|
||
full_image_name = f"{image_name}:{tag}"
|
||
|
||
# Получаем настройки пользователя из профиля
|
||
from app.auth.security import decode_access_token
|
||
from app.models.user import UserProfile
|
||
from sqlalchemy import select
|
||
|
||
username = None
|
||
password = None
|
||
|
||
access_token = None
|
||
if "access_token" in websocket.cookies:
|
||
access_token = websocket.cookies.get("access_token")
|
||
if not access_token:
|
||
query_params = dict(websocket.query_params)
|
||
access_token = query_params.get("token")
|
||
|
||
if access_token:
|
||
try:
|
||
payload = decode_access_token(access_token)
|
||
username_token = payload.get("sub")
|
||
if username_token:
|
||
async for db in get_async_db():
|
||
user = await UserService.get_user_by_username(db, username_token)
|
||
if user:
|
||
result = await db.execute(
|
||
select(UserProfile).where(UserProfile.user_id == user.id)
|
||
)
|
||
profile = result.scalar_one_or_none()
|
||
if profile:
|
||
if registry == "docker.io":
|
||
username = profile.dockerhub_username
|
||
password = profile.dockerhub_password
|
||
else:
|
||
username = profile.harbor_username
|
||
password = profile.harbor_password
|
||
if not registry.startswith("http"):
|
||
registry = profile.harbor_url or registry
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
# Формируем webhook URL для получения логов
|
||
webhook_url = f"http://web:8000/api/v1/dockerfiles/build-logs/webhook"
|
||
|
||
# Запускаем push через Builder API
|
||
builder_service = DockerBuilderService(base_url=settings.DOCKER_BUILDER_URL)
|
||
result = await builder_service.push_image(
|
||
image_name=full_image_name,
|
||
webhook_url=webhook_url,
|
||
registry=registry,
|
||
username=username,
|
||
password=password
|
||
)
|
||
|
||
# Получаем push_log_id из данных WebSocket (передан из POST endpoint)
|
||
push_log_id = data.get("push_log_id")
|
||
|
||
if not push_log_id:
|
||
# Если push_log_id не передан, значит push еще не запущен
|
||
# Это не должно происходить, но на всякий случай отправляем ошибку
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": "push_log_id не передан. Push должен быть запущен через POST endpoint."
|
||
})
|
||
await websocket.close()
|
||
return
|
||
|
||
# Формируем полное имя образа для отображения
|
||
if ":" in image_name:
|
||
full_image_name = image_name
|
||
else:
|
||
full_image_name = f"{image_name}:{tag}"
|
||
|
||
# Отправляем подтверждение подключения
|
||
await websocket.send_json({
|
||
"type": "status",
|
||
"status": "connected",
|
||
"data": f"🔗 Подключено к логам отправки образа {full_image_name}"
|
||
})
|
||
|
||
# Читаем логи из БД в реальном времени (как для build)
|
||
if push_log_id:
|
||
last_log_length = 0
|
||
retry_count = 0
|
||
max_retries = 3
|
||
|
||
while True:
|
||
await asyncio.sleep(1) # Опрашиваем БД каждую секунду
|
||
|
||
try:
|
||
async for db_session in get_async_db():
|
||
# Получаем объект из БД заново по ID
|
||
push_log = await db_session.get(DockerfileBuildLog, push_log_id)
|
||
if not push_log:
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": f"Лог отправки #{push_log_id} не найден"
|
||
})
|
||
await websocket.close()
|
||
return
|
||
|
||
# Отправляем новые строки логов
|
||
if push_log.logs:
|
||
current_log_length = len(push_log.logs)
|
||
if current_log_length > last_log_length:
|
||
new_logs = push_log.logs[last_log_length:]
|
||
logs_lines = new_logs.split('\n')
|
||
for line in logs_lines:
|
||
if line.strip():
|
||
build_service = DockerBuildService()
|
||
log_type = build_service.detect_log_level(line)
|
||
try:
|
||
await websocket.send_json({
|
||
"type": "log",
|
||
"level": log_type,
|
||
"data": line
|
||
})
|
||
except:
|
||
# Соединение закрыто
|
||
return
|
||
last_log_length = current_log_length
|
||
|
||
# Проверяем статус отправки
|
||
if push_log.status != "running":
|
||
# Отправка завершена
|
||
final_status = "success" if push_log.status == "success" else "failed"
|
||
await websocket.send_json({
|
||
"type": "complete",
|
||
"status": final_status,
|
||
"data": "✅ Отправка завершена" if push_log.status == "success" else "❌ Отправка завершена с ошибкой"
|
||
})
|
||
await websocket.close()
|
||
return
|
||
break
|
||
|
||
# Сбрасываем счетчик повторов при успешном запросе
|
||
retry_count = 0
|
||
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
if "connection is closed" in error_msg.lower() or "InterfaceError" in error_msg:
|
||
retry_count += 1
|
||
if retry_count >= max_retries:
|
||
logger.error(f"Превышено количество попыток переподключения к БД: {e}")
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": f"Ошибка подключения к БД: {error_msg}"
|
||
})
|
||
await websocket.close()
|
||
return
|
||
await asyncio.sleep(2)
|
||
else:
|
||
logger.error(f"Ошибка при чтении логов из БД: {e}")
|
||
await asyncio.sleep(2)
|
||
else:
|
||
# Если не удалось создать запись в БД, просто ждем и отправляем финальное сообщение
|
||
await asyncio.sleep(5) # Даем время на push
|
||
await websocket.send_json({
|
||
"type": "status",
|
||
"status": "success",
|
||
"data": "✅ Отправка образа завершена"
|
||
})
|
||
|
||
except WebSocketDisconnect:
|
||
pass
|
||
except Exception as e:
|
||
import traceback
|
||
import logging
|
||
error_msg = f"❌ Ошибка: {str(e)}"
|
||
logging.error(f"Push WebSocket error: {e}", exc_info=True)
|
||
try:
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"data": error_msg
|
||
})
|
||
except:
|
||
pass
|
||
finally:
|
||
try:
|
||
await websocket.close()
|
||
except:
|
||
pass
|
||
|
||
|
||
@router.get("/dockerfiles/{dockerfile_id}/build-logs", response_class=HTMLResponse)
|
||
async def get_dockerfile_build_logs(
|
||
request: Request,
|
||
dockerfile_id: int,
|
||
page: int = 1,
|
||
per_page: int = 20,
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Страница с историей логов сборки Dockerfile"""
|
||
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
|
||
if not dockerfile:
|
||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||
|
||
# Получаем логи сборки
|
||
offset = (page - 1) * per_page
|
||
query = select(DockerfileBuildLog).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
).order_by(desc(DockerfileBuildLog.started_at)).offset(offset).limit(per_page)
|
||
|
||
result = await db.execute(query)
|
||
logs = result.scalars().all()
|
||
|
||
# Подсчитываем общее количество
|
||
count_query = select(func.count(DockerfileBuildLog.id)).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
)
|
||
total_result = await db.execute(count_query)
|
||
total = total_result.scalar()
|
||
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||
|
||
return templates.TemplateResponse(
|
||
"pages/dockerfiles/build-logs.html",
|
||
{
|
||
"request": request,
|
||
"dockerfile": dockerfile,
|
||
"logs": logs,
|
||
"page": page,
|
||
"per_page": per_page,
|
||
"total": total,
|
||
"total_pages": total_pages
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/api/v1/dockerfiles/{dockerfile_id}/build-logs")
|
||
async def get_dockerfile_build_logs_api(
|
||
dockerfile_id: int,
|
||
page: int = 1,
|
||
per_page: int = 20,
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""API для получения логов сборки Dockerfile"""
|
||
offset = (page - 1) * per_page
|
||
query = select(DockerfileBuildLog).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
).order_by(desc(DockerfileBuildLog.started_at)).offset(offset).limit(per_page)
|
||
|
||
result = await db.execute(query)
|
||
logs = result.scalars().all()
|
||
|
||
# Подсчитываем общее количество
|
||
count_query = select(func.count(DockerfileBuildLog.id)).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
)
|
||
total_result = await db.execute(count_query)
|
||
total = total_result.scalar()
|
||
|
||
return {
|
||
"logs": [
|
||
{
|
||
"id": log.id,
|
||
"image_name": log.image_name,
|
||
"tag": log.tag,
|
||
"platforms": log.platforms,
|
||
"status": log.status,
|
||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||
"finished_at": log.finished_at.isoformat() if log.finished_at else None,
|
||
"duration": log.duration,
|
||
"returncode": log.returncode,
|
||
"user": log.user
|
||
}
|
||
for log in logs
|
||
],
|
||
"total": total,
|
||
"page": page,
|
||
"per_page": per_page,
|
||
"total_pages": (total + per_page - 1) // per_page if total > 0 else 1
|
||
}
|
||
|
||
|
||
@router.get("/api/v1/dockerfiles/build-logs/{log_id}")
|
||
async def get_build_log_detail(
|
||
log_id: int,
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Получение детального лога сборки"""
|
||
query = select(DockerfileBuildLog).where(DockerfileBuildLog.id == log_id)
|
||
result = await db.execute(query)
|
||
log = result.scalar_one_or_none()
|
||
|
||
if not log:
|
||
raise HTTPException(status_code=404, detail="Лог не найден")
|
||
|
||
return {
|
||
"id": log.id,
|
||
"dockerfile_id": log.dockerfile_id,
|
||
"image_name": log.image_name,
|
||
"tag": log.tag,
|
||
"platforms": log.platforms,
|
||
"status": log.status,
|
||
"logs": log.logs,
|
||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||
"finished_at": log.finished_at.isoformat() if log.finished_at else None,
|
||
"duration": log.duration,
|
||
"returncode": log.returncode,
|
||
"user": log.user
|
||
}
|
||
|
||
|
||
@router.delete("/api/v1/dockerfiles/{dockerfile_id}/build-logs")
|
||
async def clear_dockerfile_build_logs(
|
||
dockerfile_id: int,
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Очистка всех логов сборки для Dockerfile"""
|
||
await db.execute(
|
||
delete(DockerfileBuildLog).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
)
|
||
)
|
||
await db.commit()
|
||
|
||
return {"message": f"Логи сборки для Dockerfile #{dockerfile_id} очищены"}
|
||
|
||
|
||
@router.delete("/api/v1/dockerfiles/build-logs/{log_id}")
|
||
async def delete_build_log(
|
||
log_id: int,
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Удаление конкретного лога сборки"""
|
||
query = select(DockerfileBuildLog).where(DockerfileBuildLog.id == log_id)
|
||
result = await db.execute(query)
|
||
log = result.scalar_one_or_none()
|
||
|
||
if not log:
|
||
raise HTTPException(status_code=404, detail="Лог не найден")
|
||
|
||
await db.execute(delete(DockerfileBuildLog).where(DockerfileBuildLog.id == log_id))
|
||
await db.commit()
|
||
|
||
return {"message": f"Лог #{log_id} удален"}
|
||
|
||
|
||
@router.post("/api/v1/dockerfiles/build-logs/{log_id}/cancel")
|
||
async def cancel_build_log(
|
||
log_id: int,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Остановка сборки (отмена Celery задачи)"""
|
||
query = select(DockerfileBuildLog).where(DockerfileBuildLog.id == log_id)
|
||
result = await db.execute(query)
|
||
log = result.scalar_one_or_none()
|
||
|
||
if not log:
|
||
raise HTTPException(status_code=404, detail="Лог не найден")
|
||
|
||
if log.status != "running":
|
||
raise HTTPException(status_code=400, detail="Сборка не выполняется")
|
||
|
||
# Получаем celery_task_id из extra_data
|
||
celery_task_id = None
|
||
if log.extra_data and isinstance(log.extra_data, dict):
|
||
celery_task_id = log.extra_data.get("celery_task_id")
|
||
|
||
if not celery_task_id:
|
||
raise HTTPException(status_code=400, detail="ID задачи Celery не найден")
|
||
|
||
# Отменяем задачу Celery
|
||
try:
|
||
from app.tasks.celery_tasks import celery_app
|
||
celery_app.control.revoke(celery_task_id, terminate=True)
|
||
|
||
# Обновляем статус в БД
|
||
from sqlalchemy import update
|
||
from datetime import datetime
|
||
await db.execute(
|
||
update(DockerfileBuildLog)
|
||
.where(DockerfileBuildLog.id == log_id)
|
||
.values(
|
||
status="failed",
|
||
finished_at=datetime.utcnow(),
|
||
returncode=-1,
|
||
logs=(log.logs or "") + "\n⚠️ Сборка отменена пользователем"
|
||
)
|
||
)
|
||
await db.commit()
|
||
|
||
return {"message": "Сборка отменена успешно", "task_id": celery_task_id}
|
||
except Exception as e:
|
||
import logging
|
||
logging.error(f"Ошибка при отмене задачи Celery: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка при отмене сборки: {str(e)}")
|
||
|
||
|
||
@router.get("/api/v1/dockerfiles/{dockerfile_id}/build-logs/recent")
|
||
async def get_recent_build_logs(
|
||
dockerfile_id: int,
|
||
limit: int = 50,
|
||
include_logs: bool = True,
|
||
max_log_lines: int = 100,
|
||
current_user: Optional[dict] = Depends(get_current_user_optional),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""Получение последних логов сборки для обновления истории"""
|
||
query = select(DockerfileBuildLog).where(
|
||
DockerfileBuildLog.dockerfile_id == dockerfile_id
|
||
).order_by(desc(DockerfileBuildLog.started_at)).limit(limit)
|
||
|
||
result = await db.execute(query)
|
||
logs = result.scalars().all()
|
||
|
||
result_logs = []
|
||
for log in logs:
|
||
log_data = {
|
||
"id": log.id,
|
||
"image_name": log.image_name,
|
||
"tag": log.tag,
|
||
"status": log.status,
|
||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||
"duration": log.duration,
|
||
"extra_data": log.extra_data
|
||
}
|
||
|
||
# Добавляем логи, если запрошено
|
||
if include_logs and log.logs:
|
||
log_lines = log.logs.split('\n')
|
||
# Если логов больше max_log_lines, берем последние строки
|
||
if len(log_lines) > max_log_lines:
|
||
log_data["logs"] = '\n'.join(log_lines[-max_log_lines:])
|
||
log_data["logs_truncated"] = True
|
||
log_data["logs_total_lines"] = len(log_lines)
|
||
else:
|
||
log_data["logs"] = log.logs
|
||
log_data["logs_truncated"] = False
|
||
|
||
result_logs.append(log_data)
|
||
|
||
return result_logs
|
||
|
||
|
||
@router.post("/api/v1/dockerfiles/build-logs/webhook")
|
||
async def build_log_webhook(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""
|
||
Webhook для получения логов сборки и push от Docker Builder приложения
|
||
|
||
Принимает POST запросы от builder с логами и обновляет запись в БД
|
||
Если build_log_id не указан (равен 0), логи просто возвращаются (для push)
|
||
"""
|
||
try:
|
||
data = await request.json()
|
||
build_log_id = data.get("build_log_id", 0)
|
||
log_line = data.get("log", "")
|
||
status_update = data.get("status")
|
||
|
||
# Если build_log_id не указан или равен 0, это push логи - просто возвращаем успех
|
||
if not build_log_id or build_log_id == 0:
|
||
return {"status": "ok", "message": "Push log received"}
|
||
|
||
# Получаем запись лога
|
||
build_log = await db.get(DockerfileBuildLog, build_log_id)
|
||
if not build_log:
|
||
raise HTTPException(status_code=404, detail=f"Build log {build_log_id} not found")
|
||
|
||
# Обновляем логи
|
||
current_logs = build_log.logs or ""
|
||
if log_line:
|
||
current_logs += log_line
|
||
|
||
# Обновляем статус если передан
|
||
update_values = {"logs": current_logs}
|
||
if status_update:
|
||
update_values["status"] = status_update
|
||
if status_update in ["success", "failed"]:
|
||
update_values["finished_at"] = datetime.utcnow()
|
||
if build_log.started_at:
|
||
duration = int((datetime.utcnow() - build_log.started_at).total_seconds())
|
||
update_values["duration"] = duration
|
||
if status_update == "failed":
|
||
update_values["returncode"] = -1
|
||
else:
|
||
update_values["returncode"] = 0
|
||
|
||
# Сохраняем изменения
|
||
from sqlalchemy import update
|
||
await db.execute(
|
||
update(DockerfileBuildLog)
|
||
.where(DockerfileBuildLog.id == build_log_id)
|
||
.values(**update_values)
|
||
)
|
||
await db.commit()
|
||
|
||
return {"status": "ok", "build_log_id": build_log_id}
|
||
|
||
except Exception as e:
|
||
import logging
|
||
logging.error(f"Error in build log webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/api/v1/dockerfiles/{dockerfile_id}/push")
|
||
async def push_docker_image(
|
||
dockerfile_id: int,
|
||
request: PushImageRequest,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_async_db)
|
||
):
|
||
"""
|
||
Запуск отправки Docker образа в registry
|
||
|
||
Args:
|
||
dockerfile_id: ID Dockerfile
|
||
image_name: Имя образа
|
||
tag: Тег образа
|
||
"""
|
||
from app.services.docker_builder_service import DockerBuilderService
|
||
from app.core.config import settings
|
||
|
||
# Формируем полное имя образа
|
||
if ":" in request.image_name:
|
||
full_image_name = request.image_name
|
||
else:
|
||
full_image_name = f"{request.image_name}:{request.tag}"
|
||
|
||
# Создаем запись в БД для push лога ДО запуска push
|
||
push_log_id = None
|
||
try:
|
||
# Формируем полное имя образа
|
||
if ":" in request.image_name:
|
||
full_image_name = request.image_name
|
||
else:
|
||
full_image_name = f"{request.image_name}:{request.tag}"
|
||
|
||
# Создаем запись для push логов
|
||
push_log = DockerfileBuildLog(
|
||
dockerfile_id=dockerfile_id,
|
||
image_name=full_image_name.split(":")[0] if ":" in full_image_name else full_image_name,
|
||
tag=full_image_name.split(":")[1] if ":" in full_image_name else "latest",
|
||
status="running",
|
||
logs="",
|
||
started_at=datetime.utcnow(),
|
||
user=current_user.get("username"), # Сохраняем пользователя из POST endpoint
|
||
platforms=None, # Для push платформы не используются
|
||
extra_data={"type": "push", "registry": request.registry or "docker.io"}
|
||
)
|
||
db.add(push_log)
|
||
await db.commit()
|
||
await db.refresh(push_log)
|
||
push_log_id = push_log.id
|
||
logger.info(f"Created push log with ID: {push_log_id}, user: {current_user.get('username')}")
|
||
except Exception as e:
|
||
logger.error(f"Error creating push log: {e}", exc_info=True)
|
||
# Продолжаем без сохранения в БД
|
||
|
||
# Формируем webhook URL для получения логов с push_log_id
|
||
if push_log_id:
|
||
webhook_url = f"http://web:8000/api/v1/dockerfiles/build-logs/webhook?build_log_id={push_log_id}"
|
||
else:
|
||
webhook_url = f"http://web:8000/api/v1/dockerfiles/build-logs/webhook"
|
||
|
||
# Инициализируем builder service
|
||
builder_service = DockerBuilderService(base_url=settings.DOCKER_BUILDER_URL)
|
||
|
||
try:
|
||
# Получаем настройки пользователя из профиля
|
||
username = None
|
||
password = None
|
||
registry = request.registry or "docker.io"
|
||
|
||
# Получаем профиль пользователя для определения registry
|
||
async for db_session in get_async_db():
|
||
user = await UserService.get_user_by_username(db_session, current_user.get("username"))
|
||
if user:
|
||
from app.models.user import UserProfile
|
||
from sqlalchemy import select
|
||
result = await db_session.execute(
|
||
select(UserProfile).where(UserProfile.user_id == user.id)
|
||
)
|
||
profile = result.scalar_one_or_none()
|
||
if profile:
|
||
# Используем настройки из профиля в зависимости от выбранного registry
|
||
if registry == "docker.io":
|
||
username = profile.dockerhub_username
|
||
password = profile.dockerhub_password
|
||
else:
|
||
# Для Harbor
|
||
username = profile.harbor_username
|
||
password = profile.harbor_password
|
||
if registry == "harbor" and profile.harbor_url:
|
||
registry = profile.harbor_url
|
||
break
|
||
|
||
# Запускаем push через Builder API
|
||
result = await builder_service.push_image(
|
||
image_name=full_image_name,
|
||
webhook_url=webhook_url,
|
||
registry=registry,
|
||
username=username,
|
||
password=password
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Отправка образа {full_image_name} запущена",
|
||
"status": result.get("status"),
|
||
"push_log_id": push_log_id # Возвращаем ID лога для WebSocket
|
||
}
|
||
except Exception as e:
|
||
import logging
|
||
logging.error(f"Error starting push: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"Не удалось запустить отправку образа: {str(e)}"
|
||
)
|