feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
133
app/services/deployment_service.py
Normal file
133
app/services/deployment_service.py
Normal 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)
|
||||
418
app/services/docker_build_service.py
Normal file
418
app/services/docker_build_service.py
Normal 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"
|
||||
236
app/services/docker_builder_service.py
Normal file
236
app/services/docker_builder_service.py
Normal 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()
|
||||
168
app/services/dockerfile_service.py
Normal file
168
app/services/dockerfile_service.py
Normal 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
|
||||
219
app/services/export_service.py
Normal file
219
app/services/export_service.py
Normal 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
|
||||
226
app/services/history_service.py
Normal file
226
app/services/history_service.py
Normal 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()
|
||||
233
app/services/import_service.py
Normal file
233
app/services/import_service.py
Normal 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)
|
||||
}
|
||||
180
app/services/lint_service.py
Normal file
180
app/services/lint_service.py
Normal 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"
|
||||
187
app/services/playbook_service.py
Normal file
187
app/services/playbook_service.py
Normal 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
|
||||
364
app/services/preset_service.py
Normal file
364
app/services/preset_service.py
Normal 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
|
||||
)
|
||||
577
app/services/role_service.py
Normal file
577
app/services/role_service.py
Normal 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"]},
|
||||
]
|
||||
89
app/services/user_service.py
Normal file
89
app/services/user_service.py
Normal 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
|
||||
248
app/services/vault_service.py
Normal file
248
app/services/vault_service.py
Normal 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
|
||||
Reference in New Issue
Block a user