Files
DevOpsLab/app/core/molecule_executor.py
Сергей Антропов 1fbf9185a2 feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
2026-02-15 22:59:02 +03:00

313 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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