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

1283 lines
52 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""
API endpoints для управления 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"""
# Получаем имя Dockerfile до удаления
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
if not dockerfile:
raise HTTPException(status_code=404, detail="Dockerfile не найден")
dockerfile_name = dockerfile.name
# Удаляем Dockerfile
deleted = await DockerfileService.delete_dockerfile(db, dockerfile_id)
if not deleted:
raise HTTPException(status_code=404, detail="Dockerfile не найден")
return JSONResponse(content={
"success": True,
"dockerfile_id": dockerfile_id,
"dockerfile_name": dockerfile_name,
"message": f"Dockerfile '{dockerfile_name}' успешно удален"
})
@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)}"
)