- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""
|
||
Сервис для работы с 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
|
||
)
|