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

419 lines
19 KiB
Python
Raw Permalink 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.

"""
Сервис для сборки 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"