feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core package
|
||||
148
app/core/ansible_executor.py
Normal file
148
app/core/ansible_executor.py
Normal 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
64
app/core/config.py
Normal 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
231
app/core/docker_client.py
Normal 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
169
app/core/make_executor.py
Normal 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 {}
|
||||
312
app/core/molecule_executor.py
Normal file
312
app/core/molecule_executor.py
Normal 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"
|
||||
Reference in New Issue
Block a user