- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
1030 lines
46 KiB
Python
1030 lines
46 KiB
Python
"""
|
||
FastAPI приложение для сборки Docker образов
|
||
Автор: Сергей Антропов
|
||
Сайт: https://devops.org.ru
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import Optional, List, Union
|
||
|
||
import docker
|
||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
||
from pydantic import BaseModel, Field, model_validator, ConfigDict
|
||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||
from sqlalchemy.orm import sessionmaker
|
||
from sqlalchemy import text
|
||
|
||
# Настройка логирования
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Глобальный Docker клиент
|
||
docker_client: Optional[docker.DockerClient] = None
|
||
build_tasks: dict[str, dict] = {}
|
||
|
||
# Глобальные переменные для БД
|
||
db_engine = None
|
||
async_session_maker = None
|
||
|
||
|
||
class BuildRequest(BaseModel):
|
||
"""Запрос на сборку Docker образа"""
|
||
# Используем model_config для правильной обработки опциональных полей в Pydantic v2
|
||
model_config = ConfigDict(
|
||
# Разрешаем отсутствие опциональных полей в запросе
|
||
# Это позволяет не передавать поля, которые имеют значение по умолчанию None
|
||
populate_by_name=True,
|
||
# Не требуем все поля - опциональные поля могут отсутствовать
|
||
extra='ignore'
|
||
)
|
||
|
||
build_log_id: int
|
||
image_name: str
|
||
tag: str
|
||
platforms: List[str] # Используем List из typing для совместимости
|
||
no_cache: bool = False
|
||
|
||
# Опциональные поля - используем Field с default=None и validate_default=False
|
||
# В Pydantic v2 это позволяет полям быть действительно опциональными
|
||
dockerfile_content: Optional[str] = Field(default=None, validate_default=False) # Содержимое Dockerfile (приоритет)
|
||
dockerfile_path: Optional[str] = Field(default=None, validate_default=False) # Путь к Dockerfile (если dockerfile_content не указан)
|
||
context_path: Optional[str] = Field(default=None, validate_default=False) # Путь к контексту (если не указан, будет создана временная директория)
|
||
webhook_url: Optional[str] = Field(default=None, validate_default=False) # URL для отправки логов в основное приложение
|
||
|
||
@model_validator(mode='after')
|
||
def validate_dockerfile_source(self):
|
||
"""Валидация: должен быть указан либо dockerfile_content, либо dockerfile_path"""
|
||
if not self.dockerfile_content and not self.dockerfile_path:
|
||
raise ValueError("Должен быть указан либо dockerfile_content, либо dockerfile_path")
|
||
return self
|
||
|
||
|
||
class BuildStatus(BaseModel):
|
||
"""Статус сборки"""
|
||
build_id: str
|
||
status: str # running, success, failed, cancelled
|
||
progress: Optional[float] = None
|
||
message: Optional[str] = None
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""Инициализация и очистка при запуске/остановке приложения"""
|
||
global docker_client, db_engine, async_session_maker
|
||
|
||
# Инициализация Docker клиента
|
||
try:
|
||
docker_host = os.getenv("DOCKER_HOST", "unix:///var/run/docker.sock")
|
||
|
||
if docker_host.startswith("unix://"):
|
||
socket_path = docker_host.replace("unix://", "")
|
||
if not socket_path.startswith("/"):
|
||
socket_path = "/" + socket_path
|
||
base_url = f"unix://{socket_path}"
|
||
docker_client = docker.DockerClient(base_url=base_url)
|
||
else:
|
||
docker_client = docker.from_env()
|
||
|
||
# Проверка подключения
|
||
docker_client.ping()
|
||
logger.info("Docker client initialized successfully")
|
||
except Exception as e:
|
||
logger.error(f"Failed to initialize Docker client: {e}")
|
||
raise
|
||
|
||
# Инициализация подключения к БД
|
||
database_url = os.getenv("DATABASE_URL", "postgresql+asyncpg://devopslab:devopslab123@postgres:5432/devopslab")
|
||
try:
|
||
db_engine = create_async_engine(database_url, echo=False)
|
||
async_session_maker = sessionmaker(
|
||
db_engine, class_=AsyncSession, expire_on_commit=False
|
||
)
|
||
logger.info("Database connection initialized")
|
||
except Exception as e:
|
||
logger.error(f"Failed to initialize database connection: {e}")
|
||
db_engine = None
|
||
async_session_maker = None
|
||
|
||
yield
|
||
|
||
# Очистка при остановке
|
||
if docker_client:
|
||
docker_client.close()
|
||
if db_engine:
|
||
await db_engine.dispose()
|
||
logger.info("Docker client closed")
|
||
|
||
|
||
app = FastAPI(
|
||
title="Docker Builder API",
|
||
description="API для сборки Docker образов в изолированном контейнере",
|
||
version="1.0.0",
|
||
lifespan=lifespan
|
||
)
|
||
|
||
|
||
async def send_log_to_webhook(webhook_url: str, build_log_id: int, log_line: str, status: Optional[str] = None):
|
||
"""Отправка лога в основное приложение через webhook"""
|
||
if not webhook_url:
|
||
return
|
||
|
||
try:
|
||
import httpx
|
||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||
|
||
# Извлекаем build_log_id из query параметров webhook_url, если он там есть
|
||
parsed_url = urlparse(webhook_url)
|
||
query_params = parse_qs(parsed_url.query)
|
||
|
||
# Если build_log_id передан в URL, используем его (приоритет выше)
|
||
if 'build_log_id' in query_params:
|
||
webhook_build_log_id = int(query_params['build_log_id'][0])
|
||
else:
|
||
# Иначе используем переданный build_log_id
|
||
webhook_build_log_id = build_log_id
|
||
|
||
# Убираем build_log_id из URL, так как он будет в payload
|
||
query_params.pop('build_log_id', None)
|
||
new_query = urlencode(query_params, doseq=True)
|
||
clean_url = urlunparse((
|
||
parsed_url.scheme,
|
||
parsed_url.netloc,
|
||
parsed_url.path,
|
||
parsed_url.params,
|
||
new_query,
|
||
parsed_url.fragment
|
||
))
|
||
|
||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||
payload = {
|
||
"log": log_line,
|
||
"status": status
|
||
}
|
||
# Добавляем build_log_id только если он больше 0
|
||
if webhook_build_log_id > 0:
|
||
payload["build_log_id"] = webhook_build_log_id
|
||
|
||
await client.post(clean_url, json=payload)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to send log to webhook: {e}")
|
||
|
||
|
||
async def run_build(
|
||
build_id: str,
|
||
build_log_id: int,
|
||
image_name: str,
|
||
tag: str,
|
||
platforms: List[str],
|
||
dockerfile_content: Optional[str] = None,
|
||
dockerfile_path: Optional[str] = None,
|
||
context_path: Optional[str] = None,
|
||
no_cache: bool = False,
|
||
webhook_url: Optional[str] = None
|
||
):
|
||
"""Выполнение сборки Docker образа"""
|
||
global build_tasks
|
||
|
||
# Создаем временную директорию, если передан dockerfile_content или не указан context_path
|
||
temp_dir = None
|
||
|
||
try:
|
||
# ВАЖНО: для Docker Desktop на macOS используем tmpfs для временных файлов
|
||
# Docker Desktop не может монтировать пути, созданные внутри контейнера
|
||
# Поэтому сохраняем содержимое Dockerfile в памяти и передадим его в контейнер через tmpfs
|
||
|
||
# Если передан dockerfile_content, сохраняем его для передачи в контейнер
|
||
dockerfile_content_to_use = None
|
||
if dockerfile_content:
|
||
dockerfile_content_to_use = dockerfile_content
|
||
logger.info(f"Using dockerfile_content (length: {len(dockerfile_content)} bytes)")
|
||
elif dockerfile_path:
|
||
# Читаем содержимое Dockerfile, если указан путь
|
||
dockerfile_content_to_use = Path(dockerfile_path).read_text()
|
||
logger.info(f"Read dockerfile from path: {dockerfile_path}")
|
||
|
||
# Если не указан context_path, используем пустую директорию в tmpfs
|
||
if not context_path:
|
||
context_path = "/tmp" # Будет использован tmpfs в контейнере
|
||
|
||
build_tasks[build_id] = {
|
||
"status": "running",
|
||
"progress": 0.0,
|
||
"message": "Сборка запущена"
|
||
}
|
||
|
||
# Формируем полное имя образа
|
||
if ":" in image_name:
|
||
full_image_name = image_name
|
||
else:
|
||
full_image_name = f"{image_name}:{tag}"
|
||
|
||
logger.info(f"Starting build: {full_image_name}")
|
||
logger.info(f"Platforms: {', '.join(platforms)}")
|
||
logger.info(f"Dockerfile: {dockerfile_path}")
|
||
logger.info(f"Context: {context_path}")
|
||
|
||
# Отправляем начальный лог
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"🚀 Запуск сборки образа {full_image_name}...\n",
|
||
"running"
|
||
)
|
||
|
||
# Проверяем, что у нас есть содержимое Dockerfile
|
||
if not dockerfile_content_to_use:
|
||
error_msg = "Не указано содержимое Dockerfile"
|
||
logger.error(error_msg)
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"❌ {error_msg}\n",
|
||
"failed"
|
||
)
|
||
raise ValueError(error_msg)
|
||
|
||
# Формируем команду docker buildx build
|
||
# Используем tmpfs для временных файлов (решает проблему с монтированием на macOS)
|
||
dockerfile_in_container = "/tmp/Dockerfile"
|
||
context_in_container = "/tmp"
|
||
|
||
build_args = [
|
||
"docker", "buildx", "build",
|
||
"--file", dockerfile_in_container,
|
||
"--tag", full_image_name,
|
||
"--progress", "plain"
|
||
]
|
||
|
||
# Добавляем платформы
|
||
if platforms:
|
||
build_args.extend(["--platform", ",".join(platforms)])
|
||
|
||
# Добавляем --load для локальной сборки
|
||
build_args.append("--load")
|
||
|
||
# Добавляем --no-cache если нужно
|
||
if no_cache:
|
||
build_args.append("--no-cache")
|
||
|
||
# Добавляем путь к контексту (будет использован tmpfs)
|
||
build_args.append(context_in_container)
|
||
|
||
logger.info(f"Build command: {' '.join(build_args)}")
|
||
|
||
# Запускаем сборку в отдельном контейнере
|
||
container_name = f"dockerfile-builder-{build_id[:8]}"
|
||
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"🐳 Создание контейнера {container_name}...\n",
|
||
"running"
|
||
)
|
||
|
||
# Создаем контейнер для сборки с tmpfs
|
||
# Для Docker Desktop на macOS используем tmpfs для временных файлов
|
||
# Это позволяет избежать проблем с монтированием путей из контейнера
|
||
|
||
# Используем tmpfs для временных файлов
|
||
tmpfs = {
|
||
"/tmp": "size=1G" # Создаем tmpfs для временных файлов
|
||
}
|
||
|
||
# Монтируем только Docker socket
|
||
volumes = {
|
||
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
|
||
}
|
||
|
||
# Создаем контейнер с tmpfs БЕЗ команды (будет запущен с sleep для копирования файлов)
|
||
container = docker_client.containers.create(
|
||
image="docker:24-cli",
|
||
name=container_name,
|
||
command="sleep infinity", # Запускаем контейнер с sleep для копирования файлов
|
||
volumes=volumes,
|
||
tmpfs=tmpfs,
|
||
network_mode="host" # Используем host network для доступа к Docker socket
|
||
)
|
||
|
||
try:
|
||
# Запускаем контейнер для копирования файлов
|
||
container.start()
|
||
|
||
# Записываем Dockerfile в tmpfs контейнера через exec_run с base64
|
||
# Используем base64 для безопасной передачи содержимого
|
||
import base64
|
||
|
||
# Кодируем содержимое в base64 для безопасной передачи
|
||
dockerfile_base64 = base64.b64encode(dockerfile_content_to_use.encode('utf-8')).decode('utf-8')
|
||
|
||
# Записываем файл через base64 decode
|
||
# Используем одинарные кавычки для избежания проблем с экранированием
|
||
write_cmd = f"echo '{dockerfile_base64}' | base64 -d > /tmp/Dockerfile"
|
||
write_result = container.exec_run(["sh", "-c", write_cmd])
|
||
|
||
if write_result.exit_code != 0:
|
||
# Если base64 не доступен, пробуем через printf с экранированием
|
||
# Экранируем специальные символы
|
||
escaped_content = dockerfile_content_to_use.replace('\\', '\\\\').replace("'", "'\"'\"'")
|
||
# Используем printf для записи с экранированием
|
||
write_cmd = f"printf '%s' '{escaped_content}' > /tmp/Dockerfile"
|
||
write_result = container.exec_run(["sh", "-c", write_cmd])
|
||
|
||
if write_result.exit_code != 0:
|
||
error_output = write_result.output.decode('utf-8', errors='replace') if write_result.output else 'No output'
|
||
error_msg = f"Failed to write Dockerfile to container: {error_output}"
|
||
logger.error(error_msg)
|
||
raise Exception(error_msg)
|
||
|
||
logger.info(f"Written Dockerfile to container tmpfs: {dockerfile_in_container}")
|
||
|
||
# Проверяем файл
|
||
import time
|
||
time.sleep(0.2)
|
||
|
||
# Проверяем несколько раз
|
||
for attempt in range(3):
|
||
check_result = container.exec_run(["sh", "-c", "test -f /tmp/Dockerfile && echo 'EXISTS' && wc -l /tmp/Dockerfile && head -1 /tmp/Dockerfile"])
|
||
output = check_result.output.decode('utf-8', errors='replace') if check_result.output else ''
|
||
|
||
if 'EXISTS' in output or check_result.exit_code == 0:
|
||
logger.info(f"Dockerfile verified in container (attempt {attempt + 1}): {output[:100]}")
|
||
break
|
||
else:
|
||
if attempt < 2:
|
||
logger.warning(f"Dockerfile check failed, retrying... (attempt {attempt + 1}/3)")
|
||
time.sleep(0.3)
|
||
else:
|
||
# Последняя попытка - подробная отладка
|
||
debug_info = container.exec_run(["sh", "-c", "ls -la /tmp/ && echo '---' && cat /tmp/Dockerfile 2>&1 | head -10"])
|
||
debug_output = debug_info.output.decode('utf-8', errors='replace') if debug_info.output else 'No output'
|
||
logger.error(f"Dockerfile check failed after 3 attempts. Debug: {debug_output}")
|
||
raise Exception(f"Dockerfile not found in container after 3 attempts. Debug: {debug_output}")
|
||
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"✅ Контейнер {container_name} создан (ID: {container.id[:12]})\n",
|
||
"running"
|
||
)
|
||
|
||
# Выполняем команду сборки через exec
|
||
logger.info(f"Executing build command: {' '.join(build_args)}")
|
||
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"🔨 Запуск сборки...\n",
|
||
"running"
|
||
)
|
||
|
||
# Выполняем команду сборки через exec_create и exec_start для правильной работы с stream
|
||
# exec_run с stream=True может не работать правильно, используем exec_create + exec_start
|
||
logger.info(f"Executing build command: {' '.join(build_args)}")
|
||
|
||
# Создаем exec объект
|
||
exec_id = docker_client.api.exec_create(
|
||
container.id,
|
||
cmd=build_args, # Список аргументов
|
||
stdout=True,
|
||
stderr=True,
|
||
tty=False # Отключаем TTY для правильной работы с потоком
|
||
)
|
||
|
||
logger.info(f"Created exec: {exec_id['Id']}")
|
||
|
||
# Запускаем exec с потоковым выводом
|
||
log_stream = docker_client.api.exec_start(
|
||
exec_id['Id'],
|
||
stream=True,
|
||
detach=False
|
||
)
|
||
|
||
logger.info(f"Got exec stream: {type(log_stream)}, is generator: {hasattr(log_stream, '__iter__')}")
|
||
except Exception as container_error:
|
||
error_msg = f"❌ Ошибка создания/запуска контейнера: {str(container_error)}"
|
||
logger.error(error_msg, exc_info=True)
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"{error_msg}\n",
|
||
"failed"
|
||
)
|
||
# Удаляем контейнер в случае ошибки
|
||
try:
|
||
container.remove(force=True)
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
# Читаем логи из exec в реальном времени
|
||
# exec_run с stream=True возвращает генератор байтов или кортежей
|
||
line_count = 0
|
||
exit_code = None
|
||
last_output = None
|
||
|
||
try:
|
||
# exec_start с stream=True возвращает генератор
|
||
# Каждый элемент может быть байтами или кортежем (stream_type, data)
|
||
logger.info("Starting to read log stream...")
|
||
chunk_count = 0
|
||
|
||
for log_chunk in log_stream:
|
||
chunk_count += 1
|
||
try:
|
||
log_text = None
|
||
|
||
if log_chunk is None:
|
||
logger.debug(f"Received None chunk #{chunk_count}")
|
||
continue
|
||
|
||
# Логируем тип первого чанка для отладки
|
||
if chunk_count == 1:
|
||
logger.info(f"First chunk type: {type(log_chunk)}, value: {str(log_chunk)[:100] if log_chunk else 'None'}")
|
||
|
||
if isinstance(log_chunk, tuple):
|
||
# exec может возвращать кортежи (stream_type, data)
|
||
stream_type, data = log_chunk
|
||
logger.debug(f"Received tuple chunk: stream_type={stream_type}, data_len={len(data) if data else 0}")
|
||
if data:
|
||
log_text = data.decode('utf-8', errors='replace').rstrip()
|
||
elif isinstance(log_chunk, bytes):
|
||
# Или просто байты
|
||
logger.debug(f"Received bytes chunk: len={len(log_chunk)}")
|
||
log_text = log_chunk.decode('utf-8', errors='replace').rstrip()
|
||
elif hasattr(log_chunk, 'decode'):
|
||
# Если это объект с методом decode
|
||
logger.debug(f"Received decodeable chunk: {type(log_chunk)}")
|
||
log_text = log_chunk.decode('utf-8', errors='replace').rstrip()
|
||
else:
|
||
# Пропускаем неизвестные типы
|
||
logger.warning(f"Skipping unknown log chunk type: {type(log_chunk)}, value: {str(log_chunk)[:100]}")
|
||
continue
|
||
|
||
if log_text:
|
||
line_count += 1
|
||
last_output = log_text
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
log_text + "\n",
|
||
"running"
|
||
)
|
||
|
||
# Обновляем прогресс каждые 10 строк
|
||
if line_count % 10 == 0:
|
||
build_tasks[build_id]["progress"] = min(50.0, line_count / 100.0 * 100)
|
||
except Exception as e:
|
||
logger.error(f"Error processing log chunk #{chunk_count}: {e}", exc_info=True)
|
||
|
||
logger.info(f"Finished reading log stream. Total chunks: {chunk_count}, Total lines: {line_count}")
|
||
except Exception as stream_error:
|
||
logger.error(f"Error reading exec stream: {stream_error}", exc_info=True)
|
||
# Если была ошибка чтения потока, но сборка могла завершиться
|
||
if last_output:
|
||
logger.info(f"Last log output before error: {last_output[:100]}")
|
||
|
||
# Получаем exit code из exec результата
|
||
try:
|
||
if 'exec_id' in locals() and exec_id:
|
||
exec_inspect = docker_client.api.exec_inspect(exec_id['Id'])
|
||
exit_code = exec_inspect.get('ExitCode', 1)
|
||
logger.info(f"Exec exit code: {exit_code}")
|
||
else:
|
||
# Если exec_id не доступен, проверяем образ
|
||
check_result = container.exec_run(["sh", "-c", f"docker images {full_image_name} --format '{{{{.Repository}}}}:{{{{.Tag}}}}' | head -1"])
|
||
if check_result.exit_code == 0 and check_result.output:
|
||
exit_code = 0
|
||
else:
|
||
exit_code = 1
|
||
except Exception as wait_error:
|
||
logger.error(f"Error getting exit code: {wait_error}", exc_info=True)
|
||
# Если не удалось проверить, считаем что ошибка
|
||
exit_code = 1
|
||
|
||
# Останавливаем и удаляем контейнер
|
||
try:
|
||
# Сначала останавливаем контейнер, если он еще работает
|
||
try:
|
||
container.stop(timeout=5)
|
||
except Exception:
|
||
pass # Контейнер уже остановлен
|
||
|
||
# Затем удаляем контейнер
|
||
container.remove(force=True)
|
||
logger.info(f"Container {container_name} removed")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to remove container {container_name}: {e}")
|
||
|
||
if exit_code == 0:
|
||
build_tasks[build_id] = {
|
||
"status": "success",
|
||
"progress": 100.0,
|
||
"message": f"Сборка успешно завершена: {full_image_name}"
|
||
}
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"✅ Сборка успешно завершена: {full_image_name}\n",
|
||
"success"
|
||
)
|
||
logger.info(f"Build completed successfully: {full_image_name}")
|
||
else:
|
||
build_tasks[build_id] = {
|
||
"status": "failed",
|
||
"progress": 100.0,
|
||
"message": f"Сборка завершилась с ошибкой (код: {exit_code})"
|
||
}
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"❌ Сборка завершилась с ошибкой (код: {exit_code})\n",
|
||
"failed"
|
||
)
|
||
logger.error(f"Build failed with exit code: {exit_code}")
|
||
|
||
except Exception as e:
|
||
error_msg = f"Ошибка при сборке: {str(e)}"
|
||
build_tasks[build_id] = {
|
||
"status": "failed",
|
||
"progress": 100.0,
|
||
"message": error_msg
|
||
}
|
||
await send_log_to_webhook(
|
||
webhook_url,
|
||
build_log_id,
|
||
f"❌ {error_msg}\n",
|
||
"failed"
|
||
)
|
||
logger.error(f"Build error: {e}", exc_info=True)
|
||
finally:
|
||
# Временные файлы находятся в tmpfs контейнера, они удалятся автоматически
|
||
# Не нужно удалять temp_dir, так как мы используем tmpfs
|
||
pass
|
||
|
||
|
||
@app.post("/api/v1/build/start", response_model=dict)
|
||
async def start_build(request: Request, background_tasks: BackgroundTasks):
|
||
"""Запуск сборки Docker образа"""
|
||
import uuid
|
||
|
||
# Парсим JSON напрямую без Pydantic
|
||
try:
|
||
data = await request.json()
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
|
||
|
||
# Валидация обязательных полей
|
||
build_log_id = data.get("build_log_id")
|
||
image_name = data.get("image_name")
|
||
tag = data.get("tag")
|
||
platforms = data.get("platforms")
|
||
|
||
if not build_log_id:
|
||
raise HTTPException(status_code=422, detail="build_log_id is required")
|
||
if not image_name:
|
||
raise HTTPException(status_code=422, detail="image_name is required")
|
||
if not tag:
|
||
raise HTTPException(status_code=422, detail="tag is required")
|
||
if not platforms or len(platforms) == 0:
|
||
raise HTTPException(status_code=422, detail="platforms cannot be empty")
|
||
|
||
# Опциональные поля
|
||
dockerfile_id = data.get("dockerfile_id") # ID Dockerfile в БД (приоритет)
|
||
dockerfile_content = data.get("dockerfile_content") # Для обратной совместимости
|
||
dockerfile_path = data.get("dockerfile_path") # Для обратной совместимости
|
||
context_path = data.get("context_path")
|
||
no_cache = data.get("no_cache", False)
|
||
webhook_url = data.get("webhook_url")
|
||
|
||
# Получаем содержимое Dockerfile из БД, если указан dockerfile_id
|
||
dockerfile_content_from_db = None
|
||
if dockerfile_id:
|
||
if not async_session_maker:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Database connection not available"
|
||
)
|
||
|
||
try:
|
||
async with async_session_maker() as db:
|
||
# Используем прямой SQL запрос вместо импорта модели
|
||
# Билдер - отдельное приложение и не имеет доступа к структуре основного приложения
|
||
result = await db.execute(
|
||
text("SELECT content FROM dockerfiles WHERE id = :dockerfile_id"),
|
||
{"dockerfile_id": dockerfile_id}
|
||
)
|
||
row = result.fetchone()
|
||
|
||
if not row:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"Dockerfile with id {dockerfile_id} not found"
|
||
)
|
||
|
||
dockerfile_content_from_db = row[0]
|
||
logger.info(f"Retrieved Dockerfile content from DB: dockerfile_id={dockerfile_id}, length={len(dockerfile_content_from_db)}")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error retrieving Dockerfile from DB: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"Failed to retrieve Dockerfile from database: {str(e)}"
|
||
)
|
||
|
||
# Используем содержимое из БД, если оно есть, иначе используем переданное
|
||
final_dockerfile_content = dockerfile_content_from_db or dockerfile_content
|
||
|
||
# Валидация: должен быть указан либо dockerfile_id, либо dockerfile_content, либо dockerfile_path
|
||
if not dockerfile_id and not final_dockerfile_content and not dockerfile_path:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Должен быть указан либо dockerfile_id, либо dockerfile_content, либо dockerfile_path"
|
||
)
|
||
|
||
# Логируем запрос для диагностики
|
||
logger.info(f"Received build request: build_log_id={build_log_id}, "
|
||
f"image={image_name}:{tag}, "
|
||
f"platforms={platforms}, "
|
||
f"dockerfile_id={dockerfile_id}, "
|
||
f"has_dockerfile_content={bool(final_dockerfile_content)}, "
|
||
f"has_dockerfile_path={bool(dockerfile_path)}")
|
||
|
||
build_id = str(uuid.uuid4())
|
||
|
||
# Запускаем сборку в фоне - передаем параметры напрямую без BuildRequest
|
||
background_tasks.add_task(
|
||
run_build,
|
||
build_id,
|
||
build_log_id,
|
||
image_name,
|
||
tag,
|
||
platforms,
|
||
final_dockerfile_content, # Используем содержимое из БД или переданное
|
||
dockerfile_path,
|
||
context_path,
|
||
no_cache,
|
||
webhook_url
|
||
)
|
||
|
||
return {
|
||
"build_id": build_id,
|
||
"status": "started",
|
||
"message": "Сборка запущена"
|
||
}
|
||
|
||
|
||
@app.get("/api/v1/build/{build_id}/status", response_model=BuildStatus)
|
||
async def get_build_status(build_id: str):
|
||
"""Получение статуса сборки"""
|
||
if build_id not in build_tasks:
|
||
raise HTTPException(status_code=404, detail="Build not found")
|
||
|
||
task_info = build_tasks[build_id]
|
||
return BuildStatus(
|
||
build_id=build_id,
|
||
status=task_info["status"],
|
||
progress=task_info.get("progress"),
|
||
message=task_info.get("message")
|
||
)
|
||
|
||
|
||
@app.post("/api/v1/build/{build_id}/cancel")
|
||
async def cancel_build(build_id: str):
|
||
"""Отмена сборки"""
|
||
if build_id not in build_tasks:
|
||
raise HTTPException(status_code=404, detail="Build not found")
|
||
|
||
if build_tasks[build_id]["status"] != "running":
|
||
raise HTTPException(status_code=400, detail="Build is not running")
|
||
|
||
# Помечаем задачу как отмененную
|
||
build_tasks[build_id]["status"] = "cancelled"
|
||
build_tasks[build_id]["message"] = "Сборка отменена"
|
||
|
||
return {"status": "cancelled"}
|
||
|
||
|
||
class PushRequest(BaseModel):
|
||
"""Запрос на отправку Docker образа"""
|
||
image_name: str # Полное имя образа с тегом (например, "myimage:latest")
|
||
webhook_url: Optional[str] = None # URL для отправки логов обратно в основное приложение
|
||
registry: Optional[str] = "docker.io" # Registry (docker.io или URL Harbor)
|
||
username: Optional[str] = None # Имя пользователя для авторизации
|
||
password: Optional[str] = None # Пароль для авторизации
|
||
|
||
|
||
async def run_push(request: PushRequest):
|
||
"""Выполнение отправки Docker образа в registry"""
|
||
# Извлекаем build_log_id из webhook_url если он там есть
|
||
build_log_id = 0
|
||
if request.webhook_url:
|
||
from urllib.parse import urlparse, parse_qs
|
||
parsed_url = urlparse(request.webhook_url)
|
||
query_params = parse_qs(parsed_url.query)
|
||
if 'build_log_id' in query_params:
|
||
try:
|
||
build_log_id = int(query_params['build_log_id'][0])
|
||
logger.info(f"Push will use build_log_id: {build_log_id}")
|
||
except (ValueError, IndexError):
|
||
build_log_id = 0
|
||
|
||
try:
|
||
logger.info(f"Starting push: {request.image_name} to {request.registry}")
|
||
|
||
# Отправляем начальный лог
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"🚀 Запуск отправки образа {request.image_name} в {request.registry}...\n"
|
||
)
|
||
|
||
# Получаем образ
|
||
source_image = request.image_name
|
||
image = docker_client.images.get(source_image)
|
||
|
||
# Формируем полное имя образа для пуша
|
||
if request.registry == "docker.io":
|
||
if request.username:
|
||
# Для Docker Hub: username/imagename:tag
|
||
if "/" in source_image:
|
||
# Если уже есть префикс (например, inecs/ansible-lab:astra-linux)
|
||
# Извлекаем только имя образа с тегом
|
||
image_part = source_image.split("/", 1)[1] if "/" in source_image else source_image
|
||
full_image_name = f"{request.username}/{image_part}"
|
||
else:
|
||
# Если нет префикса, добавляем username
|
||
full_image_name = f"{request.username}/{source_image}"
|
||
|
||
logger.info(f"Docker Hub push: {source_image} -> {full_image_name} (username: {request.username})")
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"ℹ️ Имя образа для Docker Hub: {full_image_name}\n"
|
||
)
|
||
else:
|
||
# Без авторизации используем исходное имя
|
||
full_image_name = source_image
|
||
logger.warning(f"No username provided for Docker Hub push, using: {full_image_name}")
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"⚠️ Внимание: авторизация не выполнена, образ может не отправиться\n"
|
||
)
|
||
else:
|
||
# Для Harbor: registry/project/imagename:tag
|
||
# Если в image_name уже есть registry, используем его, иначе добавляем registry
|
||
if "/" in source_image and not source_image.startswith(request.registry):
|
||
# Убираем существующий registry если есть
|
||
parts = source_image.split("/")
|
||
if len(parts) > 1:
|
||
image_part = "/".join(parts[1:])
|
||
else:
|
||
image_part = source_image
|
||
full_image_name = f"{request.registry}/{image_part}"
|
||
else:
|
||
full_image_name = f"{request.registry}/{source_image}"
|
||
|
||
# Авторизация если нужно
|
||
if request.username and request.password:
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"🔐 Авторизация в {request.registry}...\n"
|
||
)
|
||
try:
|
||
docker_client.login(
|
||
username=request.username,
|
||
password=request.password,
|
||
registry=request.registry if request.registry != "docker.io" else None
|
||
)
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"✅ Авторизация успешна\n"
|
||
)
|
||
except Exception as e:
|
||
error_msg = f"❌ Ошибка авторизации: {str(e)}\n"
|
||
await send_log_to_webhook(request.webhook_url, build_log_id, error_msg)
|
||
logger.error(f"Login error: {e}")
|
||
raise
|
||
|
||
# Тегируем образ если нужно
|
||
if full_image_name != source_image:
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"🏷️ Тегирование образа {source_image} -> {full_image_name}...\n"
|
||
)
|
||
try:
|
||
image.tag(full_image_name)
|
||
logger.info(f"Tagged image: {source_image} -> {full_image_name}")
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"✅ Образ успешно тегирован\n"
|
||
)
|
||
except Exception as e:
|
||
error_msg = f"❌ Ошибка тегирования: {str(e)}\n"
|
||
await send_log_to_webhook(request.webhook_url, build_log_id, error_msg)
|
||
logger.error(f"Tag error: {e}")
|
||
raise
|
||
|
||
# Проверяем, что образ с нужным тегом существует
|
||
try:
|
||
tagged_image = docker_client.images.get(full_image_name)
|
||
logger.info(f"Tagged image exists: {full_image_name}")
|
||
except docker.errors.ImageNotFound:
|
||
error_msg = f"❌ Образ {full_image_name} не найден после тегирования\n"
|
||
await send_log_to_webhook(request.webhook_url, build_log_id, error_msg)
|
||
logger.error(f"Tagged image not found: {full_image_name}")
|
||
raise Exception(f"Образ {full_image_name} не найден после тегирования")
|
||
|
||
# Отправляем образ
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"📤 Отправка образа {full_image_name} в registry...\n"
|
||
)
|
||
logger.info(f"Starting push: {full_image_name} to {request.registry}")
|
||
|
||
# Выполняем push
|
||
push_logs = []
|
||
push_success = False
|
||
push_error = None
|
||
|
||
try:
|
||
for line in docker_client.images.push(full_image_name, stream=True, decode=True):
|
||
# Логируем все строки для отладки
|
||
logger.debug(f"Push line: {line}")
|
||
|
||
if 'error' in line:
|
||
error_detail = line.get('error', '')
|
||
error_msg = f"❌ Ошибка при push: {error_detail}\n"
|
||
push_logs.append(error_msg)
|
||
await send_log_to_webhook(request.webhook_url, build_log_id, error_msg)
|
||
push_error = error_detail
|
||
logger.error(f"Push error: {error_detail}")
|
||
|
||
if 'status' in line:
|
||
status = line.get('status', '')
|
||
progress = line.get('progress', '')
|
||
id_info = line.get('id', '')
|
||
|
||
log_line = f"{status}"
|
||
if id_info:
|
||
log_line += f" [{id_info}]"
|
||
if progress:
|
||
log_line += f" {progress}"
|
||
log_line += "\n"
|
||
|
||
push_logs.append(log_line)
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
log_line
|
||
)
|
||
|
||
# Проверяем успешное завершение
|
||
if 'Pushed' in status or 'Layer already exists' in status or 'already pushed' in status.lower():
|
||
push_success = True
|
||
logger.info(f"Push success marker: {status}")
|
||
|
||
# Проверяем успешное завершение по другим признакам
|
||
if 'aux' in line:
|
||
aux = line.get('aux', {})
|
||
if 'Digest' in aux:
|
||
digest = aux.get('Digest', '')
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"✅ Digest: {digest}\n"
|
||
)
|
||
push_success = True
|
||
logger.info(f"Push completed with digest: {digest}")
|
||
|
||
# Проверяем успешное завершение по streamEnd
|
||
if 'stream' in line:
|
||
stream_msg = line.get('stream', '')
|
||
if 'successfully' in stream_msg.lower() or 'digest:' in stream_msg.lower():
|
||
push_success = True
|
||
logger.info(f"Push success in stream: {stream_msg}")
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"✅ {stream_msg}\n"
|
||
)
|
||
except Exception as e:
|
||
error_msg = f"❌ Исключение при push: {str(e)}\n"
|
||
push_logs.append(error_msg)
|
||
await send_log_to_webhook(request.webhook_url, build_log_id, error_msg)
|
||
push_error = str(e)
|
||
logger.error(f"Push exception: {e}", exc_info=True)
|
||
raise
|
||
|
||
# Проверяем результат push
|
||
if push_error:
|
||
error_msg = f"❌ Образ {full_image_name} не был отправлен: {push_error}\n"
|
||
await send_log_to_webhook(request.webhook_url, build_log_id, error_msg)
|
||
logger.error(f"Push failed: {full_image_name}, error: {push_error}")
|
||
raise Exception(f"Ошибка при отправке образа: {push_error}")
|
||
|
||
if not push_success:
|
||
# Если нет явного успеха, но и нет ошибок, считаем успешным
|
||
logger.warning(f"Push completed without explicit success marker: {full_image_name}")
|
||
|
||
# Успешное завершение
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
build_log_id,
|
||
f"✅ Образ {full_image_name} успешно отправлен в registry\n",
|
||
status="success"
|
||
)
|
||
logger.info(f"Push completed successfully: {full_image_name}")
|
||
|
||
except docker.errors.APIError as e:
|
||
error_msg = f"Ошибка Docker API при отправке: {str(e)}"
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
0,
|
||
f"❌ {error_msg}\n"
|
||
)
|
||
logger.error(f"Push error: {e}", exc_info=True)
|
||
raise
|
||
except Exception as e:
|
||
error_msg = f"Ошибка при отправке: {str(e)}"
|
||
await send_log_to_webhook(
|
||
request.webhook_url,
|
||
0,
|
||
f"❌ {error_msg}\n"
|
||
)
|
||
logger.error(f"Push error: {e}", exc_info=True)
|
||
raise
|
||
|
||
|
||
@app.post("/api/v1/push/start", response_model=dict)
|
||
async def start_push(request: PushRequest, background_tasks: BackgroundTasks):
|
||
"""Запуск отправки Docker образа в registry"""
|
||
try:
|
||
# Проверяем, существует ли образ
|
||
try:
|
||
docker_client.images.get(request.image_name)
|
||
except docker.errors.ImageNotFound:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"Образ {request.image_name} не найден. Убедитесь, что сборка завершена успешно."
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error checking image: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"Ошибка при проверке образа: {str(e)}"
|
||
)
|
||
|
||
# Запускаем push в фоне
|
||
background_tasks.add_task(run_push, request)
|
||
|
||
return {
|
||
"status": "started",
|
||
"message": f"Отправка образа {request.image_name} запущена"
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error starting push: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"Не удалось запустить отправку образа: {str(e)}"
|
||
)
|
||
|
||
|
||
@app.get("/health")
|
||
async def health_check():
|
||
"""Проверка здоровья сервиса"""
|
||
try:
|
||
if docker_client:
|
||
docker_client.ping()
|
||
return {"status": "healthy", "docker": "connected"}
|
||
return {"status": "unhealthy", "docker": "not_connected"}
|
||
except Exception as e:
|
||
return {"status": "unhealthy", "error": str(e)}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(
|
||
"main:app",
|
||
host="0.0.0.0",
|
||
port=int(os.getenv("PORT", "8001")),
|
||
log_level="info"
|
||
)
|