feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
418
app/services/docker_build_service.py
Normal file
418
app/services/docker_build_service.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Сервис для сборки 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"
|
||||
Reference in New Issue
Block a user