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