""" 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)}" )