""" Исполнитель 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 import logging from app.core.config import settings from app.core.docker_client import DockerClient logger = logging.getLogger(__name__) class MoleculeExecutor: """Выполнение Molecule тестов без использования Makefile""" def __init__(self): # Определяем реальный путь к проекту для монтирования в docker run # В docker-compose.yml проект монтируется как ../:/workspace:rw # Когда мы запускаем docker run из контейнера, Docker Desktop на macOS # не знает о пути /workspace внутри контейнера. Нужно использовать реальный путь на хосте. project_root = settings.PROJECT_ROOT # Если PROJECT_ROOT = /workspace (внутри контейнера), но мы запускаем docker run, # нужно использовать реальный путь на хосте. # В docker-compose.yml монтируется ../:/workspace, значит на хосте это родительская директория от app/ if str(project_root) == "/workspace": # Получаем путь к app/ и поднимаемся на уровень выше (это и есть реальный путь на хосте) current_file = Path(__file__) # Внутри контейнера: /app/app/core/molecule_executor.py # current_file.parent = /app/app/core # current_file.parent.parent = /app/app # current_file.parent.parent.parent = /app # Нам нужно получить путь на хосте, который соответствует /workspace # В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/ # Но внутри контейнера код находится в /app/app/, а проект в /workspace # Поэтому нужно использовать переменную окружения или определить путь по-другому # Попробуем получить путь из переменной окружения или использовать текущую рабочую директорию import os # Если есть переменная окружения с реальным путем на хосте host_project_root = os.getenv("HOST_PROJECT_ROOT") if host_project_root: project_root = Path(host_project_root) else: # Используем путь относительно текущего файла # Внутри контейнера: /app/app/core/molecule_executor.py # На хосте: /Users/inecs/Documents/DevOpsLab/app/core/molecule_executor.py # Нужно подняться на 2 уровня выше от app/ чтобы получить корень проекта app_dir = current_file.parent.parent.parent # /app/app или /Users/.../app # Если мы в /app/app, то родительская директория - это /app, но нам нужен путь на хосте # Поэтому используем абсолютный путь, который будет правильно разрешен Docker Desktop # Docker Desktop автоматически преобразует пути из контейнера в пути на хосте # Но для этого нужно использовать путь, который существует на хосте # В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/ # На хосте: /Users/inecs/Documents/DevOpsLab # Внутри контейнера: /workspace # Когда мы запускаем docker run, нужно использовать путь на хосте # Но мы не знаем его напрямую, поэтому используем относительный путь # или определяем его через переменную окружения project_root = Path("/workspace") # Оставляем как есть, но для docker run нужно использовать реальный путь self.project_root = project_root.resolve() if isinstance(project_root, Path) else Path(project_root).resolve() # Для docker run нужно использовать реальный путь на хосте # Определяем его через переменную окружения или используем путь относительно app/ self.host_project_root = os.getenv("HOST_PROJECT_ROOT", str(self.project_root)) if self.host_project_root == "/workspace": # Если не задана переменная, пытаемся определить путь на хосте # В docker-compose.yml монтируется ../:/workspace # Значит на хосте это родительская директория от app/ current_file = Path(__file__) app_dir = current_file.parent.parent.parent # app/ # На хосте app/ находится в /Users/.../DevOpsLab/app # Значит корень проекта на хосте - это родительская директория от app/ # Но внутри контейнера мы не знаем этот путь напрямую # Поэтому используем путь, который Docker Desktop может разрешить # Docker Desktop автоматически преобразует пути из volume mounts # Но для docker run нужно использовать путь на хосте # Лучше всего использовать переменную окружения HOST_PROJECT_ROOT self.host_project_root = str(self.project_root) self.molecule_dir = self.project_root / "molecule" / "default" # Пресеты для Molecule должны находиться в molecule/presets # чтобы create.yml мог их найти по пути /workspace/molecule/presets/ # ВАЖНО: presets_dir должен быть относительно project_root, который внутри контейнера = /workspace # Это гарантирует, что файлы будут доступны внутри ansible-controller контейнера self.presets_dir = Path("/workspace") / "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, stop_event: Optional[callable] = None, container_ref: Optional[object] = None ) -> AsyncGenerator[str, None]: """ Тестирование роли через Molecule Args: role_name: Имя роли (опционально, если None - тестируются все роли) preset_name: Имя preset'а preset_content: Содержимое preset'а из БД (если None - загружается из файла) preset_category: Категория preset'а (main или k8s) stream: Если True, возвращает генератор строк для WebSocket stop_event: Функция для проверки флага остановки container_ref: Объект для хранения ссылки на контейнер Yields: Строки вывода команды """ container = None try: # Если 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 SDK вместо subprocess для запуска контейнера # Команда docker может быть недоступна внутри контейнера container_name = f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}" try: # Подготавливаем volumes для монтирования volumes = { str(self.host_project_root): {"bind": "/workspace", "mode": "rw"}, "/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"} } # Запускаем контейнер container = self.docker_client.client.containers.run( image="inecs/ansible-lab:ansible-controller-latest", name=container_name, command=["bash", "-c", self._build_test_command(role_name, preset_name, preset_category)], environment=env, volumes=volumes, working_dir="/workspace", user="root", detach=True, remove=False, # Не удаляем автоматически, чтобы можно было получить логи auto_remove=False ) # Сохраняем ссылку на контейнер для возможности остановки if container_ref and hasattr(container_ref, 'set'): container_ref.set(container) # Потоковый вывод логов (асинхронно) try: # Используем обычную очередь для передачи данных между потоками import queue import threading log_queue = queue.Queue() loop = asyncio.get_event_loop() def read_logs(): """Синхронная функция для чтения логов""" try: for line in container.logs(stream=True, follow=True, stdout=True, stderr=True): log_queue.put(line) except Exception as e: log_queue.put(None) # Запускаем чтение логов в отдельном потоке log_thread = threading.Thread(target=read_logs, daemon=True) log_thread.start() # Читаем логи из очереди асинхронно while True: # Проверяем флаг остановки if stop_event and stop_event(): try: yield "\n⏹️ Остановка тестирования по запросу пользователя...\n" except GeneratorExit: # Генератор уже закрыт - выполняем cleanup без yield pass # Останавливаем и удаляем контейнер ansible-controller try: if container: container.stop(timeout=10) logger.info(f"Container {container_name} stopped") try: yield "🛑 Контейнер ansible-controller остановлен\n" except GeneratorExit: pass except Exception as e: logger.error(f"Error stopping container: {e}") try: yield f"⚠️ Ошибка при остановке контейнера: {e}\n" except GeneratorExit: pass # Запускаем destroy.yml для очистки контейнеров из preset'а try: yield "\n🧹 Очистка контейнеров из preset'а...\n" except GeneratorExit: pass # Выполняем cleanup (синхронно, без yield) cleanup_success = False try: await self._cleanup_preset_containers(preset_name, preset_category) logger.info(f"Cleanup completed for preset {preset_name}") cleanup_success = True try: yield "✅ Контейнеры из preset'а удалены\n" except GeneratorExit: pass except Exception as e: logger.error(f"Error cleaning up preset containers: {e}") try: yield f"⚠️ Ошибка при очистке контейнеров preset'а: {e}\n" except GeneratorExit: pass # Удаляем контейнер ansible-controller try: if container: container.remove(force=True) logger.info(f"Container {container_name} removed") try: yield "🗑️ Контейнер ansible-controller удален\n" except GeneratorExit: pass except Exception as e: logger.error(f"Error removing container: {e}") try: yield f"⚠️ Ошибка при удалении контейнера: {e}\n" except GeneratorExit: pass # Сохраняем флаг, что cleanup был выполнен cleanup_done = cleanup_success # Выходим из цикла break try: # Используем run_in_executor для чтения из обычной очереди line = await asyncio.get_event_loop().run_in_executor( None, lambda: log_queue.get(timeout=0.1) ) if line is None: break yield line.decode('utf-8', errors='replace') except queue.Empty: # Проверяем, завершился ли контейнер try: container.reload() if container.status == 'exited': # Читаем оставшиеся логи из очереди while not log_queue.empty(): try: line = log_queue.get_nowait() if line is not None: yield line.decode('utf-8', errors='replace') except queue.Empty: break # Читаем финальные логи из контейнера remaining_logs = container.logs(stdout=True, stderr=True) if remaining_logs: yield remaining_logs.decode('utf-8', errors='replace') break except Exception as e: logger.error(f"Error reloading container: {e}") break continue except GeneratorExit: # Генератор закрыт - останавливаем контейнер и очищаем ресурсы # НЕ используем yield здесь, так как генератор уже закрывается logger.info(f"GeneratorExit caught, cleaning up container {container_name if container else 'unknown'}") try: if container: container.stop(timeout=5) container.remove(force=True) logger.info(f"Container {container_name} stopped and removed on GeneratorExit") except Exception as e: logger.error(f"Error cleaning up container on GeneratorExit: {e}") # Запускаем cleanup при закрытии генератора (без yield) try: await self._cleanup_preset_containers(preset_name, preset_category) logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit") except Exception as e: logger.error(f"Error in cleanup on GeneratorExit: {e}") # Обязательно поднимаем исключение дальше raise except Exception as e: logger.error(f"Error reading logs: {e}") break finally: # Ожидаем завершения контейнера (если он еще не остановлен) # cleanup_done может быть уже установлен при остановке по запросу if 'cleanup_done' not in locals(): cleanup_done = False if container: try: container.reload() if container.status != 'exited': try: exit_code = container.wait(timeout=5)["StatusCode"] except: # Если не удалось дождаться, останавливаем принудительно container.stop(timeout=5) exit_code = -1 else: exit_code = container.attrs.get("State", {}).get("ExitCode", -1) except Exception as e: logger.error(f"Error waiting for container: {e}") exit_code = -1 # Получаем финальные логи, если есть try: final_logs = container.logs(stdout=True, stderr=True, tail=100) if final_logs: yield final_logs.decode('utf-8', errors='replace') except Exception as e: logger.error(f"Error getting final logs: {e}") # Удаляем контейнер ansible-controller try: if container: container.stop(timeout=5) container.remove(force=True) try: yield f"\n🗑️ Контейнер {container_name} удален\n" except GeneratorExit: pass except Exception as e: logger.error(f"Error removing container {container_name}: {e}") try: yield f"\n⚠️ Предупреждение: не удалось удалить контейнер {container_name}: {e}\n" except GeneratorExit: pass # Запускаем cleanup контейнеров из preset'а (всегда, даже если был вызван ранее) # Это гарантирует, что контейнеры будут удалены даже при ошибках try: logger.info(f"Running cleanup in finally block for preset {preset_name}") await self._cleanup_preset_containers(preset_name, preset_category) logger.info(f"Cleanup completed in finally block for preset {preset_name}") try: yield "\n🧹 Контейнеры из preset'а очищены\n" except GeneratorExit: pass except Exception as e: logger.error(f"Error in cleanup during finally: {e}", exc_info=True) try: yield f"\n⚠️ Ошибка при очистке контейнеров preset'а: {e}\n" except GeneratorExit: pass except Exception as e: yield f"❌ Ошибка при запуске контейнера: {str(e)}\n" import traceback yield f"Детали: {traceback.format_exc()}\n" # Пытаемся удалить контейнер, если он был создан try: container = self.docker_client.client.containers.get(container_name) container.remove(force=True) except: pass # Шифрование vault файлов yield "\n🔒 Шифрование vault файлов...\n" await self.encrypt_vault_files() # Проверяем код возврата контейнера (если не было остановки) if not (stop_event and stop_event()) and container: try: container.reload() exit_code = container.attrs.get("State", {}).get("ExitCode", -1) if exit_code == 0: yield "\n✅ Тестирование завершено успешно\n" elif exit_code == -1: yield "\n✅ Тестирование preset'а завершено\n" else: yield f"\n❌ Тестирование завершено с ошибкой (код: {exit_code})\n" except: yield "\n✅ Тестирование preset'а завершено\n" except GeneratorExit: # Генератор закрыт - останавливаем контейнер и очищаем ресурсы # НЕ используем yield здесь, так как генератор уже закрывается if container: try: container.stop(timeout=5) container.remove(force=True) logger.info(f"Container {container_name} stopped and removed due to GeneratorExit") except Exception as e: logger.error(f"Error removing container on GeneratorExit: {e}") # Запускаем cleanup при закрытии генератора (без yield) try: if preset_content or preset_name: await self._cleanup_preset_containers(preset_name, preset_category) logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit") except Exception as e: logger.error(f"Error in cleanup on GeneratorExit: {e}") raise except Exception as e: # Обрабатываем другие исключения if container: try: container.stop(timeout=5) container.remove(force=True) except: pass # Запускаем cleanup при ошибке (без yield, так как генератор может быть закрыт) try: if preset_content or preset_name: await self._cleanup_preset_containers(preset_name, preset_category) except: pass # Пытаемся отправить сообщение об ошибке, если генератор еще открыт try: yield f"\n❌ Критическая ошибка: {str(e)}\n" except GeneratorExit: # Генератор закрыт - это нормально raise except: # Другие ошибки при yield - игнорируем pass async def _cleanup_preset_containers(self, preset_name: str, preset_category: str = "main"): """Очистка контейнеров из preset'а через destroy.yml""" logger.info(f"Starting cleanup for preset {preset_name} (category: {preset_category})") # Сначала пытаемся удалить контейнеры напрямую (быстрее и надежнее) cleanup_success = False try: # Получаем список всех контейнеров all_containers = self.docker_client.client.containers.list(all=True) removed_count = 0 for cont in all_containers: try: cont.reload() cont_name = cont.name # Проверяем, что контейнер в сети labnet или имеет короткое имя (тестовые контейнеры u1, u2 и т.д.) networks = cont.attrs.get("NetworkSettings", {}).get("Networks", {}) is_test_container = False if "labnet" in networks: is_test_container = True elif cont_name and len(cont_name) <= 10 and (cont_name.startswith("u") or cont_name.startswith("test-")): is_test_container = True if is_test_container: try: if cont.status != 'exited': cont.stop(timeout=5) cont.remove(force=True) removed_count += 1 logger.info(f"Removed container {cont_name} during direct cleanup") except Exception as remove_error: logger.warning(f"Error removing container {cont_name}: {remove_error}") except Exception as cont_error: logger.debug(f"Error processing container: {cont_error}") if removed_count > 0: logger.info(f"Direct cleanup removed {removed_count} containers") cleanup_success = True except Exception as direct_cleanup_error: logger.error(f"Error in direct cleanup: {direct_cleanup_error}") # Также пытаемся запустить destroy.yml для полной очистки try: # Запускаем destroy.yml в отдельном контейнере для очистки env = { "ANSIBLE_FORCE_COLOR": "1", "MOLECULE_PRESET": preset_name, "MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace", "MOLECULE_VAULT_ENABLED": "false" } cleanup_container_name = f"ansible-cleanup-{datetime.now().strftime('%Y%m%d%H%M%S')}" volumes = { str(self.host_project_root): {"bind": "/workspace", "mode": "rw"}, "/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"} } cleanup_container = self.docker_client.client.containers.run( image="inecs/ansible-lab:ansible-controller-latest", name=cleanup_container_name, command=["bash", "-c", "cd molecule/default && ansible-playbook -i localhost, destroy.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace"], environment=env, volumes=volumes, working_dir="/workspace", user="root", detach=True, remove=False, auto_remove=False ) # Ждем завершения cleanup try: exit_code = cleanup_container.wait(timeout=30)["StatusCode"] if exit_code == 0: logger.info(f"Destroy.yml cleanup completed successfully") cleanup_success = True else: logger.warning(f"Cleanup container exited with code {exit_code}") except Exception as e: logger.error(f"Error waiting for cleanup container: {e}") finally: # Удаляем cleanup контейнер try: cleanup_container.stop(timeout=5) cleanup_container.remove(force=True) except Exception as e: logger.debug(f"Error removing cleanup container: {e}") except Exception as e: logger.error(f"Error running destroy.yml cleanup: {e}") if not cleanup_success: logger.warning(f"Cleanup may not have completed successfully for preset {preset_name}") def _build_test_command(self, role_name: Optional[str] = None, preset_name: Optional[str] = None, preset_category: str = "main") -> str: """Построение команды для тестирования""" commands = [ "echo -e '\\033[33m=== СОЗДАНИЕ ТЕСТОВЫХ КОНТЕЙНЕРОВ ==='", "echo ''", "mkdir -p /tmp/molecule_workspace/inventory", ] # Для k8s пресетов сначала запускаем create_k8s_cluster.py is_k8s_preset = False if preset_category == "k8s": is_k8s_preset = True elif preset_name: if preset_name.startswith("k8s-") or preset_name in ["kubernetes", "k8s-full"]: is_k8s_preset = True if is_k8s_preset: # Определяем путь к preset файлу # Для k8s пресетов файл должен быть в /workspace/molecule/presets/k8s/ if preset_category == "k8s": preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" elif preset_name: k8s_names = ["kubernetes", "k8s-full"] if preset_name.startswith("k8s-") or preset_name in k8s_names: preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" else: preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" else: preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" # Формируем команду для создания Kind кластера script_path = "/workspace/scripts/create_k8s_cluster.py" k8s_cmd_str = f"test -f {preset_file_path} && python3 {script_path} {preset_file_path} ansible-controller || echo Ошибка при создании Kind кластера" commands.extend([ "echo -e '\\033[33m=== СОЗДАНИЕ KUBERNETES КЛАСТЕРА ==='", "echo ''", k8s_cmd_str, "echo ''", "echo -e '\\033[33m=== СОЗДАНИЕ DOCKER КОНТЕЙНЕРОВ ==='", "echo ''", ]) commands.extend([ "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"