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,312 @@
"""
Исполнитель Molecule тестов
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import asyncio
import subprocess
from pathlib import Path
from typing import Optional, AsyncGenerator, Dict
from datetime import datetime
import yaml
import tempfile
from app.core.config import settings
from app.core.docker_client import DockerClient
class MoleculeExecutor:
"""Выполнение Molecule тестов без использования Makefile"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
self.molecule_dir = self.project_root / "molecule" / "default"
# Пресеты теперь находятся в alembic/presets
# Находим путь к alembic относительно текущего файла
current_file = Path(__file__)
alembic_dir = current_file.parent.parent / "alembic"
self.presets_dir = alembic_dir / "presets"
# Если не найдено, пробуем старый путь (для обратной совместимости)
if not self.presets_dir.exists():
self.presets_dir = self.project_root / "molecule" / "presets"
self.docker_client = DockerClient()
self._temp_preset_files = {} # Кэш временных файлов preset'ов
def load_preset_from_db(self, preset_content: str) -> Dict:
"""Загрузка конфигурации preset'а из содержимого (из БД)"""
return yaml.safe_load(preset_content) or {}
def create_temp_preset_file(self, preset_name: str, preset_content: str, category: str = "main") -> Path:
"""Создание временного файла preset'а из содержимого БД"""
# Создаем временную директорию для preset'ов если её нет
temp_presets_dir = self.presets_dir
if category == "k8s":
temp_presets_dir = temp_presets_dir / "k8s"
temp_presets_dir.mkdir(parents=True, exist_ok=True)
# Создаем временный файл
preset_file = temp_presets_dir / f"{preset_name}.yml"
preset_file.write_text(preset_content)
# Сохраняем путь для последующего удаления
if preset_name not in self._temp_preset_files:
self._temp_preset_files[preset_name] = preset_file
return preset_file
def load_preset(self, preset_name: str, category: str = "main") -> Dict:
"""Загрузка конфигурации preset'а (из файла или БД)"""
# Сначала пробуем загрузить из файла (для обратной совместимости)
if category == "k8s":
preset_file = self.presets_dir / "k8s" / f"{preset_name}.yml"
else:
preset_file = self.presets_dir / f"{preset_name}.yml"
if preset_file.exists():
with open(preset_file) as f:
return yaml.safe_load(f) or {}
# Если файла нет, значит preset в БД и должен быть передан через create_temp_preset_file
raise FileNotFoundError(f"Preset '{preset_name}' не найден. Убедитесь, что preset создан через create_temp_preset_file")
async def decrypt_vault_files(self) -> bool:
"""Расшифровка vault файлов перед тестированием"""
vault_dir = self.project_root / "vault"
if not vault_dir.exists():
return True
vault_password_file = vault_dir / ".vault"
if not vault_password_file.exists():
return True
# Запускаем ansible-vault decrypt для всех .yml файлов
vault_files = list(vault_dir.glob("*.yml"))
if not vault_files:
return True
try:
for vault_file in vault_files:
# Проверяем, зашифрован ли файл
with open(vault_file, 'rb') as f:
content = f.read(100)
if b'$ANSIBLE_VAULT' not in content:
continue
# Расшифровываем
cmd = [
"ansible-vault", "decrypt",
str(vault_file),
"--vault-password-file", str(vault_password_file)
]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(self.project_root)
)
await result.wait()
return True
except Exception:
return False
async def encrypt_vault_files(self) -> bool:
"""Шифрование vault файлов после тестирования"""
vault_dir = self.project_root / "vault"
if not vault_dir.exists():
return True
vault_password_file = vault_dir / ".vault"
if not vault_password_file.exists():
return True
# Находим расшифрованные .yml файлы
vault_files = list(vault_dir.glob("*.yml"))
if not vault_files:
return True
try:
for vault_file in vault_files:
# Проверяем, не зашифрован ли уже файл
with open(vault_file, 'rb') as f:
content = f.read(100)
if b'$ANSIBLE_VAULT' in content:
continue
# Шифруем
cmd = [
"ansible-vault", "encrypt",
str(vault_file),
"--vault-password-file", str(vault_password_file)
]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(self.project_root)
)
await result.wait()
return True
except Exception:
return False
async def test_role(
self,
role_name: Optional[str] = None,
preset_name: str = "default",
preset_content: Optional[str] = None,
preset_category: str = "main",
stream: bool = False
) -> AsyncGenerator[str, None]:
"""
Тестирование роли через Molecule
Args:
role_name: Имя роли (опционально, если None - тестируются все роли)
preset_name: Имя preset'а
preset_content: Содержимое preset'а из БД (если None - загружается из файла)
preset_category: Категория preset'а (main или k8s)
stream: Если True, возвращает генератор строк для WebSocket
Yields:
Строки вывода команды
"""
# Если preset_content передан, создаем временный файл из БД
if preset_content:
try:
self.create_temp_preset_file(preset_name, preset_content, preset_category)
preset_data = self.load_preset_from_db(preset_content)
except Exception as e:
yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n"
return
else:
# Проверка существования preset'а в файловой системе
try:
preset_data = self.load_preset(preset_name, preset_category)
except FileNotFoundError as e:
yield f"❌ Ошибка: {str(e)}\n"
yield "💡 Убедитесь, что preset существует в БД или файловой системе\n"
return
# Расшифровка vault файлов
yield "🔓 Расшифровка vault файлов...\n"
await self.decrypt_vault_files()
# Запуск ansible-controller контейнера
yield "🔧 Запуск ansible-controller контейнера...\n"
# Подготовка переменных окружения
env = {
"ANSIBLE_FORCE_COLOR": "1",
"MOLECULE_PRESET": preset_name,
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
"MOLECULE_VAULT_ENABLED": "false"
}
if role_name:
env["MOLECULE_ROLE_NAME"] = role_name
yield f"📋 Тестируется роль: {role_name}\n"
yield f"📋 Используется пресет: {preset_name}\n\n"
# Команда для выполнения в контейнере
docker_cmd = [
"docker", "run", "--rm",
"--name", f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"-v", f"{self.project_root}:/workspace",
"-w", "/workspace",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-u", "root"
]
# Добавляем переменные окружения
for key, value in env.items():
docker_cmd.extend(["-e", f"{key}={value}"])
docker_cmd.extend([
"inecs/ansible-lab:ansible-controller-latest",
"bash", "-c", self._build_test_command(role_name)
])
# Запуск процесса
process = await asyncio.create_subprocess_exec(
*docker_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(self.project_root)
)
# Потоковый вывод
async for line in process.stdout:
yield line.decode('utf-8', errors='replace')
await process.wait()
# Шифрование vault файлов
yield "\n🔒 Шифрование vault файлов...\n"
await self.encrypt_vault_files()
if process.returncode == 0:
yield "\n✅ Тестирование завершено успешно\n"
else:
yield f"\n❌ Тестирование завершено с ошибкой (код: {process.returncode})\n"
def _build_test_command(self, role_name: Optional[str] = None) -> str:
"""Построение команды для тестирования"""
commands = [
"echo -e '\\033[33m=== СОЗДАНИЕ ТЕСТОВЫХ КОНТЕЙНЕРОВ ==='",
"echo ''",
"mkdir -p /tmp/molecule_workspace/inventory",
"cd molecule/default",
"ansible-playbook -i localhost, create.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace",
"echo ''",
"echo -e '\\033[33m=== НАСТРОЙКА VAULT И ПЕРЕМЕННЫХ ==='",
"echo ''",
"ansible-playbook -i localhost, converge.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace",
"echo ''",
"echo -e '\\033[33m=== ПРОВЕРКА ПОДКЛЮЧЕНИЯ К КОНТЕЙНЕРАМ ==='",
"echo ''",
"ansible all -i /tmp/molecule_workspace/inventory/hosts.ini -m ping",
"echo ''",
"echo -e '\\033[33m=== ЗАПУСК CONVERGE.YML НА ТЕСТОВЫХ КОНТЕЙНЕРАХ ===\\033[0m'",
"echo ''",
"ansible-playbook -i /tmp/molecule_workspace/inventory/hosts.ini converge.yml",
"echo ''",
"echo -e '\\033[33m=== ЗАПУСК ROLES/DEPLOY.YML НА ТЕСТОВЫХ КОНТЕЙНЕРАХ ===\\033[0m'",
"echo ''"
]
# Добавляем команду для deploy.yml с фильтрацией по роли
if role_name:
commands.append(
f"ansible-playbook -i /tmp/molecule_workspace/inventory/hosts.ini ../../roles/deploy.yml --tags {role_name} || true"
)
else:
commands.append(
"ansible-playbook -i /tmp/molecule_workspace/inventory/hosts.ini ../../roles/deploy.yml || true"
)
commands.extend([
"echo ''",
"echo -e '\\033[33m=== ОЧИСТКА РЕСУРСОВ ==='",
"echo ''",
"ansible-playbook -i localhost, destroy.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace",
"echo ''",
"echo '✅ Тестирование завершено'"
])
return " && ".join(commands)
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 ["changed", "ok"]):
return "info"
else:
return "debug"