feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile

- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
Сергей Антропов
2026-02-15 22:59:02 +03:00
parent 23e1a6037b
commit 1fbf9185a2
232 changed files with 38075 additions and 5 deletions

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