""" Сервис для сборки Docker образов Автор: Сергей Антропов Сайт: https://devops.org.ru """ import asyncio import subprocess import os from pathlib import Path from typing import Optional, AsyncGenerator, List, Dict from datetime import datetime from app.core.config import settings from app.core.docker_client import DockerClient class DockerBuildService: """Сервис для сборки и пуша Docker образов""" def __init__(self): self.project_root = settings.PROJECT_ROOT self.docker_client = DockerClient() async def build_image( self, dockerfile_content: str, image_name: str, tag: str = "latest", platforms: List[str] = None, build_args: Dict = None, no_cache: bool = False, stream: bool = False ) -> AsyncGenerator[str, None]: """ Сборка Docker образа Args: dockerfile_content: Содержимое Dockerfile image_name: Имя образа (например, inecs/ansible-lab:ubuntu22) tag: Тег образа platforms: Список платформ для multi-arch сборки (amd64, arm64, etc.) build_args: Аргументы сборки stream: Если True, возвращает генератор строк Yields: Строки вывода команды """ if platforms is None: platforms = ["linux/amd64"] # Создаем временный Dockerfile import tempfile import os with tempfile.TemporaryDirectory() as tmpdir: dockerfile_path = Path(tmpdir) / "Dockerfile" dockerfile_path.write_text(dockerfile_content) # Проверяем, есть ли уже тег в image_name # Если есть, используем его, иначе добавляем переданный тег if ":" in image_name: # Тег уже есть в image_name, используем его как есть full_image_name = image_name else: # Тега нет, добавляем переданный тег full_image_name = f"{image_name}:{tag}" # Формируем команду docker buildx build # Используем полный путь к docker для надежности docker_cmd = "/usr/bin/docker" cmd = [ docker_cmd, "buildx", "build", "--file", str(dockerfile_path), "--tag", full_image_name, "--progress", "plain" ] # Флаг --no-cache if no_cache: cmd.append("--no-cache") # Multi-arch сборка # Если несколько платформ, собираем каждую отдельно для лучшей обработки ошибок if len(platforms) > 1: successful_platforms = [] failed_platforms = [] yield f"🔨 Запуск сборки образа {full_image_name}...\n" yield f"📋 Платформы: {', '.join(platforms)}\n" yield f"ℹ️ Сборка будет выполняться для каждой платформы отдельно\n\n" # Устанавливаем PATH для доступа к docker env = os.environ.copy() env["PATH"] = "/usr/bin:/usr/local/bin:" + env.get("PATH", "") # Собираем каждую платформу отдельно for platform in platforms: yield f"\n{'='*60}\n" yield f"🔨 Сборка для платформы: {platform}\n" yield f"{'='*60}\n\n" # Формируем команду для одной платформы platform_cmd = [ docker_cmd, "buildx", "build", "--file", str(dockerfile_path), "--tag", full_image_name, "--platform", platform, "--progress", "plain" ] if no_cache: platform_cmd.append("--no-cache") if build_args: for key, value in build_args.items(): platform_cmd.extend(["--build-arg", f"{key}={value}"]) platform_cmd.append("--load") platform_cmd.append(tmpdir) # Запускаем сборку для платформы process = await asyncio.create_subprocess_exec( *platform_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=str(self.project_root), env=env ) platform_output = [] async for line in process.stdout: decoded_line = line.decode('utf-8', errors='replace') platform_output.append(decoded_line) yield decoded_line await process.wait() # Проверяем наличие ошибок, связанных с отсутствием образа для платформы output_text = "".join(platform_output).lower() has_platform_error = ( "no match for platform in manifest" in output_text or "failed to resolve source metadata" in output_text or "not found" in output_text ) if process.returncode == 0: successful_platforms.append(platform) yield f"\n✅ Платформа {platform} собрана успешно\n" elif has_platform_error: failed_platforms.append(platform) yield f"\n⚠️ Платформа {platform}: базовый образ недоступен для этой архитектуры\n" yield f"ℹ️ Сборка продолжается для других платформ\n" else: failed_platforms.append(platform) yield f"\n❌ Платформа {platform}: ошибка сборки (код: {process.returncode})\n" # Итоговый результат yield f"\n{'='*60}\n" yield f"📊 Итоги сборки:\n" yield f"{'='*60}\n" if successful_platforms: yield f"✅ Успешно собрано для платформ: {', '.join(successful_platforms)}\n" if failed_platforms: yield f"⚠️ Не собрано для платформ: {', '.join(failed_platforms)}\n" # Если хотя бы одна платформа собралась успешно, считаем сборку успешной if successful_platforms: yield f"\n✅ Сборка завершена: образ доступен для {len(successful_platforms)} из {len(platforms)} платформ\n" else: yield f"\n❌ Сборка не удалась для всех платформ\n" else: # Одна платформа - обычная сборка cmd.extend(["--platform", platforms[0]]) cmd.append("--load") # Загружаем образ в локальный Docker # Build args if build_args: for key, value in build_args.items(): cmd.extend(["--build-arg", f"{key}={value}"]) cmd.append(tmpdir) # Запуск сборки yield f"🔨 Запуск сборки образа {full_image_name}...\n" yield f"📋 Платформа: {platforms[0]}\n\n" # Устанавливаем PATH для доступа к docker env = os.environ.copy() env["PATH"] = "/usr/bin:/usr/local/bin:" + env.get("PATH", "") process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=str(self.project_root), env=env ) async for line in process.stdout: yield line.decode('utf-8', errors='replace') await process.wait() if process.returncode == 0: yield f"\n✅ Сборка завершена успешно: {full_image_name}\n" else: yield f"\n❌ Сборка завершена с ошибкой (код: {process.returncode})\n" async def push_image( self, image_name: str, tag: str = "latest", registry: str = "docker.io", username: Optional[str] = None, password: Optional[str] = None, stream: bool = False, use_container: bool = False ) -> AsyncGenerator[str, None]: """ Пуш Docker образа в репозиторий Args: image_name: Имя образа tag: Тег образа registry: Реестр (docker.io для Docker Hub, или URL Harbor) username: Имя пользователя для авторизации password: Пароль для авторизации stream: Если True, возвращает генератор строк use_container: Если True, выполняет пуш в отдельном контейнере Yields: Строки вывода команды """ # Используем полный путь к docker docker_cmd = "/usr/bin/docker" env = os.environ.copy() env["PATH"] = "/usr/bin:/usr/local/bin:" + env.get("PATH", "") # Если нужно использовать контейнер для пуша if use_container: import tempfile from app.core.docker_client import DockerClient docker_client = DockerClient() # Проверяем, есть ли уже тег в image_name if ":" in image_name: source_image = image_name else: source_image = f"{image_name}:{tag}" # Формируем полное имя образа для пуша if registry == "docker.io": if username: if "/" in source_image: image_part = source_image.split("/", 1)[1] if "/" in source_image else source_image full_image_name = f"{username}/{image_part}" else: full_image_name = f"{username}/{source_image}" else: full_image_name = source_image else: full_image_name = f"{registry}/{source_image}" from datetime import datetime container_name = f"dockerfile-pusher-{int(datetime.utcnow().timestamp())}" try: # Авторизация если нужно if username and password: yield f"🔐 Авторизация в {registry}...\n" login_cmd = f"echo '{password}' | docker login {registry} --username {username} --password-stdin" else: login_cmd = "" # Команда пуша push_cmd = f"docker push {full_image_name}" # Тегируем образ если нужно tag_cmd = f"docker tag {source_image} {full_image_name}" full_cmd = f"{tag_cmd} && {push_cmd}" if login_cmd else f"{tag_cmd} && {push_cmd}" if login_cmd: full_cmd = f"{login_cmd} && {full_cmd}" # Запускаем контейнер для пуша container = docker_client.client.containers.run( image="docker:24-cli", name=container_name, command=["sh", "-c", full_cmd], volumes={"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "ro"}}, detach=True, remove=False, auto_remove=False ) yield f"🚀 Запуск пуша в контейнере {container_name}...\n" # Читаем логи контейнера for log_line in container.logs(stream=True, follow=True): line = log_line.decode('utf-8', errors='replace').rstrip() if line: yield line + "\n" # Ждем завершения result = container.wait() exit_code = result.get("StatusCode") if isinstance(result, dict) else result if exit_code == 0: yield f"\n✅ Образ успешно отправлен: {full_image_name}\n" else: yield f"\n❌ Ошибка отправки образа (код: {exit_code})\n" except Exception as e: yield f"\n❌ Ошибка при пуше: {str(e)}\n" raise finally: # Удаляем контейнер try: container = docker_client.client.containers.get(container_name) if container.status != "removing": container.remove(force=True) except: pass return # Авторизация если нужно if username and password: yield f"🔐 Авторизация в {registry}...\n" login_cmd = [docker_cmd, "login", registry, "--username", username, "--password-stdin"] process = await asyncio.create_subprocess_exec( *login_cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env ) stdout, _ = await process.communicate(input=password.encode()) await process.wait() if process.returncode != 0: yield f"❌ Ошибка авторизации: {stdout.decode()}\n" return else: yield "✅ Авторизация успешна\n\n" # Проверяем, есть ли уже тег в image_name if ":" in image_name: # Тег уже есть в image_name, используем его source_image = image_name else: # Тега нет, добавляем переданный тег source_image = f"{image_name}:{tag}" # Формируем полное имя образа для пуша if registry == "docker.io": if username: # Если есть username, добавляем его к имени образа if "/" in source_image: # Извлекаем имя образа без username image_part = source_image.split("/", 1)[1] if "/" in source_image else source_image full_image_name = f"{username}/{image_part}" else: full_image_name = f"{username}/{source_image}" else: full_image_name = source_image else: # Harbor формат: harbor.example.com/project/image:tag full_image_name = f"{registry}/{source_image}" # Тегируем образ yield f"🏷️ Тегирование образа {source_image} -> {full_image_name}...\n" tag_cmd = [docker_cmd, "tag", source_image, full_image_name] tag_process = await asyncio.create_subprocess_exec( *tag_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env ) await tag_process.wait() if tag_process.returncode != 0: yield f"❌ Ошибка тегирования\n" return # Пуш образа yield f"📤 Отправка образа {full_image_name}...\n\n" push_cmd = [docker_cmd, "push", full_image_name] push_process = await asyncio.create_subprocess_exec( *push_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env ) async for line in push_process.stdout: yield line.decode('utf-8', errors='replace') await push_process.wait() if push_process.returncode == 0: yield f"\n✅ Образ успешно отправлен: {full_image_name}\n" else: yield f"\n❌ Ошибка отправки образа (код: {push_process.returncode})\n" def detect_log_level(self, line: str) -> str: """Определение уровня лога из строки""" line_lower = line.lower() if any(word in line_lower for word in ["error", "failed", "fatal"]): return "error" elif any(word in line_lower for word in ["warning", "warn"]): return "warning" elif any(word in line_lower for word in ["success", "pushed", "built"]): return "info" else: return "debug"