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/services/__init__.py Normal file
View File

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

View File

@@ -0,0 +1,133 @@
"""
Сервис для развертывания ролей на реальные серверы
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import asyncio
from pathlib import Path
from typing import Optional, AsyncGenerator, Dict, List
from datetime import datetime
from app.core.config import settings
from app.core.ansible_executor import AnsibleExecutor
class DeploymentService:
"""Развертывание ролей на реальные серверы"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
self.inventory_file = self.project_root / "inventory" / "hosts.ini"
self.deploy_playbook = self.project_root / "roles" / "deploy.yml"
self.ansible_executor = AnsibleExecutor()
async def deploy_role(
self,
role_name: str,
inventory: Optional[str] = None,
limit: Optional[str] = None,
tags: Optional[List[str]] = None,
check: bool = False,
extra_vars: Optional[Dict] = None,
stream: bool = False
) -> AsyncGenerator[str, None]:
"""
Развертывание роли на реальные серверы
Args:
role_name: Имя роли для развертывания
inventory: Путь к inventory файлу (по умолчанию inventory/hosts.ini)
limit: Ограничение на хосты
tags: Список тегов для фильтрации
check: Режим dry-run (--check)
extra_vars: Дополнительные переменные
stream: Если True, возвращает генератор строк
Yields:
Строки вывода команды
"""
# Проверка существования роли
role_path = self.project_root / "roles" / role_name
if not role_path.exists():
yield f"❌ Роль '{role_name}' не найдена\n"
return
# Определение inventory файла
if inventory:
inventory_path = Path(inventory)
if not inventory_path.is_absolute():
inventory_path = self.project_root / inventory_path
else:
inventory_path = self.inventory_file
if not inventory_path.exists():
yield f"❌ Inventory файл '{inventory_path}' не найден\n"
yield "💡 Создайте файл inventory/hosts.ini с вашими серверами\n"
return
# Проверка deploy.yml
if not self.deploy_playbook.exists():
yield f"❌ Playbook '{self.deploy_playbook}' не найден\n"
return
# Формирование тегов
deploy_tags = [role_name]
if tags:
deploy_tags.extend(tags)
yield f"🚀 Развертывание роли '{role_name}' на реальные серверы...\n"
if check:
yield "⚠️ Режим dry-run (--check) - изменения не будут применены\n"
yield f"📋 Inventory: {inventory_path}\n"
if limit:
yield f"📋 Limit: {limit}\n"
yield f"📋 Tags: {', '.join(deploy_tags)}\n\n"
# Запуск ansible-playbook
async for line in self.ansible_executor.run_playbook(
playbook_path=str(self.deploy_playbook),
inventory=str(inventory_path),
tags=deploy_tags,
limit=limit,
check=check,
extra_vars=extra_vars,
stream=True
):
yield line
yield "\n✅ Развертывание завершено\n"
async def dry_run_role(
self,
role_name: str,
inventory: Optional[str] = None,
limit: Optional[str] = None,
stream: bool = False
) -> AsyncGenerator[str, None]:
"""
Dry-run проверка роли на реальных серверах (без изменений)
Args:
role_name: Имя роли
inventory: Путь к inventory файлу
limit: Ограничение на хосты
stream: Если True, возвращает генератор строк
Yields:
Строки вывода команды
"""
yield f"🔍 Dry-run проверка роли '{role_name}' на реальных серверах...\n"
yield "⚠️ Безопасно: не изменяет серверы, только проверяет\n\n"
async for line in self.deploy_role(
role_name=role_name,
inventory=inventory,
limit=limit,
check=True,
stream=True
):
yield line
def detect_log_level(self, line: str) -> str:
"""Определение уровня лога из строки"""
return self.ansible_executor.detect_log_level(line)

View File

@@ -0,0 +1,418 @@
"""
Сервис для сборки Docker образов
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import asyncio
import subprocess
import os
from pathlib import Path
from typing import Optional, AsyncGenerator, List, Dict
from datetime import datetime
from app.core.config import settings
from app.core.docker_client import DockerClient
class DockerBuildService:
"""Сервис для сборки и пуша Docker образов"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
self.docker_client = DockerClient()
async def build_image(
self,
dockerfile_content: str,
image_name: str,
tag: str = "latest",
platforms: List[str] = None,
build_args: Dict = None,
no_cache: bool = False,
stream: bool = False
) -> AsyncGenerator[str, None]:
"""
Сборка Docker образа
Args:
dockerfile_content: Содержимое Dockerfile
image_name: Имя образа (например, inecs/ansible-lab:ubuntu22)
tag: Тег образа
platforms: Список платформ для multi-arch сборки (amd64, arm64, etc.)
build_args: Аргументы сборки
stream: Если True, возвращает генератор строк
Yields:
Строки вывода команды
"""
if platforms is None:
platforms = ["linux/amd64"]
# Создаем временный Dockerfile
import tempfile
import os
with tempfile.TemporaryDirectory() as tmpdir:
dockerfile_path = Path(tmpdir) / "Dockerfile"
dockerfile_path.write_text(dockerfile_content)
# Проверяем, есть ли уже тег в image_name
# Если есть, используем его, иначе добавляем переданный тег
if ":" in image_name:
# Тег уже есть в image_name, используем его как есть
full_image_name = image_name
else:
# Тега нет, добавляем переданный тег
full_image_name = f"{image_name}:{tag}"
# Формируем команду docker buildx build
# Используем полный путь к docker для надежности
docker_cmd = "/usr/bin/docker"
cmd = [
docker_cmd, "buildx", "build",
"--file", str(dockerfile_path),
"--tag", full_image_name,
"--progress", "plain"
]
# Флаг --no-cache
if no_cache:
cmd.append("--no-cache")
# Multi-arch сборка
# Если несколько платформ, собираем каждую отдельно для лучшей обработки ошибок
if len(platforms) > 1:
successful_platforms = []
failed_platforms = []
yield f"🔨 Запуск сборки образа {full_image_name}...\n"
yield f"📋 Платформы: {', '.join(platforms)}\n"
yield f" Сборка будет выполняться для каждой платформы отдельно\n\n"
# Устанавливаем PATH для доступа к docker
env = os.environ.copy()
env["PATH"] = "/usr/bin:/usr/local/bin:" + env.get("PATH", "")
# Собираем каждую платформу отдельно
for platform in platforms:
yield f"\n{'='*60}\n"
yield f"🔨 Сборка для платформы: {platform}\n"
yield f"{'='*60}\n\n"
# Формируем команду для одной платформы
platform_cmd = [
docker_cmd, "buildx", "build",
"--file", str(dockerfile_path),
"--tag", full_image_name,
"--platform", platform,
"--progress", "plain"
]
if no_cache:
platform_cmd.append("--no-cache")
if build_args:
for key, value in build_args.items():
platform_cmd.extend(["--build-arg", f"{key}={value}"])
platform_cmd.append("--load")
platform_cmd.append(tmpdir)
# Запускаем сборку для платформы
process = await asyncio.create_subprocess_exec(
*platform_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(self.project_root),
env=env
)
platform_output = []
async for line in process.stdout:
decoded_line = line.decode('utf-8', errors='replace')
platform_output.append(decoded_line)
yield decoded_line
await process.wait()
# Проверяем наличие ошибок, связанных с отсутствием образа для платформы
output_text = "".join(platform_output).lower()
has_platform_error = (
"no match for platform in manifest" in output_text or
"failed to resolve source metadata" in output_text or
"not found" in output_text
)
if process.returncode == 0:
successful_platforms.append(platform)
yield f"\n✅ Платформа {platform} собрана успешно\n"
elif has_platform_error:
failed_platforms.append(platform)
yield f"\n⚠️ Платформа {platform}: базовый образ недоступен для этой архитектуры\n"
yield f" Сборка продолжается для других платформ\n"
else:
failed_platforms.append(platform)
yield f"\n❌ Платформа {platform}: ошибка сборки (код: {process.returncode})\n"
# Итоговый результат
yield f"\n{'='*60}\n"
yield f"📊 Итоги сборки:\n"
yield f"{'='*60}\n"
if successful_platforms:
yield f"✅ Успешно собрано для платформ: {', '.join(successful_platforms)}\n"
if failed_platforms:
yield f"⚠️ Не собрано для платформ: {', '.join(failed_platforms)}\n"
# Если хотя бы одна платформа собралась успешно, считаем сборку успешной
if successful_platforms:
yield f"\n✅ Сборка завершена: образ доступен для {len(successful_platforms)} из {len(platforms)} платформ\n"
else:
yield f"\n❌ Сборка не удалась для всех платформ\n"
else:
# Одна платформа - обычная сборка
cmd.extend(["--platform", platforms[0]])
cmd.append("--load") # Загружаем образ в локальный Docker
# Build args
if build_args:
for key, value in build_args.items():
cmd.extend(["--build-arg", f"{key}={value}"])
cmd.append(tmpdir)
# Запуск сборки
yield f"🔨 Запуск сборки образа {full_image_name}...\n"
yield f"📋 Платформа: {platforms[0]}\n\n"
# Устанавливаем PATH для доступа к docker
env = os.environ.copy()
env["PATH"] = "/usr/bin:/usr/local/bin:" + env.get("PATH", "")
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()
if process.returncode == 0:
yield f"\n✅ Сборка завершена успешно: {full_image_name}\n"
else:
yield f"\n❌ Сборка завершена с ошибкой (код: {process.returncode})\n"
async def push_image(
self,
image_name: str,
tag: str = "latest",
registry: str = "docker.io",
username: Optional[str] = None,
password: Optional[str] = None,
stream: bool = False,
use_container: bool = False
) -> AsyncGenerator[str, None]:
"""
Пуш Docker образа в репозиторий
Args:
image_name: Имя образа
tag: Тег образа
registry: Реестр (docker.io для Docker Hub, или URL Harbor)
username: Имя пользователя для авторизации
password: Пароль для авторизации
stream: Если True, возвращает генератор строк
use_container: Если True, выполняет пуш в отдельном контейнере
Yields:
Строки вывода команды
"""
# Используем полный путь к docker
docker_cmd = "/usr/bin/docker"
env = os.environ.copy()
env["PATH"] = "/usr/bin:/usr/local/bin:" + env.get("PATH", "")
# Если нужно использовать контейнер для пуша
if use_container:
import tempfile
from app.core.docker_client import DockerClient
docker_client = DockerClient()
# Проверяем, есть ли уже тег в image_name
if ":" in image_name:
source_image = image_name
else:
source_image = f"{image_name}:{tag}"
# Формируем полное имя образа для пуша
if registry == "docker.io":
if username:
if "/" in source_image:
image_part = source_image.split("/", 1)[1] if "/" in source_image else source_image
full_image_name = f"{username}/{image_part}"
else:
full_image_name = f"{username}/{source_image}"
else:
full_image_name = source_image
else:
full_image_name = f"{registry}/{source_image}"
from datetime import datetime
container_name = f"dockerfile-pusher-{int(datetime.utcnow().timestamp())}"
try:
# Авторизация если нужно
if username and password:
yield f"🔐 Авторизация в {registry}...\n"
login_cmd = f"echo '{password}' | docker login {registry} --username {username} --password-stdin"
else:
login_cmd = ""
# Команда пуша
push_cmd = f"docker push {full_image_name}"
# Тегируем образ если нужно
tag_cmd = f"docker tag {source_image} {full_image_name}"
full_cmd = f"{tag_cmd} && {push_cmd}" if login_cmd else f"{tag_cmd} && {push_cmd}"
if login_cmd:
full_cmd = f"{login_cmd} && {full_cmd}"
# Запускаем контейнер для пуша
container = docker_client.client.containers.run(
image="docker:24-cli",
name=container_name,
command=["sh", "-c", full_cmd],
volumes={"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "ro"}},
detach=True,
remove=False,
auto_remove=False
)
yield f"🚀 Запуск пуша в контейнере {container_name}...\n"
# Читаем логи контейнера
for log_line in container.logs(stream=True, follow=True):
line = log_line.decode('utf-8', errors='replace').rstrip()
if line:
yield line + "\n"
# Ждем завершения
result = container.wait()
exit_code = result.get("StatusCode") if isinstance(result, dict) else result
if exit_code == 0:
yield f"\n✅ Образ успешно отправлен: {full_image_name}\n"
else:
yield f"\n❌ Ошибка отправки образа (код: {exit_code})\n"
except Exception as e:
yield f"\n❌ Ошибка при пуше: {str(e)}\n"
raise
finally:
# Удаляем контейнер
try:
container = docker_client.client.containers.get(container_name)
if container.status != "removing":
container.remove(force=True)
except:
pass
return
# Авторизация если нужно
if username and password:
yield f"🔐 Авторизация в {registry}...\n"
login_cmd = [docker_cmd, "login", registry, "--username", username, "--password-stdin"]
process = await asyncio.create_subprocess_exec(
*login_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=env
)
stdout, _ = await process.communicate(input=password.encode())
await process.wait()
if process.returncode != 0:
yield f"❌ Ошибка авторизации: {stdout.decode()}\n"
return
else:
yield "✅ Авторизация успешна\n\n"
# Проверяем, есть ли уже тег в image_name
if ":" in image_name:
# Тег уже есть в image_name, используем его
source_image = image_name
else:
# Тега нет, добавляем переданный тег
source_image = f"{image_name}:{tag}"
# Формируем полное имя образа для пуша
if registry == "docker.io":
if username:
# Если есть username, добавляем его к имени образа
if "/" in source_image:
# Извлекаем имя образа без username
image_part = source_image.split("/", 1)[1] if "/" in source_image else source_image
full_image_name = f"{username}/{image_part}"
else:
full_image_name = f"{username}/{source_image}"
else:
full_image_name = source_image
else:
# Harbor формат: harbor.example.com/project/image:tag
full_image_name = f"{registry}/{source_image}"
# Тегируем образ
yield f"🏷️ Тегирование образа {source_image} -> {full_image_name}...\n"
tag_cmd = [docker_cmd, "tag", source_image, full_image_name]
tag_process = await asyncio.create_subprocess_exec(
*tag_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=env
)
await tag_process.wait()
if tag_process.returncode != 0:
yield f"❌ Ошибка тегирования\n"
return
# Пуш образа
yield f"📤 Отправка образа {full_image_name}...\n\n"
push_cmd = [docker_cmd, "push", full_image_name]
push_process = await asyncio.create_subprocess_exec(
*push_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=env
)
async for line in push_process.stdout:
yield line.decode('utf-8', errors='replace')
await push_process.wait()
if push_process.returncode == 0:
yield f"\n✅ Образ успешно отправлен: {full_image_name}\n"
else:
yield f"\n❌ Ошибка отправки образа (код: {push_process.returncode})\n"
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 ["success", "pushed", "built"]):
return "info"
else:
return "debug"

View File

@@ -0,0 +1,236 @@
"""
Сервис для взаимодействия с Docker Builder API
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, List
import httpx
logger = logging.getLogger(__name__)
class DockerBuilderService:
"""Сервис для работы с Docker Builder API"""
def __init__(self, base_url: str = "http://docker-builder:8001"):
"""
Инициализация сервиса
Args:
base_url: URL Docker Builder API
"""
self.base_url = base_url.rstrip("/")
async def start_build(
self,
build_log_id: int,
dockerfile_id: int = None, # ID Dockerfile в БД (приоритет)
dockerfile_content: str = None, # Для обратной совместимости
dockerfile_path: str = None, # Для обратной совместимости
image_name: str = None,
tag: str = None,
platforms: List[str] = None,
context_path: str = None,
no_cache: bool = False,
webhook_url: Optional[str] = None
) -> dict:
"""
Запуск сборки Docker образа через Builder API
Args:
build_log_id: ID записи лога в БД
dockerfile_content: Содержимое Dockerfile (приоритет над dockerfile_path)
dockerfile_path: Путь к Dockerfile (если dockerfile_content не указан)
image_name: Имя образа
tag: Тег образа
platforms: Список платформ для сборки
context_path: Путь к контексту сборки (если не указан, будет создана временная директория)
no_cache: Сборка без кеша
webhook_url: URL для отправки логов обратно в основное приложение
Returns:
dict: Информация о запущенной сборке
"""
url = f"{self.base_url}/api/v1/build/start"
payload = {
"build_log_id": build_log_id,
"image_name": image_name,
"tag": tag,
"platforms": platforms,
"no_cache": no_cache,
}
# Добавляем webhook_url только если он указан
if webhook_url:
payload["webhook_url"] = webhook_url
# Добавляем dockerfile_id (приоритет) или dockerfile_content/dockerfile_path для обратной совместимости
if dockerfile_id:
payload["dockerfile_id"] = dockerfile_id
elif dockerfile_content:
payload["dockerfile_content"] = dockerfile_content
elif dockerfile_path:
payload["dockerfile_path"] = dockerfile_path
# Если передан context_path, используем его
if context_path:
payload["context_path"] = context_path
try:
async with httpx.AsyncClient(timeout=30.0) as client:
logger.info(f"Sending build request to {url} with payload: {payload}")
response = await client.post(url, json=payload)
# Если ошибка, логируем детали ответа
if response.status_code != 200:
error_detail = response.text
logger.error(f"Builder API returned {response.status_code}: {error_detail}")
try:
error_json = response.json()
error_detail = error_json.get("detail", error_detail)
except:
pass
raise Exception(f"Builder API вернул ошибку {response.status_code}: {error_detail}")
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"Error starting build via Builder API: {e}")
if hasattr(e, 'response') and e.response is not None:
try:
error_detail = e.response.json()
logger.error(f"Error details: {error_detail}")
except:
logger.error(f"Error response text: {e.response.text}")
raise Exception(f"Не удалось запустить сборку через Builder API: {e}")
async def get_build_status(self, build_id: str) -> dict:
"""
Получение статуса сборки
Args:
build_id: ID сборки
Returns:
dict: Статус сборки
"""
url = f"{self.base_url}/api/v1/build/{build_id}/status"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"Error getting build status: {e}")
raise Exception(f"Не удалось получить статус сборки: {e}")
async def cancel_build(self, build_id: str) -> dict:
"""
Отмена сборки
Args:
build_id: ID сборки
Returns:
dict: Результат отмены
"""
url = f"{self.base_url}/api/v1/build/{build_id}/cancel"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"Error cancelling build: {e}")
raise Exception(f"Не удалось отменить сборку: {e}")
async def push_image(
self,
image_name: str,
webhook_url: Optional[str] = None,
registry: Optional[str] = "docker.io",
username: Optional[str] = None,
password: Optional[str] = None
) -> dict:
"""
Отправка Docker образа в registry через Builder API
Args:
image_name: Полное имя образа с тегом (например, "myimage:latest")
webhook_url: URL для отправки логов обратно в основное приложение
registry: Registry (docker.io или URL Harbor)
username: Имя пользователя для авторизации
password: Пароль для авторизации
Returns:
dict: Информация о запущенной отправке
"""
url = f"{self.base_url}/api/v1/push/start"
payload = {
"image_name": image_name,
"webhook_url": webhook_url,
"registry": registry,
"username": username,
"password": password
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
# Обрабатываем ошибки HTTP статуса
error_detail = f"HTTP {e.response.status_code}"
try:
error_data = e.response.json()
if isinstance(error_data, dict):
if "detail" in error_data:
detail = error_data["detail"]
if isinstance(detail, list):
error_detail = ", ".join([str(item) for item in detail])
else:
error_detail = str(detail)
elif "message" in error_data:
error_detail = str(error_data["message"])
elif isinstance(error_data, list):
error_detail = ", ".join([str(item) for item in error_data])
except:
# Если не удалось распарсить JSON, используем текст ответа
try:
error_detail = e.response.text[:500] # Ограничиваем длину
except:
error_detail = str(e)
logger.error(f"Error starting push via Builder API: {error_detail}")
raise Exception(f"Не удалось запустить отправку образа через Builder API: {error_detail}")
except httpx.HTTPError as e:
logger.error(f"Error starting push via Builder API: {e}")
raise Exception(f"Не удалось запустить отправку образа через Builder API: {str(e)}")
async def health_check(self) -> dict:
"""
Проверка здоровья Builder API
Returns:
dict: Статус здоровья
"""
url = f"{self.base_url}/health"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.warning(f"Builder API health check failed: {e}")
return {"status": "unhealthy", "error": str(e)}
# Глобальный экземпляр сервиса
docker_builder_service = DockerBuilderService()

View File

@@ -0,0 +1,168 @@
"""
Сервис для работы с Dockerfile
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.database import Dockerfile
from typing import Optional, List
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
class DockerfileService:
"""Сервис для работы с Dockerfile"""
@staticmethod
async def get_dockerfile(db: AsyncSession, dockerfile_id: int) -> Optional[Dockerfile]:
"""Получение Dockerfile по ID"""
result = await db.execute(select(Dockerfile).where(Dockerfile.id == dockerfile_id))
return result.scalar_one_or_none()
@staticmethod
async def get_dockerfile_by_name(db: AsyncSession, name: str) -> Optional[Dockerfile]:
"""Получение Dockerfile по имени"""
result = await db.execute(select(Dockerfile).where(Dockerfile.name == name))
return result.scalar_one_or_none()
@staticmethod
async def list_dockerfiles(db: AsyncSession, status: Optional[str] = None) -> List[Dockerfile]:
"""Список всех Dockerfile"""
query = select(Dockerfile)
if status:
query = query.where(Dockerfile.status == status)
result = await db.execute(query.order_by(Dockerfile.name))
return result.scalars().all()
@staticmethod
async def create_dockerfile(
db: AsyncSession,
name: str,
content: str,
description: Optional[str] = None,
base_image: Optional[str] = None,
tags: Optional[List[str]] = None,
platforms: Optional[List[str]] = None,
created_by: Optional[str] = None
) -> Dockerfile:
"""Создание нового Dockerfile"""
# Платформы по умолчанию: linux/amd64 (x86_64), linux/386 (x86) и linux/arm64 (macOS M1)
if platforms is None:
platforms = ["linux/amd64", "linux/386", "linux/arm64"]
dockerfile = Dockerfile(
name=name,
description=description,
content=content,
base_image=base_image,
tags=tags or [],
platforms=platforms,
created_by=created_by
)
db.add(dockerfile)
await db.commit()
await db.refresh(dockerfile)
return dockerfile
@staticmethod
async def update_dockerfile(
db: AsyncSession,
dockerfile_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
content: Optional[str] = None,
base_image: Optional[str] = None,
tags: Optional[List[str]] = None,
platforms: Optional[List[str]] = None,
updated_by: Optional[str] = None
) -> Optional[Dockerfile]:
"""Обновление Dockerfile"""
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
if not dockerfile:
return None
if name:
dockerfile.name = name
if description is not None:
dockerfile.description = description
if content is not None:
dockerfile.content = content
if base_image is not None:
dockerfile.base_image = base_image
if tags is not None:
dockerfile.tags = tags
if platforms is not None:
dockerfile.platforms = platforms
if updated_by:
dockerfile.updated_by = updated_by
await db.commit()
await db.refresh(dockerfile)
return dockerfile
@staticmethod
async def delete_dockerfile(db: AsyncSession, dockerfile_id: int) -> bool:
"""Удаление Dockerfile"""
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
if not dockerfile:
return False
await db.delete(dockerfile)
await db.commit()
return True
@staticmethod
async def load_from_filesystem(
db: AsyncSession,
project_root: Path,
created_by: Optional[str] = None
) -> List[Dockerfile]:
"""Загрузка Dockerfile из файловой системы в БД"""
# Dockerfiles теперь находятся в alembic/dockerfiles
alembic_dir = project_root / "app" / "alembic"
dockerfiles_dir = alembic_dir / "dockerfiles"
loaded = []
if not dockerfiles_dir.exists():
logger.warning(f"Dockerfiles directory not found: {dockerfiles_dir}")
return loaded
for dockerfile_path in dockerfiles_dir.rglob("Dockerfile*"):
if dockerfile_path.is_file():
# Имя из пути (например, ubuntu22/Dockerfile -> ubuntu22)
relative_path = dockerfile_path.relative_to(dockerfiles_dir)
name = str(relative_path.parent) if relative_path.parent != Path('.') else relative_path.stem
# Проверяем, существует ли уже в БД
existing = await DockerfileService.get_dockerfile_by_name(db, name)
if existing:
continue
content = dockerfile_path.read_text(encoding='utf-8')
# Определяем базовый образ из содержимого
base_image = None
for line in content.split('\n'):
if line.strip().startswith('FROM'):
base_image = line.strip().replace('FROM', '').strip().split()[0]
break
# Платформы по умолчанию: linux/amd64 (x86_64), linux/386 (x86) и linux/arm64 (macOS M1)
default_platforms = ["linux/amd64", "linux/386", "linux/arm64"]
dockerfile = await DockerfileService.create_dockerfile(
db=db,
name=name,
content=content,
base_image=base_image,
platforms=default_platforms,
created_by=created_by
)
loaded.append(dockerfile)
logger.info(f"Loaded Dockerfile: {name}")
return loaded

View File

@@ -0,0 +1,219 @@
"""
Сервис для экспорта ролей в Git репозитории
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import shutil
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from git import Repo
from git.exc import GitCommandError
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class ExportService:
"""Сервис для экспорта ролей в Git репозитории"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
self.roles_dir = self.project_root / "roles"
async def export_role(
self,
role_name: str,
repo_url: str,
branch: str = "main",
version: str = None,
components: List[str] = None,
include_secrets: bool = False,
commit_message: str = None
) -> Dict:
"""
Экспорт роли в Git репозиторий
Args:
role_name: Имя роли для экспорта
repo_url: URL Git репозитория
branch: Ветка для коммита (по умолчанию main)
version: Версия роли (для создания тега)
components: Список компонентов для экспорта
include_secrets: Включать ли секреты из vars/
commit_message: Сообщение коммита
Returns:
Информация о результате экспорта
"""
role_dir = self.roles_dir / role_name
if not role_dir.exists():
raise ValueError(f"Роль '{role_name}' не найдена")
# Компоненты по умолчанию
if components is None:
components = ["tasks", "handlers", "defaults", "meta", "templates", "files", "README.md"]
# Создание временной директории для подготовки файлов
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
export_dir = temp_path / "export"
export_dir.mkdir()
# Копирование выбранных компонентов
for component in components:
src = role_dir / component
if src.exists():
dest = export_dir / component
if src.is_dir():
shutil.copytree(src, dest)
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
# Обработка vars (секреты)
if "vars" in components:
vars_dir = export_dir / "vars"
vars_dir.mkdir(exist_ok=True)
vars_file = role_dir / "vars" / "main.yml"
if vars_file.exists():
if include_secrets:
shutil.copy2(vars_file, vars_dir / "main.yml")
else:
# Создать файл без секретов
self._create_vars_without_secrets(vars_file, vars_dir / "main.yml")
# Создание .gitignore
self._create_gitignore(export_dir)
# Создание .ansible-lint если нужно
self._create_ansible_lint(export_dir)
# Клонирование/обновление репозитория
repo_dir = temp_path / "repo"
try:
if repo_dir.exists():
repo = Repo(repo_dir)
repo.remote().pull()
else:
repo = Repo.clone_from(repo_url, repo_dir, branch=branch)
except GitCommandError as e:
raise ValueError(f"Ошибка работы с Git репозиторием: {str(e)}")
# Копирование файлов в репозиторий
for item in export_dir.iterdir():
dest = repo_dir / item.name
if dest.exists():
if dest.is_dir():
shutil.rmtree(dest)
else:
dest.unlink()
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
# Коммит и push
repo.git.add(A=True)
if not commit_message:
commit_message = f"Export role {role_name}"
if version:
commit_message += f" v{version}"
try:
repo.index.commit(commit_message)
repo.remote().push()
# Создание тега
if version:
tag_name = f"v{version}"
repo.create_tag(tag_name, message=f"Version {version} of {role_name}")
repo.remote().push(tags=True)
commit_hash = repo.head.commit.hexsha
return {
"success": True,
"role_name": role_name,
"repo_url": repo_url,
"branch": branch,
"version": version,
"commit": commit_hash,
"message": f"Роль '{role_name}' успешно экспортирована в {repo_url}"
}
except GitCommandError as e:
raise ValueError(f"Ошибка при коммите/push: {str(e)}")
def _create_vars_without_secrets(self, src_file: Path, dest_file: Path):
"""Создание vars/main.yml без секретов"""
try:
with open(src_file) as f:
data = yaml.safe_load(f)
# Удаление секретных полей (можно настроить)
if isinstance(data, dict):
# Удаляем поля, содержащие секреты
secret_keys = ["password", "secret", "key", "token", "api_key"]
for key in list(data.keys()):
if any(secret in key.lower() for secret in secret_keys):
data[key] = "***REDACTED***"
dest_file.parent.mkdir(parents=True, exist_ok=True)
with open(dest_file, 'w') as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
except Exception as e:
logger.warning(f"Не удалось обработать vars файл: {e}")
# Создаем пустой файл
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text("# Секреты не включены в экспорт\n")
def _create_gitignore(self, export_dir: Path):
"""Создание .gitignore для экспортируемой роли"""
gitignore_content = """# Ansible role
*.retry
*.pyc
__pycache__/
.DS_Store
.vscode/
.idea/
*.swp
*.swo
*~
"""
(export_dir / ".gitignore").write_text(gitignore_content)
def _create_ansible_lint(self, export_dir: Path):
"""Создание .ansible-lint если нужно"""
ansible_lint_content = """---
# Ansible Lint configuration
skip_list:
- yaml[line-length]
- yaml[truthy]
"""
(export_dir / ".ansible-lint").write_text(ansible_lint_content)
def get_role_components(self, role_name: str) -> List[str]:
"""Получение списка доступных компонентов роли"""
role_dir = self.roles_dir / role_name
if not role_dir.exists():
return []
components = []
# Стандартные директории
for component in ["tasks", "handlers", "defaults", "vars", "meta", "templates", "files"]:
if (role_dir / component).exists():
components.append(component)
# Файлы
for file in ["README.md", ".gitignore", ".ansible-lint"]:
if (role_dir / file).exists():
components.append(file)
return components

View File

@@ -0,0 +1,226 @@
"""
Сервис для работы с историей команд и тестов
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from app.models.database import (
CommandHistory, TestResult, DeploymentHistory,
ExportHistory, ImportHistory
)
from app.db.session import get_db
class HistoryService:
"""Сервис для работы с историей"""
def save_command(
self,
command: str,
command_type: str,
role_name: Optional[str] = None,
preset_name: Optional[str] = None,
status: str = "running",
user: Optional[str] = None
) -> int:
"""Сохранение команды в историю"""
db = next(get_db())
try:
cmd_history = CommandHistory(
command=command,
command_type=command_type,
role_name=role_name,
preset_name=preset_name,
status=status,
user=user,
started_at=datetime.utcnow()
)
db.add(cmd_history)
db.commit()
db.refresh(cmd_history)
return cmd_history.id
finally:
db.close()
def update_command(
self,
command_id: int,
status: str,
stdout: Optional[str] = None,
stderr: Optional[str] = None,
returncode: Optional[int] = None
):
"""Обновление команды после выполнения"""
db = next(get_db())
try:
cmd = db.query(CommandHistory).filter(CommandHistory.id == command_id).first()
if cmd:
cmd.status = status
cmd.stdout = stdout
cmd.stderr = stderr
cmd.returncode = returncode
cmd.finished_at = datetime.utcnow()
if cmd.started_at:
duration = (cmd.finished_at - cmd.started_at).total_seconds()
cmd.duration = int(duration)
db.commit()
finally:
db.close()
def get_command_history(
self,
limit: int = 50,
role_name: Optional[str] = None,
command_type: Optional[str] = None
) -> List[Dict]:
"""Получение истории команд"""
db = next(get_db())
try:
query = db.query(CommandHistory)
if role_name:
query = query.filter(CommandHistory.role_name == role_name)
if command_type:
query = query.filter(CommandHistory.command_type == command_type)
commands = query.order_by(CommandHistory.started_at.desc()).limit(limit).all()
return [
{
"id": cmd.id,
"command": cmd.command,
"command_type": cmd.command_type,
"role_name": cmd.role_name,
"preset_name": cmd.preset_name,
"status": cmd.status,
"started_at": cmd.started_at.isoformat() if cmd.started_at else None,
"finished_at": cmd.finished_at.isoformat() if cmd.finished_at else None,
"duration": cmd.duration
}
for cmd in commands
]
finally:
db.close()
def save_test_result(
self,
command_id: int,
role_name: str,
preset_name: Optional[str],
test_type: str,
status: str,
output: Optional[str] = None,
error: Optional[str] = None,
duration: Optional[int] = None
) -> int:
"""Сохранение результата теста"""
db = next(get_db())
try:
test_result = TestResult(
command_id=command_id,
role_name=role_name,
preset_name=preset_name,
test_type=test_type,
status=status,
output=output,
error=error,
duration=duration,
created_at=datetime.utcnow()
)
db.add(test_result)
db.commit()
db.refresh(test_result)
return test_result.id
finally:
db.close()
def save_deployment(
self,
role_name: str,
inventory: str,
hosts: List[str],
status: str,
output: Optional[str] = None,
error: Optional[str] = None,
user: Optional[str] = None
) -> int:
"""Сохранение истории деплоя"""
db = next(get_db())
try:
deployment = DeploymentHistory(
role_name=role_name,
inventory=inventory,
hosts=hosts,
status=status,
output=output,
error=error,
user=user,
started_at=datetime.utcnow()
)
db.add(deployment)
db.commit()
db.refresh(deployment)
return deployment.id
finally:
db.close()
def save_export(
self,
role_name: str,
repo_url: str,
branch: str,
version: Optional[str],
commit_hash: Optional[str],
status: str,
user: Optional[str] = None
) -> int:
"""Сохранение истории экспорта"""
db = next(get_db())
try:
export = ExportHistory(
role_name=role_name,
repo_url=repo_url,
branch=branch,
version=version,
commit_hash=commit_hash,
status=status,
user=user,
started_at=datetime.utcnow(),
finished_at=datetime.utcnow() if status != "running" else None
)
db.add(export)
db.commit()
db.refresh(export)
return export.id
finally:
db.close()
def save_import(
self,
role_name: str,
source_type: str,
source_url: Optional[str],
status: str,
user: Optional[str] = None
) -> int:
"""Сохранение истории импорта"""
db = next(get_db())
try:
import_history = ImportHistory(
role_name=role_name,
source_type=source_type,
source_url=source_url,
status=status,
user=user,
started_at=datetime.utcnow(),
finished_at=datetime.utcnow() if status != "running" else None
)
db.add(import_history)
db.commit()
db.refresh(import_history)
return import_history.id
finally:
db.close()

View File

@@ -0,0 +1,233 @@
"""
Сервис для импорта ролей из репозиториев и Ansible Galaxy
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import shutil
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from git import Repo
from git.exc import GitCommandError
from app.core.config import settings
import logging
import subprocess
logger = logging.getLogger(__name__)
class ImportService:
"""Сервис для импорта ролей"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
self.roles_dir = self.project_root / "roles"
async def import_from_git(
self,
repo_url: str,
role_name: Optional[str] = None,
branch: str = "main",
subdirectory: Optional[str] = None
) -> Dict:
"""
Импорт роли из Git репозитория
Args:
repo_url: URL Git репозитория
role_name: Имя роли (если не указано, берется из имени репозитория)
branch: Ветка для клонирования
subdirectory: Поддиректория в репозитории (если роль не в корне)
Returns:
Информация о результате импорта
"""
# Определение имени роли
if not role_name:
# Извлекаем имя из URL
repo_name = repo_url.rstrip('/').split('/')[-1].replace('.git', '')
# Убираем префиксы типа ansible-role-
if repo_name.startswith('ansible-role-'):
role_name = repo_name.replace('ansible-role-', '')
elif repo_name.startswith('ansible-'):
role_name = repo_name.replace('ansible-', '')
else:
role_name = repo_name
role_dir = self.roles_dir / role_name
if role_dir.exists():
raise ValueError(f"Роль '{role_name}' уже существует. Используйте другое имя или удалите существующую роль.")
# Клонирование репозитория во временную директорию
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
repo_dir = temp_path / "repo"
try:
repo = Repo.clone_from(repo_url, repo_dir, branch=branch)
except GitCommandError as e:
raise ValueError(f"Ошибка клонирования репозитория: {str(e)}")
# Определение исходной директории роли
source_dir = repo_dir
if subdirectory:
source_dir = repo_dir / subdirectory
if not source_dir.exists():
raise ValueError(f"Поддиректория '{subdirectory}' не найдена в репозитории")
# Проверка структуры роли
if not (source_dir / "tasks").exists() and not (source_dir / "tasks" / "main.yml").exists():
# Проверяем, может быть это роль Ansible Galaxy
if (source_dir / "meta" / "main.yml").exists():
# Это роль, но структура может отличаться
pass
else:
raise ValueError("Не найдена структура роли Ansible (tasks/main.yml или meta/main.yml)")
# Копирование роли
role_dir.mkdir(parents=True, exist_ok=True)
# Копируем все стандартные директории и файлы
for item in source_dir.iterdir():
if item.name.startswith('.'):
continue
dest = role_dir / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
# Обновление deploy.yml
from app.core.make_executor import MakeExecutor
executor = MakeExecutor()
await executor.execute("update-playbooks")
return {
"success": True,
"role_name": role_name,
"repo_url": repo_url,
"branch": branch,
"message": f"Роль '{role_name}' успешно импортирована из {repo_url}"
}
async def import_from_galaxy(
self,
role_name: str,
version: Optional[str] = None,
namespace: Optional[str] = None
) -> Dict:
"""
Импорт роли из Ansible Galaxy
Args:
role_name: Имя роли (может быть с namespace: namespace.role_name)
version: Версия роли (опционально)
namespace: Namespace роли (опционально)
Returns:
Информация о результате импорта
"""
# Парсинг имени роли
if '.' in role_name:
parts = role_name.split('.', 1)
namespace = parts[0]
role_name = parts[1]
full_role_name = f"{namespace}.{role_name}" if namespace else role_name
role_dir = self.roles_dir / role_name
if role_dir.exists():
raise ValueError(f"Роль '{role_name}' уже существует")
# Создание временной директории
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Установка роли через ansible-galaxy
cmd = ["ansible-galaxy", "install", full_role_name]
if version:
cmd.extend(["--version", version])
cmd.extend(["--roles-path", str(temp_path)])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
if result.returncode != 0:
raise ValueError(f"Ошибка установки роли из Galaxy: {result.stderr}")
# Поиск установленной роли
installed_role_dir = None
for item in temp_path.iterdir():
if item.is_dir() and (item / "tasks" / "main.yml").exists():
installed_role_dir = item
break
if not installed_role_dir:
raise ValueError("Роль не найдена после установки из Galaxy")
# Копирование роли
role_dir.mkdir(parents=True, exist_ok=True)
for item in installed_role_dir.iterdir():
dest = role_dir / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
# Обновление deploy.yml
from app.core.make_executor import MakeExecutor
executor = MakeExecutor()
await executor.execute("update-playbooks")
return {
"success": True,
"role_name": role_name,
"galaxy_role": full_role_name,
"version": version,
"message": f"Роль '{role_name}' успешно импортирована из Ansible Galaxy"
}
except subprocess.TimeoutExpired:
raise ValueError("Таймаут при установке роли из Galaxy")
except Exception as e:
raise ValueError(f"Ошибка при импорте из Galaxy: {str(e)}")
async def validate_repo(self, repo_url: str, branch: str = "main") -> Dict:
"""Проверка доступности репозитория"""
try:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
repo_dir = temp_path / "repo"
repo = Repo.clone_from(repo_url, repo_dir, branch=branch, depth=1)
# Проверка структуры
has_tasks = (repo_dir / "tasks" / "main.yml").exists()
has_meta = (repo_dir / "meta" / "main.yml").exists()
return {
"valid": True,
"has_tasks": has_tasks,
"has_meta": has_meta,
"is_role": has_tasks or has_meta
}
except GitCommandError as e:
return {
"valid": False,
"error": str(e)
}
except Exception as e:
return {
"valid": False,
"error": str(e)
}

View File

@@ -0,0 +1,180 @@
"""
Сервис для проверки синтаксиса ролей (ansible-lint)
Автор: Сергей Антропов
Сайт: 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 LintService:
"""Проверка синтаксиса ролей через ansible-lint"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
self.roles_dir = self.project_root / "roles"
self.lint_config = self.project_root / ".ansible-lint"
async def lint_role(
self,
role_name: Optional[str] = None,
stream: bool = False
) -> AsyncGenerator[str, None]:
"""
Проверка синтаксиса роли через ansible-lint
Args:
role_name: Имя роли (опционально, если None - проверяются все роли)
stream: Если True, возвращает генератор строк
Yields:
Строки вывода команды
"""
# Расшифровка vault файлов перед линтингом
yield "🔓 Расшифровка vault файлов...\n"
await self._decrypt_vault_files()
if role_name:
role_path = self.roles_dir / role_name
if not role_path.exists():
yield f"❌ Роль '{role_name}' не найдена\n"
return
yield f"🔍 Проверка синтаксиса роли: {role_name}\n"
cmd = [
"ansible-lint",
str(role_path),
"--config-file", str(self.lint_config)
]
else:
yield "🔍 Проверка синтаксиса всех ролей...\n"
cmd = [
"ansible-lint",
str(self.roles_dir),
"--config-file", str(self.lint_config)
]
# Запуск в Docker контейнере для изоляции
docker_cmd = [
"docker", "run", "--rm",
"--name", f"ansible-lint-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"-v", f"{self.project_root}:/workspace",
"-w", "/workspace",
"-e", "ANSIBLE_FORCE_COLOR=1",
"inecs/ansible-lab:ansible-controller-latest",
"bash", "-c", " ".join(cmd) + " || true"
]
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"
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
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
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
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 ["passed", "ok"]):
return "info"
else:
return "debug"

View File

@@ -0,0 +1,187 @@
"""
Сервис для работы с playbook
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from app.models.database import Playbook, PlaybookTestRun, PlaybookDeployment
from typing import Optional, List, Dict
import yaml
import logging
logger = logging.getLogger(__name__)
class PlaybookService:
"""Сервис для работы с playbook"""
@staticmethod
async def create_playbook(
db: AsyncSession,
name: str,
roles: List[str],
description: Optional[str] = None,
variables: Optional[Dict] = None,
inventory: Optional[str] = None,
created_by: Optional[str] = None
) -> Playbook:
"""Создание нового playbook"""
# Генерация YAML содержимого playbook
playbook_content = PlaybookService._generate_playbook_yaml(roles, variables)
playbook = Playbook(
name=name,
description=description,
content=playbook_content,
roles=roles,
variables=variables or {},
inventory=inventory,
created_by=created_by
)
db.add(playbook)
await db.commit()
await db.refresh(playbook)
return playbook
@staticmethod
async def get_playbook(db: AsyncSession, playbook_id: int) -> Optional[Playbook]:
"""Получение playbook по ID"""
result = await db.execute(select(Playbook).where(Playbook.id == playbook_id))
return result.scalar_one_or_none()
@staticmethod
async def get_playbook_by_name(db: AsyncSession, name: str) -> Optional[Playbook]:
"""Получение playbook по имени"""
result = await db.execute(select(Playbook).where(Playbook.name == name))
return result.scalar_one_or_none()
@staticmethod
async def list_playbooks(db: AsyncSession, status: Optional[str] = None) -> List[Playbook]:
"""Список всех playbook"""
query = select(Playbook)
if status:
query = query.where(Playbook.status == status)
result = await db.execute(query.order_by(Playbook.created_at.desc()))
return result.scalars().all()
@staticmethod
async def update_playbook(
db: AsyncSession,
playbook_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
roles: Optional[List[str]] = None,
variables: Optional[Dict] = None,
inventory: Optional[str] = None,
content: Optional[str] = None,
updated_by: Optional[str] = None
) -> Optional[Playbook]:
"""Обновление playbook"""
playbook = await PlaybookService.get_playbook(db, playbook_id)
if not playbook:
return None
if name:
playbook.name = name
if description is not None:
playbook.description = description
if roles is not None:
playbook.roles = roles
# Перегенерируем content если изменились роли
playbook.content = PlaybookService._generate_playbook_yaml(roles, variables or playbook.variables)
if variables is not None:
playbook.variables = variables
# Перегенерируем content если изменились переменные
playbook.content = PlaybookService._generate_playbook_yaml(playbook.roles, variables)
if inventory is not None:
playbook.inventory = inventory
if content is not None:
playbook.content = content
if updated_by:
playbook.updated_by = updated_by
await db.commit()
await db.refresh(playbook)
return playbook
@staticmethod
async def delete_playbook(db: AsyncSession, playbook_id: int) -> bool:
"""Удаление playbook"""
playbook = await PlaybookService.get_playbook(db, playbook_id)
if not playbook:
return False
await db.delete(playbook)
await db.commit()
return True
@staticmethod
def _generate_playbook_yaml(roles: List[str], variables: Optional[Dict] = None) -> str:
"""Генерация YAML содержимого playbook"""
playbook_data = {
'name': 'Playbook',
'hosts': 'all',
'become': True,
'roles': roles
}
if variables:
playbook_data['vars'] = variables
return yaml.dump([playbook_data], default_flow_style=False, allow_unicode=True)
@staticmethod
async def save_test_run(
db: AsyncSession,
playbook_id: int,
preset_name: Optional[str],
status: str,
user: Optional[str] = None,
output: Optional[str] = None,
error: Optional[str] = None,
returncode: Optional[int] = None
) -> PlaybookTestRun:
"""Сохранение результата тестирования playbook"""
test_run = PlaybookTestRun(
playbook_id=playbook_id,
preset_name=preset_name,
status=status,
output=output,
error=error,
returncode=returncode,
user=user
)
db.add(test_run)
await db.commit()
await db.refresh(test_run)
return test_run
@staticmethod
async def save_deployment(
db: AsyncSession,
playbook_id: int,
inventory: Optional[str],
hosts: Optional[List[str]],
status: str,
user: Optional[str] = None,
output: Optional[str] = None,
error: Optional[str] = None,
returncode: Optional[int] = None
) -> PlaybookDeployment:
"""Сохранение результата деплоя playbook"""
deployment = PlaybookDeployment(
playbook_id=playbook_id,
inventory=inventory,
hosts=hosts or [],
status=status,
output=output,
error=error,
returncode=returncode,
user=user
)
db.add(deployment)
await db.commit()
await db.refresh(deployment)
return deployment

View File

@@ -0,0 +1,364 @@
"""
Сервис для работы с preset'ами Molecule (работа с БД)
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.models.database import Preset
class PresetService:
"""Сервис для управления preset'ами из БД"""
@staticmethod
async def get_all_presets(db: AsyncSession, category: Optional[str] = None) -> List[Dict]:
"""Получение списка всех preset'ов из БД"""
query = select(Preset)
if category:
query = query.where(Preset.category == category)
result = await db.execute(query.order_by(Preset.category, Preset.name))
presets = result.scalars().all()
presets_list = []
for preset in presets:
# Парсинг YAML для получения информации
try:
data = yaml.safe_load(preset.content) if preset.content else {}
hosts = data.get("hosts", [])
images = data.get("images", {})
# Получение групп
groups = set()
for host in hosts:
host_groups = host.get("groups", [])
if isinstance(host_groups, list):
groups.update(host_groups)
presets_list.append({
"name": preset.name,
"category": preset.category,
"description": preset.description,
"hosts_count": len(hosts),
"hosts": hosts,
"images": list(images.keys()) if images else [],
"groups": sorted(list(groups)),
"id": preset.id
})
except Exception as e:
presets_list.append({
"name": preset.name,
"category": preset.category,
"description": preset.description,
"error": str(e),
"id": preset.id
})
return presets_list
@staticmethod
async def get_preset(db: AsyncSession, preset_name: str, category: str = "main") -> Optional[Preset]:
"""Получение preset'а по имени из БД"""
result = await db.execute(
select(Preset).where(Preset.name == preset_name, Preset.category == category)
)
return result.scalar_one_or_none()
@staticmethod
async def get_preset_dict(db: AsyncSession, preset_name: str, category: str = "main") -> Dict:
"""Получение preset'а в виде словаря"""
preset = await PresetService.get_preset(db, preset_name, category)
if not preset:
raise ValueError(f"Preset '{preset_name}' не найден")
try:
data = yaml.safe_load(preset.content) if preset.content else {}
except Exception as e:
raise ValueError(f"Ошибка парсинга YAML preset'а: {str(e)}")
return {
"name": preset.name,
"category": preset.category,
"path": f"presets/{preset.category}/{preset.name}.yml", # Виртуальный путь
"content": preset.content,
"data": data,
"description": preset.description,
"id": preset.id
}
@staticmethod
async def create_preset(
db: AsyncSession,
preset_name: str,
description: str = "",
hosts: List[Dict] = None,
category: str = "main",
created_by: Optional[str] = None
) -> Preset:
"""Создание нового preset'а в БД"""
# Проверка существования
existing = await PresetService.get_preset(db, preset_name, category)
if existing:
raise ValueError(f"Preset '{preset_name}' уже существует")
# Генерация содержимого preset'а
content = PresetService._generate_preset_content(description, hosts or [])
# Парсинг для извлечения данных
data = yaml.safe_load(content)
preset = Preset(
name=preset_name,
category=category,
description=description,
content=content,
docker_network=data.get("docker_network", "labnet"),
hosts=data.get("hosts", []),
images=data.get("images", {}),
systemd_defaults=data.get("systemd_defaults", {}),
kind_clusters=data.get("kind_clusters", []),
created_by=created_by
)
db.add(preset)
await db.commit()
await db.refresh(preset)
return preset
@staticmethod
async def update_preset(
db: AsyncSession,
preset_name: str,
content: str,
category: str = "main",
updated_by: Optional[str] = None
) -> Preset:
"""Обновление preset'а (старый метод - через YAML)"""
preset = await PresetService.get_preset(db, preset_name, category)
if not preset:
raise ValueError(f"Preset '{preset_name}' не найден")
# Валидация YAML
try:
data = yaml.safe_load(content)
except yaml.YAMLError as e:
raise ValueError(f"Неверный формат YAML: {str(e)}")
# Извлечение описания из комментария
description = None
for line in content.split('\n'):
if line.strip().startswith('#description:'):
description = line.split('#description:')[1].strip()
break
preset.content = content
preset.description = description or preset.description
preset.docker_network = data.get("docker_network", preset.docker_network)
preset.hosts = data.get("hosts", preset.hosts)
preset.images = data.get("images", preset.images)
preset.systemd_defaults = data.get("systemd_defaults", preset.systemd_defaults)
preset.kind_clusters = data.get("kind_clusters", preset.kind_clusters)
preset.updated_by = updated_by
await db.commit()
await db.refresh(preset)
return preset
@staticmethod
async def update_preset_from_form(
db: AsyncSession,
preset_name: str,
description: str = "",
category: str = "main",
docker_network: str = "labnet",
hosts: List[Dict] = None,
images: Dict = None,
systemd_defaults: Dict = None,
kind_clusters: List = None,
updated_by: Optional[str] = None
) -> Preset:
"""Обновление preset'а из формы"""
preset = await PresetService.get_preset(db, preset_name, category)
if not preset:
raise ValueError(f"Preset '{preset_name}' не найден")
# Генерация содержимого из формы
content = PresetService._generate_preset_content_from_form(
description=description,
docker_network=docker_network,
hosts=hosts or [],
images=images or {},
systemd_defaults=systemd_defaults or {},
kind_clusters=kind_clusters or []
)
# Парсинг для обновления полей
data = yaml.safe_load(content)
preset.content = content
preset.description = description
preset.docker_network = docker_network
preset.hosts = hosts or []
preset.images = images or {}
preset.systemd_defaults = systemd_defaults or {}
preset.kind_clusters = kind_clusters or []
preset.updated_by = updated_by
await db.commit()
await db.refresh(preset)
return preset
@staticmethod
async def delete_preset(db: AsyncSession, preset_name: str, category: str = "main") -> bool:
"""Удаление preset'а из БД"""
preset = await PresetService.get_preset(db, preset_name, category)
if not preset:
raise ValueError(f"Preset '{preset_name}' не найден")
await db.delete(preset)
await db.commit()
return True
@staticmethod
def _generate_preset_content_from_form(
description: str = "",
docker_network: str = "labnet",
hosts: List[Dict] = None,
images: Dict = None,
systemd_defaults: Dict = None,
kind_clusters: List = None
) -> str:
"""Генерация содержимого preset'а из формы"""
# Базовые образы по умолчанию
default_images = {
"alt9": "inecs/ansible-lab:alt9-latest",
"alt10": "inecs/ansible-lab:alt10-latest",
"astra": "inecs/ansible-lab:astra-linux-latest",
"rhel": "inecs/ansible-lab:rhel-latest",
"centos7": "inecs/ansible-lab:centos7-latest",
"centos8": "inecs/ansible-lab:centos8-latest",
"centos9": "inecs/ansible-lab:centos9-latest",
"alma": "inecs/ansible-lab:alma-latest",
"rocky": "inecs/ansible-lab:rocky-latest",
"redos": "inecs/ansible-lab:redos-latest",
"ubuntu20": "inecs/ansible-lab:ubuntu20-latest",
"ubuntu22": "inecs/ansible-lab:ubuntu22-latest",
"ubuntu24": "inecs/ansible-lab:ubuntu24-latest",
"debian9": "inecs/ansible-lab:debian9-latest",
"debian10": "inecs/ansible-lab:debian10-latest",
"debian11": "inecs/ansible-lab:debian11-latest",
"debian12": "inecs/ansible-lab:debian12-latest"
}
# Объединяем с переданными образами
final_images = {**default_images, **(images or {})}
# Systemd defaults по умолчанию
default_systemd = {
"privileged": True,
"command": "/sbin/init",
"volumes": ["/sys/fs/cgroup:/sys/fs/cgroup:rw"],
"tmpfs": ["/run", "/run/lock"],
"capabilities": ["SYS_ADMIN"]
}
# Объединяем с переданными настройками
final_systemd = {**default_systemd, **(systemd_defaults or {})}
# Заголовок
content = f"""---
#description: {description or "Пользовательский preset"}
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
docker_network: {docker_network}
generated_inventory: "{{{{ molecule_ephemeral_directory }}}}/inventory/hosts.ini"
# systemd-ready образы
images:
"""
# Добавление образов
for key, value in sorted(final_images.items()):
content += f" {key}: \"{value}\"\n"
# Systemd defaults
content += "\nsystemd_defaults:\n"
content += f" privileged: {str(final_systemd.get('privileged', True)).lower()}\n"
content += f" command: \"{final_systemd.get('command', '/sbin/init')}\"\n"
if final_systemd.get('volumes'):
content += " volumes:\n"
for vol in final_systemd['volumes']:
content += f" - \"{vol}\"\n"
if final_systemd.get('tmpfs'):
content += " tmpfs: ["
content += ", ".join([f'"{v}"' for v in final_systemd['tmpfs']])
content += "]\n"
if final_systemd.get('capabilities'):
content += " capabilities: ["
content += ", ".join([f'"{v}"' for v in final_systemd['capabilities']])
content += "]\n"
# Хосты
content += "\nhosts:\n"
if hosts:
for host in hosts:
content += f" - name: {host.get('name', 'host1')}\n"
content += f" family: {host.get('family', 'ubuntu22')}\n"
# Группы
host_groups = host.get('groups', [])
if isinstance(host_groups, str):
host_groups = [g.strip() for g in host_groups.split(',') if g.strip()]
if host_groups:
content += " groups: ["
content += ", ".join([f'"{g}"' for g in host_groups])
content += "]\n"
# Дополнительные поля хоста
if host.get('type'):
content += f" type: {host['type']}\n"
if host.get('supported_platforms'):
platforms = host['supported_platforms']
if isinstance(platforms, str):
platforms = [p.strip() for p in platforms.split(',') if p.strip()]
content += " supported_platforms: ["
content += ", ".join([f'"{p}"' for p in platforms])
content += "]\n"
if host.get('publish'):
content += f" publish: {host['publish']}\n"
else:
# Хост по умолчанию
content += """ - name: u1
family: ubuntu22
groups: [test]
"""
# Kind clusters (для k8s preset'ов)
if kind_clusters:
content += "\nkind_clusters:\n"
for cluster in kind_clusters:
content += f" - {cluster}\n"
return content
@staticmethod
def _generate_preset_content(description: str, hosts: List[Dict]) -> str:
"""Генерация содержимого preset'а"""
return PresetService._generate_preset_content_from_form(
description=description,
docker_network="labnet",
hosts=hosts,
images=None,
systemd_defaults=None,
kind_clusters=None
)

View File

@@ -0,0 +1,577 @@
"""
Сервис для работы с ролями
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from pathlib import Path
from typing import Dict, List, Optional
import yaml
import json
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, and_, func
from app.core.config import settings
from app.models.database import Role
from app.core.make_executor import MakeExecutor
import logging
logger = logging.getLogger(__name__)
class RoleService:
"""Сервис для управления ролями"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
# Папка для хранения ролей (для экспорта и работы с файлами)
self.roles_dir_alembic = Path(__file__).parent.parent / "alembic" / "roles"
self.roles_dir_alembic.mkdir(exist_ok=True)
self.executor = MakeExecutor()
@staticmethod
async def get_role(db: AsyncSession, role_id: int) -> Optional[Role]:
"""Получение роли по ID"""
result = await db.execute(select(Role).where(Role.id == role_id))
return result.scalar_one_or_none()
@staticmethod
async def get_role_by_name(db: AsyncSession, name: str) -> Optional[Role]:
"""Получение роли по имени"""
result = await db.execute(select(Role).where(Role.name == name))
return result.scalar_one_or_none()
@staticmethod
async def list_roles(
db: AsyncSession,
user_id: Optional[int] = None,
is_global: Optional[bool] = None,
is_personal: Optional[bool] = None,
groups: Optional[List[str]] = None,
status: Optional[str] = None,
search: Optional[str] = None,
page: int = 1,
per_page: int = 10
) -> tuple[List[Role], int]:
"""
Список ролей с фильтрацией и пагинацией
Args:
db: Сессия БД
user_id: ID пользователя (для фильтрации личных ролей)
is_global: Фильтр по глобальным ролям
is_personal: Фильтр по личным ролям
groups: Список групп пользователя (для фильтрации групповых ролей)
status: Фильтр по статусу
search: Поиск по имени/описанию
page: Номер страницы
per_page: Количество на странице
Returns:
Кортеж (список ролей, общее количество)
"""
query = select(Role)
# Фильтры доступа
conditions = []
# Глобальные роли доступны всем
if is_global is None or is_global:
conditions.append(Role.is_global == True)
# Личные роли доступны только владельцу
if user_id:
conditions.append(
and_(
Role.is_personal == True,
Role.user_id == user_id
)
)
# Групповые роли доступны пользователям из соответствующих групп
if groups:
for group in groups:
conditions.append(
and_(
Role.is_global == False,
Role.is_personal == False,
Role.groups.contains([group])
)
)
if conditions:
query = query.where(or_(*conditions))
# Фильтр по статусу
if status:
query = query.where(Role.status == status)
else:
query = query.where(Role.status == "active")
# Поиск
if search:
search_pattern = f"%{search}%"
query = query.where(
or_(
Role.name.ilike(search_pattern),
Role.description.ilike(search_pattern)
)
)
# Подсчет общего количества
count_query = select(func.count()).select_from(query.subquery())
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# Пагинация
offset = (page - 1) * per_page
query = query.order_by(Role.name).offset(offset).limit(per_page)
result = await db.execute(query)
roles = result.scalars().all()
return roles, total
@staticmethod
async def create_role(
db: AsyncSession,
name: str,
template: str = "default",
description: str = "",
platforms: List[str] = None,
variables: List[Dict] = None,
is_global: bool = True,
is_personal: bool = False,
groups: Optional[List[str]] = None,
user_id: Optional[int] = None,
created_by: Optional[str] = None
) -> Role:
"""
Создание новой роли в БД
Args:
db: Сессия БД
name: Имя роли
template: Тип шаблона (default, service, package, config, etc.)
description: Описание роли
platforms: Список поддерживаемых платформ
variables: Список переменных для defaults/main.yml
is_global: Глобальная роль (доступна всем)
is_personal: Личная роль пользователя
groups: Список групп, которым доступна роль
user_id: ID пользователя-владельца (если is_personal=True)
created_by: Имя пользователя, создавшего роль
Returns:
Созданная роль
"""
# Проверка существования роли
existing = await RoleService.get_role_by_name(db, name)
if existing:
raise ValueError(f"Роль '{name}' уже существует")
# Генерация содержимого роли
role_content = RoleService._generate_role_content(name, template, variables or [])
# Генерация метаданных
author = "Сергей Антропов"
galaxy_info = {
"galaxy_info": {
"author": author,
"description": description or f"Роль {name}",
"platforms": RoleService._format_platforms(platforms or []),
"company": "https://devops.org.ru",
"license": "MIT",
"min_ansible_version": "2.9"
}
}
# Создание роли
role = Role(
name=name,
description=description or f"Роль {name}",
content=role_content,
is_global=is_global,
is_personal=is_personal,
groups=groups if groups else None,
user_id=user_id if is_personal else None,
author=author,
platforms=platforms if platforms else None,
galaxy_info=galaxy_info,
status="active",
created_by=created_by,
updated_by=created_by
)
db.add(role)
await db.commit()
await db.refresh(role)
# Экспорт роли в файловую систему для совместимости
await RoleService.export_role_to_filesystem(role)
return role
@staticmethod
async def update_role(
db: AsyncSession,
role_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
content: Optional[Dict] = None,
is_global: Optional[bool] = None,
is_personal: Optional[bool] = None,
groups: Optional[List[str]] = None,
user_id: Optional[int] = None,
updated_by: Optional[str] = None
) -> Optional[Role]:
"""Обновление роли"""
role = await RoleService.get_role(db, role_id)
if not role:
return None
if name is not None:
# Проверка уникальности имени
existing = await RoleService.get_role_by_name(db, name)
if existing and existing.id != role_id:
raise ValueError(f"Роль '{name}' уже существует")
role.name = name
if description is not None:
role.description = description
if content is not None:
role.content = content
if is_global is not None:
role.is_global = is_global
if is_personal is not None:
role.is_personal = is_personal
if is_personal and user_id:
role.user_id = user_id
elif not is_personal:
role.user_id = None
if groups is not None:
role.groups = groups
if updated_by:
role.updated_by = updated_by
role.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(role)
# Экспорт обновленной роли в файловую систему
await RoleService.export_role_to_filesystem(role)
return role
@staticmethod
async def update_role_file(
db: AsyncSession,
role_id: int,
file_path: str,
content: str,
updated_by: Optional[str] = None
) -> Optional[Role]:
"""Обновление конкретного файла роли"""
role = await RoleService.get_role(db, role_id)
if not role:
return None
# Обновляем содержимое роли
role_content = role.content if isinstance(role.content, dict) else {}
role_content[file_path] = content
role.content = role_content
if updated_by:
role.updated_by = updated_by
role.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(role)
# Экспорт обновленной роли в файловую систему
await RoleService.export_role_to_filesystem(role)
return role
@staticmethod
async def delete_role(db: AsyncSession, role_id: int) -> bool:
"""Удаление роли"""
role = await RoleService.get_role(db, role_id)
if not role:
return False
await db.delete(role)
await db.commit()
# Удаление из файловой системы
role_dir = RoleService().roles_dir_alembic / role.name
if role_dir.exists():
import shutil
shutil.rmtree(role_dir)
return True
@staticmethod
async def export_role_to_filesystem(role: Role) -> Path:
"""
Экспорт роли из БД в файловую систему
Args:
role: Роль из БД
Returns:
Путь к директории роли
"""
service = RoleService()
role_dir = service.roles_dir_alembic / role.name
role_dir.mkdir(exist_ok=True)
# Записываем все файлы роли
role_content = role.content if isinstance(role.content, dict) else {}
for file_path, content in role_content.items():
target_file = role_dir / file_path
target_file.parent.mkdir(parents=True, exist_ok=True)
target_file.write_text(content, encoding='utf-8')
return role_dir
@staticmethod
def _generate_role_content(role_name: str, template: str, variables: List[Dict]) -> Dict[str, str]:
"""Генерация содержимого роли в виде словаря {file_path: content}"""
content = {}
# Tasks
content["tasks/main.yml"] = RoleService._generate_tasks_content(role_name, template)
# Defaults
content["defaults/main.yml"] = RoleService._generate_defaults_content(role_name, variables)
# Handlers
content["handlers/main.yml"] = RoleService._generate_handlers_content(role_name)
# Meta
content["meta/main.yml"] = RoleService._generate_meta_content(role_name, "", [])
# README
content["README.md"] = RoleService._generate_readme_content(role_name, "")
return content
@staticmethod
def _generate_tasks_content(role_name: str, template: str) -> str:
"""Генерация содержимого tasks/main.yml"""
base_content = f"""---
# Задачи для роли {role_name}
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
- name: Пример задачи для роли {role_name}
debug:
msg: "Роль {role_name} выполнена"
"""
templates_content = {
"service": f"""---
# Задачи для роли {role_name} (сервис)
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
- name: Установка пакетов
package:
name: "{{{{ {role_name}_packages | default([]) }}}}"
state: present
when: {role_name}_enabled | default(true)
- name: Настройка конфигурации
template:
src: {role_name}.conf.j2
dest: /etc/{role_name}/{role_name}.conf
notify: restart {role_name}
- name: Запуск сервиса
systemd:
name: {role_name}
enabled: true
state: started
when: {role_name}_enabled | default(true)
""",
"package": f"""---
# Задачи для роли {role_name} (пакеты)
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
- name: Установка пакетов
package:
name: "{{{{ {role_name}_packages }}}}"
state: present
when: {role_name}_enabled | default(true)
""",
"config": f"""---
# Задачи для роли {role_name} (конфигурация)
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
- name: Создание директорий
file:
path: "{{{{ item }}}}"
state: directory
mode: '0755'
loop: "{{{{ {role_name}_directories | default([]) }}}}"
when: {role_name}_enabled | default(true)
- name: Копирование конфигурационных файлов
copy:
src: "{{{{ item.src }}}}"
dest: "{{{{ item.dest }}}}"
mode: '0644'
loop: "{{{{ {role_name}_config_files | default([]) }}}}"
when: {role_name}_enabled | default(true)
"""
}
return templates_content.get(template, base_content)
@staticmethod
def _generate_defaults_content(role_name: str, variables: List[Dict]) -> str:
"""Генерация содержимого defaults/main.yml"""
content = f"""---
# Переменные по умолчанию для роли {role_name}
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
{role_name}_enabled: true
"""
for var in variables:
var_name = var.get("name", "")
var_value = var.get("value", "")
var_type = var.get("type", "string")
if var_type == "bool":
content += f"{var_name}: {var_value.lower()}\n"
elif var_type == "int":
content += f"{var_name}: {var_value}\n"
elif var_type == "list":
content += f"{var_name}: []\n"
elif var_type == "dict":
content += f"{var_name}: {{}}\n"
else:
content += f"{var_name}: \"{var_value}\"\n"
return content
@staticmethod
def _generate_handlers_content(role_name: str) -> str:
"""Генерация содержимого handlers/main.yml"""
return f"""---
# Обработчики для роли {role_name}
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
- name: restart {role_name}
systemd:
name: {role_name}
state: restarted
when: ansible_facts['service_mgr'] == 'systemd'
"""
@staticmethod
def _generate_meta_content(role_name: str, description: str, platforms: List[str]) -> str:
"""Генерация содержимого meta/main.yml"""
platform_map = {
"ubuntu": {"name": "Ubuntu", "versions": ["focal", "jammy", "noble"]},
"debian": {"name": "Debian", "versions": ["bullseye", "bookworm"]},
"centos": {"name": "CentOS", "versions": ["8", "9"]},
"rhel": {"name": "RHEL", "versions": ["8", "9"]},
"almalinux": {"name": "AlmaLinux", "versions": ["8", "9"]},
"rocky": {"name": "Rocky", "versions": ["8", "9"]},
}
platforms_yaml = []
for platform in platforms:
if platform in platform_map:
platforms_yaml.append(platform_map[platform])
if not platforms_yaml:
platforms_yaml = [
{"name": "Ubuntu", "versions": ["focal", "jammy"]},
{"name": "Debian", "versions": ["bullseye", "bookworm"]},
]
return f"""---
galaxy_info:
author: Сергей Антропов
description: {description or f"Роль {role_name}"}
company: https://devops.org.ru
license: MIT
min_ansible_version: "2.9"
platforms:
{yaml.dump(platforms_yaml, default_flow_style=False, indent=4, allow_unicode=True)}
galaxy_tags:
- {role_name}
dependencies: []
"""
@staticmethod
def _generate_readme_content(role_name: str, description: str) -> str:
"""Генерация содержимого README.md"""
return f"""# Роль {role_name}
{description or f"Роль для настройки и конфигурации {role_name}."}
## Описание
{description or "Описание роли"}
## Требования
- Ansible >= 2.9
- Поддерживаемые ОС: Ubuntu, Debian, CentOS, RHEL, AlmaLinux, Rocky Linux
## Переменные
| Переменная | По умолчанию | Описание |
|------------|--------------|----------|
| `{role_name}_enabled` | `true` | Включить роль |
## Примеры использования
```yaml
- hosts: all
roles:
- {role_name}
```
## Автор
Сергей Антропов - https://devops.org.ru
"""
@staticmethod
def _format_platforms(platforms: List[str]) -> List[Dict]:
"""Форматирование списка платформ для Galaxy"""
platform_map = {
"ubuntu": {"name": "Ubuntu", "versions": ["focal", "jammy", "noble"]},
"debian": {"name": "Debian", "versions": ["bullseye", "bookworm"]},
"centos": {"name": "CentOS", "versions": ["8", "9"]},
"rhel": {"name": "RHEL", "versions": ["8", "9"]},
"almalinux": {"name": "AlmaLinux", "versions": ["8", "9"]},
"rocky": {"name": "Rocky", "versions": ["8", "9"]},
}
result = []
for platform in platforms:
if platform in platform_map:
result.append(platform_map[platform])
return result if result else [
{"name": "Ubuntu", "versions": ["focal", "jammy"]},
{"name": "Debian", "versions": ["bullseye", "bookworm"]},
]

View File

@@ -0,0 +1,89 @@
"""
Сервис для работы с пользователями
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.models.user import User
from app.auth.security import get_password_hash, verify_password
from typing import Optional
import logging
logger = logging.getLogger(__name__)
class UserService:
"""Сервис для работы с пользователями"""
@staticmethod
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
"""Получение пользователя по имени"""
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
"""Получение пользователя по ID"""
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
@staticmethod
async def create_user(
db: AsyncSession,
username: str,
password: str,
is_superuser: bool = False
) -> User:
"""Создание нового пользователя"""
hashed_password = get_password_hash(password)
user = User(
username=username,
hashed_password=hashed_password,
is_active=True,
is_superuser=is_superuser
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@staticmethod
async def update_password(
db: AsyncSession,
user: User,
new_password: str
) -> User:
"""Обновление пароля пользователя"""
user.hashed_password = get_password_hash(new_password)
await db.commit()
await db.refresh(user)
return user
@staticmethod
async def verify_user_password(user: User, password: str) -> bool:
"""Проверка пароля пользователя"""
return verify_password(password, user.hashed_password)
@staticmethod
async def ensure_admin_user(db: AsyncSession) -> User:
"""Создание пользователя admin по умолчанию, если его нет"""
admin_user = await UserService.get_user_by_username(db, "admin")
if not admin_user:
logger.info("Создание пользователя admin по умолчанию")
admin_user = await UserService.create_user(
db,
username="admin",
password="admin",
is_superuser=True
)
else:
# Проверяем, что пароль правильно хеширован (bcrypt хеш имеет длину 60 символов)
if not admin_user.hashed_password or len(admin_user.hashed_password) < 50:
# Если пароль не хеширован или хеш неправильный, обновляем его
logger.info("Обновление пароля пользователя admin")
admin_user = await UserService.update_password(db, admin_user, "admin")
return admin_user

View File

@@ -0,0 +1,248 @@
"""
Сервис для работы с Ansible Vault
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import subprocess
from pathlib import Path
from typing import Dict, Optional
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class VaultService:
"""Сервис для работы с Ansible Vault"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
self.vault_dir = self.project_root / "vault"
self.vault_password_file = self.vault_dir / ".vault"
def _get_vault_password(self) -> Optional[str]:
"""Получение пароля Vault из файла"""
if self.vault_password_file.exists():
try:
return self.vault_password_file.read_text().strip()
except Exception as e:
logger.error(f"Ошибка чтения пароля Vault: {e}")
return None
def encrypt_string(self, plaintext: str, vault_id: str = "default") -> Dict:
"""
Шифрование строки через Ansible Vault
Args:
plaintext: Текст для шифрования
vault_id: ID vault (по умолчанию default)
Returns:
Результат шифрования
"""
password = self._get_vault_password()
if not password:
raise ValueError("Пароль Vault не найден. Инициализируйте Vault сначала.")
try:
# Создаем временный файл с паролем
import tempfile
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(password)
temp_password_file = f.name
try:
# Шифруем строку
result = subprocess.run(
[
"ansible-vault",
"encrypt_string",
"--vault-password-file", temp_password_file,
"--vault-id", vault_id,
plaintext
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
encrypted = result.stdout.strip()
return {
"success": True,
"encrypted": encrypted,
"plaintext": plaintext
}
else:
raise ValueError(f"Ошибка шифрования: {result.stderr}")
finally:
# Удаляем временный файл
Path(temp_password_file).unlink()
except Exception as e:
logger.error(f"Ошибка при шифровании: {e}")
raise ValueError(f"Ошибка шифрования: {str(e)}")
def decrypt_string(self, encrypted: str) -> Dict:
"""
Расшифровка строки из Ansible Vault
Args:
encrypted: Зашифрованный текст
Returns:
Результат расшифровки
"""
password = self._get_vault_password()
if not password:
raise ValueError("Пароль Vault не найден")
try:
import tempfile
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(password)
temp_password_file = f.name
try:
# Создаем временный файл с зашифрованным текстом
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yml') as f:
f.write(encrypted)
temp_vault_file = f.name
try:
# Расшифровываем
result = subprocess.run(
[
"ansible-vault",
"decrypt",
"--vault-password-file", temp_password_file,
temp_vault_file
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
decrypted = Path(temp_vault_file).read_text().strip()
# Удаляем маркеры Vault
decrypted = decrypted.replace("!vault |", "").strip()
return {
"success": True,
"decrypted": decrypted
}
else:
raise ValueError(f"Ошибка расшифровки: {result.stderr}")
finally:
Path(temp_vault_file).unlink()
finally:
Path(temp_password_file).unlink()
except Exception as e:
logger.error(f"Ошибка при расшифровке: {e}")
raise ValueError(f"Ошибка расшифровки: {str(e)}")
def view_vault_file(self, file_path: Path) -> str:
"""
Просмотр содержимого Vault файла
Args:
file_path: Путь к Vault файлу
Returns:
Расшифрованное содержимое
"""
password = self._get_vault_password()
if not password:
raise ValueError("Пароль Vault не найден")
try:
import tempfile
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(password)
temp_password_file = f.name
try:
result = subprocess.run(
[
"ansible-vault",
"view",
"--vault-password-file", temp_password_file,
str(file_path)
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
return result.stdout
else:
raise ValueError(f"Ошибка просмотра файла: {result.stderr}")
finally:
Path(temp_password_file).unlink()
except Exception as e:
logger.error(f"Ошибка при просмотре файла: {e}")
raise ValueError(f"Ошибка просмотра: {str(e)}")
def edit_vault_file(self, file_path: Path, content: str) -> Dict:
"""
Редактирование Vault файла
Args:
file_path: Путь к Vault файлу
content: Новое содержимое
Returns:
Результат редактирования
"""
password = self._get_vault_password()
if not password:
raise ValueError("Пароль Vault не найден")
try:
# Создаем временный файл с новым содержимым
import tempfile
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yml') as f:
f.write(content)
temp_content_file = f.name
try:
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(password)
temp_password_file = f.name
try:
# Шифруем содержимое
result = subprocess.run(
[
"ansible-vault",
"encrypt",
"--vault-password-file", temp_password_file,
temp_content_file
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Копируем зашифрованный файл
encrypted_content = Path(temp_content_file).read_text()
file_path.write_text(encrypted_content)
return {
"success": True,
"message": "Файл успешно обновлен"
}
else:
raise ValueError(f"Ошибка шифрования: {result.stderr}")
finally:
Path(temp_password_file).unlink()
finally:
Path(temp_content_file).unlink()
except Exception as e:
logger.error(f"Ошибка при редактировании файла: {e}")
raise ValueError(f"Ошибка редактирования: {str(e)}")
def is_vault_encrypted(self, content: str) -> bool:
"""Проверка, зашифрован ли контент через Vault"""
return "$ANSIBLE_VAULT" in content or "!vault |" in content