feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile

- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
Сергей Антропов
2026-02-15 22:59:02 +03:00
parent 23e1a6037b
commit 1fbf9185a2
232 changed files with 38075 additions and 5 deletions

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Core package

View File

@@ -0,0 +1,148 @@
"""
Исполнитель Ansible команд
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import asyncio
import subprocess
from pathlib import Path
from typing import Optional, AsyncGenerator, Dict, List
from datetime import datetime
from app.core.config import settings
class AnsibleExecutor:
"""Выполнение Ansible команд без использования Makefile"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
async def run_playbook(
self,
playbook_path: str,
inventory: str,
tags: Optional[List[str]] = None,
limit: Optional[str] = None,
check: bool = False,
extra_vars: Optional[Dict] = None,
stream: bool = False
) -> AsyncGenerator[str, None]:
"""
Запуск ansible-playbook
Args:
playbook_path: Путь к playbook файлу
inventory: Путь к inventory файлу
tags: Список тегов для фильтрации задач
limit: Ограничение на хосты
check: Режим dry-run (--check)
extra_vars: Дополнительные переменные
stream: Если True, возвращает генератор строк
Yields:
Строки вывода команды
"""
cmd = ["ansible-playbook", playbook_path, "-i", inventory]
if tags:
cmd.extend(["--tags", ",".join(tags)])
if limit:
cmd.extend(["--limit", limit])
if check:
cmd.append("--check")
if extra_vars:
import json
cmd.extend(["-e", json.dumps(extra_vars)])
# Добавляем цветной вывод
env = {"ANSIBLE_FORCE_COLOR": "1"}
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(self.project_root),
env=env
)
async for line in process.stdout:
yield line.decode('utf-8', errors='replace')
await process.wait()
async def run_ad_hoc(
self,
module: str,
args: str,
inventory: str,
hosts: Optional[str] = None,
stream: bool = False
) -> AsyncGenerator[str, None]:
"""
Запуск ad-hoc команды ansible
Args:
module: Имя модуля (например, 'ping', 'command', 'shell')
args: Аргументы модуля
inventory: Путь к inventory файлу
hosts: Ограничение на хосты (по умолчанию 'all')
stream: Если True, возвращает генератор строк
Yields:
Строки вывода команды
"""
cmd = ["ansible"]
if hosts:
cmd.extend([hosts])
else:
cmd.append("all")
cmd.extend(["-m", module, "-a", args, "-i", inventory])
# Добавляем цветной вывод
env = {"ANSIBLE_FORCE_COLOR": "1"}
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(self.project_root),
env=env
)
async for line in process.stdout:
yield line.decode('utf-8', errors='replace')
await process.wait()
def detect_log_level(self, line: str) -> str:
"""Определение уровня лога из строки"""
line_lower = line.lower()
if any(word in line_lower for word in ["error", "failed", "fatal", "unreachable"]):
return "error"
elif any(word in line_lower for word in ["warning", "warn", "skipping"]):
return "warning"
elif any(word in line_lower for word in ["changed", "ok", "success"]):
return "info"
else:
return "debug"
def parse_play_recap(self, line: str) -> Dict:
"""Парсинг PLAY RECAP из Ansible вывода"""
import re
# Пример: "web1.example.com : ok=5 changed=3 unreachable=0 failed=0"
match = re.search(r'(\S+)\s*:\s*ok=(\d+)\s+changed=(\d+).*failed=(\d+)', line)
if match:
return {
"host": match.group(1),
"ok": int(match.group(2)),
"changed": int(match.group(3)),
"failed": int(match.group(4)),
"status": "success" if int(match.group(4)) == 0 else "failed"
}
return {}

64
app/core/config.py Normal file
View File

@@ -0,0 +1,64 @@
"""
Конфигурация приложения
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from pydantic_settings import BaseSettings
from pathlib import Path
from typing import Optional
class Settings(BaseSettings):
"""Настройки приложения"""
# Проект
PROJECT_ROOT: Path = Path("/workspace")
PROJECT_NAME: str = "devops-lab"
# FastAPI
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
API_RELOAD: bool = True
API_WORKERS: int = 1
# База данных
DATABASE_URL: str = "sqlite:///./devopslab.db"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# Безопасность
SECRET_KEY: str = "dev-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Docker
DOCKER_HOST: str = "unix:///var/run/docker.sock"
DOCKER_BUILDER_URL: str = "http://docker-builder:8001"
# Проверка доступности Docker socket
@property
def docker_available(self) -> bool:
"""Проверка доступности Docker"""
from pathlib import Path
docker_sock = Path("/var/run/docker.sock")
return docker_sock.exists() and docker_sock.is_socket()
# Ansible
ANSIBLE_FORCE_COLOR: str = "1"
ANSIBLE_STDOUT_CALLBACK: str = "yaml"
# Логирование
LOG_LEVEL: str = "INFO"
LOG_FILE: Optional[str] = "logs/app.log"
# Отладка
DEBUG: bool = False
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

231
app/core/docker_client.py Normal file
View File

@@ -0,0 +1,231 @@
"""
Docker клиент для управления контейнерами
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import docker
import os
from typing import List, Dict, Optional
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class DockerClient:
"""Клиент для работы с Docker API"""
def __init__(self):
"""Инициализация Docker клиента (ленивая инициализация)"""
self._client = None
@property
def client(self):
"""Ленивая инициализация Docker клиента"""
if self._client is None:
try:
# Получаем DOCKER_HOST из настроек или окружения
docker_host = os.getenv("DOCKER_HOST", settings.DOCKER_HOST)
logger.info(f"Initializing Docker client with DOCKER_HOST: {docker_host}")
# Если DOCKER_HOST начинается с unix://, извлекаем путь к socket
if docker_host.startswith("unix://"):
socket_path = docker_host.replace("unix://", "")
# Убеждаемся, что путь начинается с /
if not socket_path.startswith("/"):
socket_path = "/" + socket_path
# Docker SDK для unix socket ожидает base_url в формате "unix:///path/to/socket"
# Важно: после unix:// должно быть три слэша (unix:///)
# Например: "unix:///var/run/docker.sock"
base_url = f"unix://{socket_path}"
logger.info(f"Using unix socket: base_url={base_url}, socket_path={socket_path}")
# НЕ используем docker.from_env() для unix socket, так как он неправильно парсит формат
# Используем только прямой base_url
try:
self._client = docker.DockerClient(base_url=base_url)
# Проверяем подключение сразу
self._client.ping()
logger.info(f"Successfully created Docker client with base_url={base_url}")
except Exception as e:
logger.error(f"Failed to create Docker client with base_url={base_url}: {e}")
# Пробуем альтернативный формат (без префикса unix://)
try:
# Некоторые версии SDK могут требовать просто путь
# Но это не работает, так как base_url должен быть полный URL
# Поэтому пробуем стандартный формат по умолчанию
logger.warning("Trying default socket path")
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
self._client.ping()
logger.info("Successfully created Docker client with default socket")
except Exception as e2:
logger.error(f"All methods failed. Last error: {e2}")
raise
elif docker_host.startswith("/"):
# Прямой путь к socket - используем base_url с префиксом unix://
base_url = f"unix://{docker_host}"
logger.info(f"Using direct socket path: {base_url}")
self._client = docker.DockerClient(base_url=base_url)
else:
# Для других форматов (tcp://, http:// и т.д.) используем from_env
# Но сначала проверяем, не установлена ли переменная DOCKER_HOST
if "DOCKER_HOST" in os.environ:
# Если DOCKER_HOST установлен, но не unix://, используем from_env
logger.info("Using docker.from_env()")
self._client = docker.from_env()
else:
# Если DOCKER_HOST не установлен, используем стандартный socket
logger.info("Using default socket: unix:///var/run/docker.sock")
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
# Проверка подключения
self._client.ping()
logger.info("Docker client initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize Docker client: {e}")
logger.error(f"DOCKER_HOST env: {os.getenv('DOCKER_HOST', 'not set')}")
logger.error(f"Settings DOCKER_HOST: {settings.DOCKER_HOST}")
import traceback
logger.error(traceback.format_exc())
raise
return self._client
def list_images(self) -> List[Dict]:
"""Список всех Docker образов"""
try:
images = self.client.images.list()
return [
{
"id": img.id,
"tags": img.tags,
"created": img.attrs.get("Created"),
"size": img.attrs.get("Size", 0)
}
for img in images
]
except Exception as e:
logger.error(f"Error listing images: {e}")
return []
def list_containers(self, all: bool = False) -> List[Dict]:
"""Список контейнеров"""
try:
containers = self.client.containers.list(all=all)
return [
{
"id": container.id,
"name": container.name,
"status": container.status,
"image": container.image.tags[0] if container.image.tags else container.image.id,
"ports": container.ports,
"created": container.attrs.get("Created")
}
for container in containers
]
except Exception as e:
logger.error(f"Error listing containers: {e}")
return []
def get_container_ip(self, container_name: str) -> Optional[str]:
"""Получение IP адреса контейнера"""
try:
container = self.client.containers.get(container_name)
network_settings = container.attrs.get("NetworkSettings", {})
networks = network_settings.get("Networks", {})
# Ищем IP в первой доступной сети
for network_name, network_info in networks.items():
ip = network_info.get("IPAddress")
if ip:
return ip
return None
except Exception as e:
logger.error(f"Error getting container IP for {container_name}: {e}")
return None
def create_container(
self,
image: str,
name: str,
command: Optional[str] = None,
environment: Optional[Dict] = None,
volumes: Optional[Dict] = None,
network: Optional[str] = None,
privileged: bool = False
) -> Dict:
"""Создание контейнера"""
try:
container = self.client.containers.run(
image=image,
name=name,
command=command,
environment=environment or {},
volumes=volumes or {},
network=network,
privileged=privileged,
detach=True,
remove=False
)
return {
"success": True,
"id": container.id,
"name": container.name,
"status": container.status
}
except Exception as e:
logger.error(f"Error creating container: {e}")
return {
"success": False,
"error": str(e)
}
def stop_container(self, container_name: str) -> bool:
"""Остановка контейнера"""
try:
container = self.client.containers.get(container_name)
container.stop()
return True
except Exception as e:
logger.error(f"Error stopping container {container_name}: {e}")
return False
def remove_container(self, container_name: str, force: bool = False) -> bool:
"""Удаление контейнера"""
try:
container = self.client.containers.get(container_name)
container.remove(force=force)
return True
except Exception as e:
logger.error(f"Error removing container {container_name}: {e}")
return False
def get_container_logs(self, container_name: str, tail: int = 100) -> str:
"""Получение логов контейнера"""
try:
container = self.client.containers.get(container_name)
logs = container.logs(tail=tail, timestamps=True)
return logs.decode('utf-8', errors='replace')
except Exception as e:
logger.error(f"Error getting logs for {container_name}: {e}")
return ""
def exec_command(self, container_name: str, command: str) -> Dict:
"""Выполнение команды в контейнере"""
try:
container = self.client.containers.get(container_name)
result = container.exec_run(command)
return {
"success": result.exit_code == 0,
"output": result.output.decode('utf-8', errors='replace'),
"exit_code": result.exit_code
}
except Exception as e:
logger.error(f"Error executing command in {container_name}: {e}")
return {
"success": False,
"error": str(e)
}

169
app/core/make_executor.py Normal file
View File

@@ -0,0 +1,169 @@
"""
Выполнение Makefile команд
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import subprocess
import asyncio
from typing import Dict, List, Optional, AsyncGenerator
from pathlib import Path
from app.core.config import settings
from datetime import datetime
import json
import re
class MakeExecutor:
"""Выполнение Makefile команд с отслеживанием прогресса"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
def detect_command_type(self, command: str) -> str:
"""Определение типа команды"""
if "test" in command:
return "test"
elif "deploy" in command:
return "deploy"
elif "export" in command:
return "export"
elif "import" in command:
return "import"
elif "lint" in command:
return "lint"
else:
return "other"
def parse_parameters(self, command: str, args: List[str] = None) -> dict:
"""Парсинг параметров команды"""
params = {}
# Парсинг команды
parts = command.split()
if len(parts) > 2:
params["role_name"] = parts[2]
if len(parts) > 3:
params["preset"] = parts[3]
# Парсинг аргументов
if args:
for i, arg in enumerate(args):
if arg.startswith("--"):
key = arg[2:].replace("-", "_")
if i + 1 < len(args) and not args[i + 1].startswith("--"):
params[key] = args[i + 1]
else:
params[key] = True
return params
async def execute(
self,
command: str,
args: List[str] = None,
user: str = None
) -> Dict:
"""
Выполнение команды make
Args:
command: Команда make (например, "role test nginx default")
args: Дополнительные аргументы
user: Пользователь, выполнивший команду
Returns:
Словарь с результатами выполнения
"""
cmd = ["make"] + command.split() + (args or [])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=self.project_root,
timeout=3600 # 1 час максимум
)
return {
"success": result.returncode == 0,
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
"command": " ".join(cmd),
"timestamp": datetime.now().isoformat()
}
except subprocess.TimeoutExpired:
return {
"success": False,
"stdout": "",
"stderr": "Command timeout after 1 hour",
"returncode": -1,
"command": " ".join(cmd),
"timestamp": datetime.now().isoformat()
}
except Exception as e:
return {
"success": False,
"stdout": "",
"stderr": str(e),
"returncode": -1,
"command": " ".join(cmd),
"timestamp": datetime.now().isoformat()
}
async def execute_stream(
self,
command: str,
args: List[str] = None
) -> AsyncGenerator[str, None]:
"""
Выполнение команды с потоковым выводом для WebSocket
Args:
command: Команда make
args: Дополнительные аргументы
Yields:
Строки вывода команды
"""
cmd = ["make"] + command.split() + (args or [])
process = await asyncio.create_subprocess_exec(
*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()
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"
def parse_play_recap(self, line: str) -> dict:
"""Парсинг PLAY RECAP из Ansible вывода"""
# Пример: "web1.example.com : ok=5 changed=3 unreachable=0 failed=0"
match = re.search(r'(\S+)\s*:\s*ok=(\d+)\s+changed=(\d+).*failed=(\d+)', line)
if match:
return {
"host": match.group(1),
"ok": int(match.group(2)),
"changed": int(match.group(3)),
"failed": int(match.group(4)),
"status": "success" if int(match.group(4)) == 0 else "failed"
}
return {}

View 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"