""" Исполнитель 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"