- Исправлена незакрытая скобка в _build_test_command (строка 745) - Добавлена поддержка k8s preset'ов: выполнение create_k8s_cluster.py перед create.yml - Обновлены образы в k8s preset'ах: заменен недоступный ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy на inecs/ansible-lab:ubuntu22-latest - Обновлены preset'ы в базе данных через SQL - Обновлены файлы: k8s-single.yml, k8s-multi.yml, k8s-istio-full.yml
779 lines
43 KiB
Python
779 lines
43 KiB
Python
"""
|
||
Исполнитель 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"
|