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