Files
DevOpsLab/app/docker_builder/main.py
Сергей Антропов 1fbf9185a2 feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
2026-02-15 22:59:02 +03:00

1030 lines
46 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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