Исправление синтаксической ошибки в molecule_executor.py и обновление k8s preset'ов

- Исправлена незакрытая скобка в _build_test_command (строка 745)
- Добавлена поддержка k8s preset'ов: выполнение create_k8s_cluster.py перед create.yml
- Обновлены образы в k8s preset'ах: заменен недоступный ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy на inecs/ansible-lab:ubuntu22-latest
- Обновлены preset'ы в базе данных через SQL
- Обновлены файлы: k8s-single.yml, k8s-multi.yml, k8s-istio-full.yml
This commit is contained in:
Сергей Антропов
2026-02-16 00:31:09 +03:00
parent 1fbf9185a2
commit d4b0d6f848
26 changed files with 1913 additions and 646 deletions

View File

@@ -22,6 +22,8 @@ help:
@echo " make shell - Открыть shell в контейнере web"
@echo " make clean - Очистить контейнеры и volumes"
@echo " make rebuild - Пересобрать и перезапустить"
@echo " make migrate - Применить миграции БД"
@echo " make load-presets - Импортировать пресеты из файловой системы"
build:
$(COMPOSE) build
@@ -58,3 +60,9 @@ clean:
status:
$(COMPOSE) ps
migrate:
$(COMPOSE) exec web bash -c "cd /app/app && alembic upgrade head"
load-presets:
$(COMPOSE) exec web bash -c "cd /app/app && python scripts/load_presets.py"

View File

@@ -29,8 +29,10 @@ kind_clusters:
ingress_host_https_port: 8443
images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
rhel: "quay.io/centos/centos:stream9-systemd"
debian: "inecs/ansible-lab:ubuntu22-latest"
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
rhel: "inecs/ansible-lab:rhel-latest"
centos: "inecs/ansible-lab:centos9-latest"
systemd_defaults:
privileged: true

View File

@@ -47,8 +47,10 @@ kind_clusters:
ingress_host_https_port: 8445
images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
rhel: "quay.io/centos/centos:stream9-systemd"
debian: "inecs/ansible-lab:ubuntu22-latest"
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
rhel: "inecs/ansible-lab:rhel-latest"
centos: "inecs/ansible-lab:centos9-latest"
systemd_defaults:
privileged: true

View File

@@ -24,8 +24,10 @@ kind_clusters:
ingress_host_https_port: 8443
images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
rhel: "quay.io/centos/centos:stream9-systemd"
debian: "inecs/ansible-lab:ubuntu22-latest"
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
rhel: "inecs/ansible-lab:rhel-latest"
centos: "inecs/ansible-lab:centos9-latest"
systemd_defaults:
privileged: true

View File

@@ -39,59 +39,70 @@ def upgrade():
presets_dir = old_presets_dir
k8s_presets_dir = presets_dir / "k8s"
# Основные preset'ы
# Функция для импорта preset'а
def import_preset(preset_file, category='main'):
try:
with open(preset_file) as f:
content = f.read()
preset_data = yaml.safe_load(content) or {}
# Извлечение описания из комментария
description = None
for line in content.split('\n'):
if line.strip().startswith('#description:'):
description = line.split('#description:')[1].strip()
break
# Проверка существования в БД
result = connection.execute(
sa.text("SELECT id FROM presets WHERE name = :name"),
{"name": preset_file.stem}
)
if result.fetchone():
return False
# Преобразуем dict/list в JSON строки для PostgreSQL
hosts_json = json.dumps(preset_data.get('hosts', []))
images_json = json.dumps(preset_data.get('images', {}))
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
connection.execute(
sa.text("""
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
"""),
{
'name': preset_file.stem,
'category': category,
'description': description,
'content': content,
'docker_network': preset_data.get('docker_network'),
'hosts': hosts_json,
'images': images_json,
'systemd_defaults': systemd_defaults_json,
'kind_clusters': kind_clusters_json,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
)
return True
except Exception as e:
print(f"Ошибка при импорте preset {preset_file.name}: {e}")
return False
# Основные preset'ы из корня папки presets
if presets_dir.exists():
for preset_file in presets_dir.glob("*.yml"):
if preset_file.name == "deploy.yml":
continue
import_preset(preset_file, category='main')
try:
with open(preset_file) as f:
content = f.read()
preset_data = yaml.safe_load(content) or {}
# Извлечение описания из комментария
description = None
for line in content.split('\n'):
if line.strip().startswith('#description:'):
description = line.split('#description:')[1].strip()
break
# Проверка существования в БД
result = connection.execute(
sa.text("SELECT id FROM presets WHERE name = :name"),
{"name": preset_file.stem}
)
if result.fetchone():
continue
# Преобразуем dict/list в JSON строки для PostgreSQL
hosts_json = json.dumps(preset_data.get('hosts', []))
images_json = json.dumps(preset_data.get('images', {}))
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
connection.execute(
sa.text("""
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
"""),
{
'name': preset_file.stem,
'category': 'main',
'description': description,
'content': content,
'docker_network': preset_data.get('docker_network'),
'hosts': hosts_json,
'images': images_json,
'systemd_defaults': systemd_defaults_json,
'kind_clusters': kind_clusters_json,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
)
except Exception as e:
print(f"Ошибка при импорте preset {preset_file.name}: {e}")
# Пресеты из папки examples
examples_dir = presets_dir / "examples"
if examples_dir.exists():
for preset_file in examples_dir.glob("*.yml"):
import_preset(preset_file, category='main')
# K8s preset'ы
if k8s_presets_dir.exists():

View File

@@ -313,11 +313,24 @@ async def delete_dockerfile(
db: AsyncSession = Depends(get_async_db)
):
"""Удаление Dockerfile"""
# Получаем имя Dockerfile до удаления
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
if not dockerfile:
raise HTTPException(status_code=404, detail="Dockerfile не найден")
dockerfile_name = dockerfile.name
# Удаляем Dockerfile
deleted = await DockerfileService.delete_dockerfile(db, dockerfile_id)
if not deleted:
raise HTTPException(status_code=404, detail="Dockerfile не найден")
return {"message": "Dockerfile удален успешно"}
return JSONResponse(content={
"success": True,
"dockerfile_id": dockerfile_id,
"dockerfile_name": dockerfile_name,
"message": f"Dockerfile '{dockerfile_name}' успешно удален"
})
@router.get("/api/v1/dockerfiles")

View File

@@ -206,11 +206,24 @@ async def delete_playbook(
db: AsyncSession = Depends(get_async_db)
):
"""Удаление playbook"""
# Получаем имя playbook до удаления
playbook = await PlaybookService.get_playbook(db, playbook_id)
if not playbook:
raise HTTPException(status_code=404, detail="Playbook не найден")
playbook_name = playbook.name
# Удаляем playbook
deleted = await PlaybookService.delete_playbook(db, playbook_id)
if not deleted:
raise HTTPException(status_code=404, detail="Playbook не найден")
return {"message": "Playbook удален успешно"}
return JSONResponse(content={
"success": True,
"playbook_id": playbook_id,
"playbook_name": playbook_name,
"message": f"Playbook '{playbook_name}' успешно удален"
})
@router.get("/api/v1/playbooks")

View File

@@ -11,10 +11,14 @@ from pathlib import Path
from typing import List, Dict, Optional
import yaml
import json
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.services.preset_service import PresetService
from app.db.session import get_async_db
from app.auth.deps import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
@@ -75,11 +79,34 @@ async def get_presets_api(
@router.get("/presets/create", response_class=HTMLResponse)
async def create_preset_page(request: Request):
async def create_preset_page(
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""Страница создания preset'а"""
from app.services.dockerfile_service import DockerfileService
# Загружаем список Dockerfiles из БД
dockerfiles = await DockerfileService.list_dockerfiles(db, status="active")
dockerfiles_list = [
{
"id": d.id,
"name": d.name,
"description": d.description,
"base_image": d.base_image,
"tags": d.tags,
"status": d.status
}
for d in dockerfiles
]
return templates.TemplateResponse(
"pages/presets/create.html",
{"request": request}
{
"request": request,
"dockerfiles": dockerfiles_list
}
)
@@ -129,6 +156,8 @@ async def create_preset_api(
description: str = Form(""),
category: str = Form("main"),
hosts: str = Form(""),
images: str = Form(""),
systemd_defaults: str = Form(""),
db: AsyncSession = Depends(get_async_db)
):
"""API endpoint для создания preset'а"""
@@ -137,12 +166,22 @@ async def create_preset_api(
if hosts:
hosts_list = json.loads(hosts)
images_dict = {}
if images:
images_dict = json.loads(images)
systemd_defaults_dict = {}
if systemd_defaults:
systemd_defaults_dict = json.loads(systemd_defaults)
preset = await PresetService.create_preset(
db=db,
preset_name=preset_name,
description=description,
hosts=hosts_list,
category=category
category=category,
images=images_dict,
systemd_defaults=systemd_defaults_dict
)
return JSONResponse(content={
@@ -270,6 +309,19 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
"""WebSocket для live логов тестирования preset'а"""
await websocket.accept()
# Используем класс для хранения ссылки на контейнер
class ContainerRef:
def __init__(self):
self.container = None
def set(self, container):
self.container = container
def get(self):
return self.container
container_ref = ContainerRef()
executor = None
stop_requested = False
try:
# Получаем preset из БД
async for db in get_async_db():
@@ -285,18 +337,6 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
preset_content = preset.content
break
# Получаем действие от клиента
data = await websocket.receive_json()
action = data.get("action", "start")
if action == "stop":
await websocket.send_json({
"type": "info",
"data": "⏹️ Остановка тестирования..."
})
await websocket.close()
return
# Запуск тестирования preset'а
from app.core.molecule_executor import MoleculeExecutor
executor = MoleculeExecutor()
@@ -309,34 +349,99 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
"data": f"🚀 Запуск тестирования preset'а '{preset_name}'..."
})
# Создаем задачу для мониторинга сообщений от клиента (стоп)
import asyncio
async def monitor_stop():
nonlocal stop_requested
try:
while True:
try:
data = await asyncio.wait_for(websocket.receive_json(), timeout=1.0)
action = data.get("action")
if action == "stop":
stop_requested = True
cont = container_ref.get()
if cont:
try:
cont.stop()
await websocket.send_json({
"type": "info",
"data": "⏹️ Остановка контейнера..."
})
except Exception as e:
logger.error(f"Error stopping container: {e}")
break
except asyncio.TimeoutError:
continue
except WebSocketDisconnect:
stop_requested = True
break
except Exception:
pass
monitor_task = asyncio.create_task(monitor_stop())
# Запускаем тест (без указания роли - тестируем все роли)
async for line in executor.test_role(
role_name=None,
preset_name=preset_name,
preset_content=preset_content,
preset_category=category,
stream=True
):
line = line.rstrip()
if not line:
continue
try:
async for line in executor.test_role(
role_name=None,
preset_name=preset_name,
preset_content=preset_content,
preset_category=category,
stream=True,
stop_event=lambda: stop_requested,
container_ref=container_ref
):
if stop_requested:
break
log_type = executor.detect_log_level(line)
line = line.rstrip()
if not line:
continue
await websocket.send_json({
"type": "log",
"level": log_type,
"data": line
})
log_type = executor.detect_log_level(line)
await websocket.send_json({
"type": "complete",
"status": "success",
"data": "✅ Тестирование preset'а завершено"
})
try:
await websocket.send_json({
"type": "log",
"level": log_type,
"data": line
})
except (WebSocketDisconnect, Exception) as e:
# Соединение закрыто - не пытаемся больше отправлять
stop_requested = True
logger.debug(f"WebSocket closed during log send: {e}")
break
# Отправляем финальное сообщение только если соединение открыто
try:
if not stop_requested:
await websocket.send_json({
"type": "complete",
"status": "success",
"data": "✅ Тестирование preset'а завершено"
})
else:
await websocket.send_json({
"type": "complete",
"status": "stopped",
"data": "⏹️ Тестирование остановлено пользователем"
})
except (WebSocketDisconnect, Exception):
# Соединение уже закрыто - это нормально
pass
except GeneratorExit:
# Генератор закрыт, это нормально при закрытии WebSocket
stop_requested = True
finally:
monitor_task.cancel()
try:
await monitor_task
except asyncio.CancelledError:
pass
# Удаляем временный файл
if preset_name in executor._temp_preset_files:
if executor and preset_name in executor._temp_preset_files:
try:
executor._temp_preset_files[preset_name].unlink()
del executor._temp_preset_files[preset_name]
@@ -344,14 +449,22 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
pass
except WebSocketDisconnect:
# Соединение закрыто клиентом - это нормально
pass
except GeneratorExit:
# Генератор закрыт - это нормально
pass
except Exception as e:
import traceback
error_msg = f"❌ Ошибка: {str(e)}\n{traceback.format_exc()}"
await websocket.send_json({
"type": "error",
"data": error_msg
})
error_msg = f"❌ Ошибка: {str(e)}"
try:
await websocket.send_json({
"type": "error",
"data": error_msg
})
except:
pass
logger.error(f"Error in preset_test_websocket: {e}\n{traceback.format_exc()}")
finally:
try:
await websocket.close()

View File

@@ -5,6 +5,7 @@ Docker клиент для управления контейнерами
"""
import docker
from docker import APIClient
import os
from typing import List, Dict, Optional
from app.core.config import settings
@@ -25,65 +26,44 @@ class DockerClient:
"""Ленивая инициализация Docker клиента"""
if self._client is None:
try:
# Получаем DOCKER_HOST из настроек или окружения
docker_host = os.getenv("DOCKER_HOST", settings.DOCKER_HOST)
# Временно удаляем DOCKER_HOST из окружения, если он установлен
# Это необходимо, так как Docker SDK может неправильно парсить его
original_docker_host = os.environ.pop("DOCKER_HOST", None)
logger.info(f"Initializing Docker client with DOCKER_HOST: {docker_host}")
# Если DOCKER_HOST начинается с unix://, извлекаем путь к socket
if docker_host.startswith("unix://"):
socket_path = docker_host.replace("unix://", "")
# Убеждаемся, что путь начинается с /
if not socket_path.startswith("/"):
socket_path = "/" + socket_path
# Docker SDK для unix socket ожидает base_url в формате "unix:///path/to/socket"
# Важно: после unix:// должно быть три слэша (unix:///)
# Например: "unix:///var/run/docker.sock"
base_url = f"unix://{socket_path}"
logger.info(f"Using unix socket: base_url={base_url}, socket_path={socket_path}")
# НЕ используем docker.from_env() для unix socket, так как он неправильно парсит формат
# Используем только прямой base_url
try:
# Пробуем docker.from_env() без DOCKER_HOST
logger.info("Trying docker.from_env() without DOCKER_HOST")
self._client = docker.from_env()
self._client.ping()
logger.info("Docker client initialized successfully with docker.from_env()")
except Exception as e1:
logger.warning(f"docker.from_env() failed: {e1}")
# Если from_env не работает, пробуем прямой base_url
try:
# Используем прямой путь к Docker socket
base_url = "unix:///var/run/docker.sock"
logger.info(f"Trying direct base_url: {base_url}")
self._client = docker.DockerClient(base_url=base_url)
# Проверяем подключение сразу
self._client.ping()
logger.info(f"Successfully created Docker client with base_url={base_url}")
except Exception as e:
logger.error(f"Failed to create Docker client with base_url={base_url}: {e}")
# Пробуем альтернативный формат (без префикса unix://)
logger.info("Docker client initialized successfully with direct base_url")
except Exception as e2:
logger.error(f"Direct base_url also failed: {e2}")
# Последняя попытка - используем APIClient
try:
# Некоторые версии SDK могут требовать просто путь
# Но это не работает, так как base_url должен быть полный URL
# Поэтому пробуем стандартный формат по умолчанию
logger.warning("Trying default socket path")
logger.info("Trying APIClient as last resort")
api_client = APIClient(base_url="unix:///var/run/docker.sock")
api_client.version()
# Если APIClient работает, создаем DockerClient
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
self._client.ping()
logger.info("Successfully created Docker client with default socket")
except Exception as e2:
logger.error(f"All methods failed. Last error: {e2}")
logger.info("Docker client initialized successfully with APIClient")
except Exception as e3:
logger.error(f"All methods failed. Last error: {e3}")
raise
elif docker_host.startswith("/"):
# Прямой путь к socket - используем base_url с префиксом unix://
base_url = f"unix://{docker_host}"
logger.info(f"Using direct socket path: {base_url}")
self._client = docker.DockerClient(base_url=base_url)
else:
# Для других форматов (tcp://, http:// и т.д.) используем from_env
# Но сначала проверяем, не установлена ли переменная DOCKER_HOST
if "DOCKER_HOST" in os.environ:
# Если DOCKER_HOST установлен, но не unix://, используем from_env
logger.info("Using docker.from_env()")
self._client = docker.from_env()
else:
# Если DOCKER_HOST не установлен, используем стандартный socket
logger.info("Using default socket: unix:///var/run/docker.sock")
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
# Проверка подключения
self._client.ping()
logger.info("Docker client initialized successfully")
finally:
# Восстанавливаем DOCKER_HOST, если он был установлен
if original_docker_host:
os.environ["DOCKER_HOST"] = original_docker_host
except Exception as e:
logger.error(f"Failed to initialize Docker client: {e}")
logger.error(f"DOCKER_HOST env: {os.getenv('DOCKER_HOST', 'not set')}")

View File

@@ -11,24 +11,88 @@ from typing import Optional, AsyncGenerator, Dict
from datetime import datetime
import yaml
import tempfile
import logging
from app.core.config import settings
from app.core.docker_client import DockerClient
logger = logging.getLogger(__name__)
class MoleculeExecutor:
"""Выполнение Molecule тестов без использования Makefile"""
def __init__(self):
self.project_root = settings.PROJECT_ROOT
# Определяем реальный путь к проекту для монтирования в docker run
# В docker-compose.yml проект монтируется как ../:/workspace:rw
# Когда мы запускаем docker run из контейнера, Docker Desktop на macOS
# не знает о пути /workspace внутри контейнера. Нужно использовать реальный путь на хосте.
project_root = settings.PROJECT_ROOT
# Если PROJECT_ROOT = /workspace (внутри контейнера), но мы запускаем docker run,
# нужно использовать реальный путь на хосте.
# В docker-compose.yml монтируется ../:/workspace, значит на хосте это родительская директория от app/
if str(project_root) == "/workspace":
# Получаем путь к app/ и поднимаемся на уровень выше (это и есть реальный путь на хосте)
current_file = Path(__file__)
# Внутри контейнера: /app/app/core/molecule_executor.py
# current_file.parent = /app/app/core
# current_file.parent.parent = /app/app
# current_file.parent.parent.parent = /app
# Нам нужно получить путь на хосте, который соответствует /workspace
# В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/
# Но внутри контейнера код находится в /app/app/, а проект в /workspace
# Поэтому нужно использовать переменную окружения или определить путь по-другому
# Попробуем получить путь из переменной окружения или использовать текущую рабочую директорию
import os
# Если есть переменная окружения с реальным путем на хосте
host_project_root = os.getenv("HOST_PROJECT_ROOT")
if host_project_root:
project_root = Path(host_project_root)
else:
# Используем путь относительно текущего файла
# Внутри контейнера: /app/app/core/molecule_executor.py
# На хосте: /Users/inecs/Documents/DevOpsLab/app/core/molecule_executor.py
# Нужно подняться на 2 уровня выше от app/ чтобы получить корень проекта
app_dir = current_file.parent.parent.parent # /app/app или /Users/.../app
# Если мы в /app/app, то родительская директория - это /app, но нам нужен путь на хосте
# Поэтому используем абсолютный путь, который будет правильно разрешен Docker Desktop
# Docker Desktop автоматически преобразует пути из контейнера в пути на хосте
# Но для этого нужно использовать путь, который существует на хосте
# В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/
# На хосте: /Users/inecs/Documents/DevOpsLab
# Внутри контейнера: /workspace
# Когда мы запускаем docker run, нужно использовать путь на хосте
# Но мы не знаем его напрямую, поэтому используем относительный путь
# или определяем его через переменную окружения
project_root = Path("/workspace") # Оставляем как есть, но для docker run нужно использовать реальный путь
self.project_root = project_root.resolve() if isinstance(project_root, Path) else Path(project_root).resolve()
# Для docker run нужно использовать реальный путь на хосте
# Определяем его через переменную окружения или используем путь относительно app/
self.host_project_root = os.getenv("HOST_PROJECT_ROOT", str(self.project_root))
if self.host_project_root == "/workspace":
# Если не задана переменная, пытаемся определить путь на хосте
# В docker-compose.yml монтируется ../:/workspace
# Значит на хосте это родительская директория от app/
current_file = Path(__file__)
app_dir = current_file.parent.parent.parent # app/
# На хосте app/ находится в /Users/.../DevOpsLab/app
# Значит корень проекта на хосте - это родительская директория от app/
# Но внутри контейнера мы не знаем этот путь напрямую
# Поэтому используем путь, который Docker Desktop может разрешить
# Docker Desktop автоматически преобразует пути из volume mounts
# Но для docker run нужно использовать путь на хосте
# Лучше всего использовать переменную окружения HOST_PROJECT_ROOT
self.host_project_root = str(self.project_root)
self.molecule_dir = self.project_root / "molecule" / "default"
# Пресеты теперь находятся в alembic/presets
# Находим путь к alembic относительно текущего файла
current_file = Path(__file__)
alembic_dir = current_file.parent.parent / "alembic"
self.presets_dir = alembic_dir / "presets"
# Если не найдено, пробуем старый путь (для обратной совместимости)
if not self.presets_dir.exists():
self.presets_dir = self.project_root / "molecule" / "presets"
# Пресеты для Molecule должны находиться в molecule/presets
# чтобы create.yml мог их найти по пути /workspace/molecule/presets/
# ВАЖНО: presets_dir должен быть относительно project_root, который внутри контейнера = /workspace
# Это гарантирует, что файлы будут доступны внутри ansible-controller контейнера
self.presets_dir = Path("/workspace") / "molecule" / "presets"
self.docker_client = DockerClient()
self._temp_preset_files = {} # Кэш временных файлов preset'ов
@@ -158,7 +222,9 @@ class MoleculeExecutor:
preset_name: str = "default",
preset_content: Optional[str] = None,
preset_category: str = "main",
stream: bool = False
stream: bool = False,
stop_event: Optional[callable] = None,
container_ref: Optional[object] = None
) -> AsyncGenerator[str, None]:
"""
Тестирование роли через Molecule
@@ -169,96 +235,496 @@ class MoleculeExecutor:
preset_content: Содержимое preset'а из БД (если None - загружается из файла)
preset_category: Категория preset'а (main или k8s)
stream: Если True, возвращает генератор строк для WebSocket
stop_event: Функция для проверки флага остановки
container_ref: Объект для хранения ссылки на контейнер
Yields:
Строки вывода команды
"""
# Если preset_content передан, создаем временный файл из БД
if preset_content:
container = None
try:
# Если preset_content передан, создаем временный файл из БД
if preset_content:
try:
self.create_temp_preset_file(preset_name, preset_content, preset_category)
preset_data = self.load_preset_from_db(preset_content)
except Exception as e:
yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n"
return
else:
# Проверка существования preset'а в файловой системе
try:
preset_data = self.load_preset(preset_name, preset_category)
except FileNotFoundError as e:
yield f"❌ Ошибка: {str(e)}\n"
yield "💡 Убедитесь, что preset существует в БД или файловой системе\n"
return
# Расшифровка vault файлов
yield "🔓 Расшифровка vault файлов...\n"
await self.decrypt_vault_files()
# Запуск ansible-controller контейнера
yield "🔧 Запуск ansible-controller контейнера...\n"
# Подготовка переменных окружения
env = {
"ANSIBLE_FORCE_COLOR": "1",
"MOLECULE_PRESET": preset_name,
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
"MOLECULE_VAULT_ENABLED": "false"
}
if role_name:
env["MOLECULE_ROLE_NAME"] = role_name
yield f"📋 Тестируется роль: {role_name}\n"
yield f"📋 Используется пресет: {preset_name}\n\n"
# Используем Docker SDK вместо subprocess для запуска контейнера
# Команда docker может быть недоступна внутри контейнера
container_name = f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}"
try:
self.create_temp_preset_file(preset_name, preset_content, preset_category)
preset_data = self.load_preset_from_db(preset_content)
# Подготавливаем volumes для монтирования
volumes = {
str(self.host_project_root): {"bind": "/workspace", "mode": "rw"},
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
}
# Запускаем контейнер
container = self.docker_client.client.containers.run(
image="inecs/ansible-lab:ansible-controller-latest",
name=container_name,
command=["bash", "-c", self._build_test_command(role_name, preset_name, preset_category)],
environment=env,
volumes=volumes,
working_dir="/workspace",
user="root",
detach=True,
remove=False, # Не удаляем автоматически, чтобы можно было получить логи
auto_remove=False
)
# Сохраняем ссылку на контейнер для возможности остановки
if container_ref and hasattr(container_ref, 'set'):
container_ref.set(container)
# Потоковый вывод логов (асинхронно)
try:
# Используем обычную очередь для передачи данных между потоками
import queue
import threading
log_queue = queue.Queue()
loop = asyncio.get_event_loop()
def read_logs():
"""Синхронная функция для чтения логов"""
try:
for line in container.logs(stream=True, follow=True, stdout=True, stderr=True):
log_queue.put(line)
except Exception as e:
log_queue.put(None)
# Запускаем чтение логов в отдельном потоке
log_thread = threading.Thread(target=read_logs, daemon=True)
log_thread.start()
# Читаем логи из очереди асинхронно
while True:
# Проверяем флаг остановки
if stop_event and stop_event():
try:
yield "\n⏹️ Остановка тестирования по запросу пользователя...\n"
except GeneratorExit:
# Генератор уже закрыт - выполняем cleanup без yield
pass
# Останавливаем и удаляем контейнер ansible-controller
try:
if container:
container.stop(timeout=10)
logger.info(f"Container {container_name} stopped")
try:
yield "🛑 Контейнер ansible-controller остановлен\n"
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Error stopping container: {e}")
try:
yield f"⚠️ Ошибка при остановке контейнера: {e}\n"
except GeneratorExit:
pass
# Запускаем destroy.yml для очистки контейнеров из preset'а
try:
yield "\n🧹 Очистка контейнеров из preset'а...\n"
except GeneratorExit:
pass
# Выполняем cleanup (синхронно, без yield)
cleanup_success = False
try:
await self._cleanup_preset_containers(preset_name, preset_category)
logger.info(f"Cleanup completed for preset {preset_name}")
cleanup_success = True
try:
yield "✅ Контейнеры из preset'а удалены\n"
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Error cleaning up preset containers: {e}")
try:
yield f"⚠️ Ошибка при очистке контейнеров preset'а: {e}\n"
except GeneratorExit:
pass
# Удаляем контейнер ansible-controller
try:
if container:
container.remove(force=True)
logger.info(f"Container {container_name} removed")
try:
yield "🗑️ Контейнер ansible-controller удален\n"
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Error removing container: {e}")
try:
yield f"⚠️ Ошибка при удалении контейнера: {e}\n"
except GeneratorExit:
pass
# Сохраняем флаг, что cleanup был выполнен
cleanup_done = cleanup_success
# Выходим из цикла
break
try:
# Используем run_in_executor для чтения из обычной очереди
line = await asyncio.get_event_loop().run_in_executor(
None,
lambda: log_queue.get(timeout=0.1)
)
if line is None:
break
yield line.decode('utf-8', errors='replace')
except queue.Empty:
# Проверяем, завершился ли контейнер
try:
container.reload()
if container.status == 'exited':
# Читаем оставшиеся логи из очереди
while not log_queue.empty():
try:
line = log_queue.get_nowait()
if line is not None:
yield line.decode('utf-8', errors='replace')
except queue.Empty:
break
# Читаем финальные логи из контейнера
remaining_logs = container.logs(stdout=True, stderr=True)
if remaining_logs:
yield remaining_logs.decode('utf-8', errors='replace')
break
except Exception as e:
logger.error(f"Error reloading container: {e}")
break
continue
except GeneratorExit:
# Генератор закрыт - останавливаем контейнер и очищаем ресурсы
# НЕ используем yield здесь, так как генератор уже закрывается
logger.info(f"GeneratorExit caught, cleaning up container {container_name if container else 'unknown'}")
try:
if container:
container.stop(timeout=5)
container.remove(force=True)
logger.info(f"Container {container_name} stopped and removed on GeneratorExit")
except Exception as e:
logger.error(f"Error cleaning up container on GeneratorExit: {e}")
# Запускаем cleanup при закрытии генератора (без yield)
try:
await self._cleanup_preset_containers(preset_name, preset_category)
logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit")
except Exception as e:
logger.error(f"Error in cleanup on GeneratorExit: {e}")
# Обязательно поднимаем исключение дальше
raise
except Exception as e:
logger.error(f"Error reading logs: {e}")
break
finally:
# Ожидаем завершения контейнера (если он еще не остановлен)
# cleanup_done может быть уже установлен при остановке по запросу
if 'cleanup_done' not in locals():
cleanup_done = False
if container:
try:
container.reload()
if container.status != 'exited':
try:
exit_code = container.wait(timeout=5)["StatusCode"]
except:
# Если не удалось дождаться, останавливаем принудительно
container.stop(timeout=5)
exit_code = -1
else:
exit_code = container.attrs.get("State", {}).get("ExitCode", -1)
except Exception as e:
logger.error(f"Error waiting for container: {e}")
exit_code = -1
# Получаем финальные логи, если есть
try:
final_logs = container.logs(stdout=True, stderr=True, tail=100)
if final_logs:
yield final_logs.decode('utf-8', errors='replace')
except Exception as e:
logger.error(f"Error getting final logs: {e}")
# Удаляем контейнер ansible-controller
try:
if container:
container.stop(timeout=5)
container.remove(force=True)
try:
yield f"\n🗑️ Контейнер {container_name} удален\n"
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Error removing container {container_name}: {e}")
try:
yield f"\n⚠️ Предупреждение: не удалось удалить контейнер {container_name}: {e}\n"
except GeneratorExit:
pass
# Запускаем cleanup контейнеров из preset'а (всегда, даже если был вызван ранее)
# Это гарантирует, что контейнеры будут удалены даже при ошибках
try:
logger.info(f"Running cleanup in finally block for preset {preset_name}")
await self._cleanup_preset_containers(preset_name, preset_category)
logger.info(f"Cleanup completed in finally block for preset {preset_name}")
try:
yield "\n🧹 Контейнеры из preset'а очищены\n"
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Error in cleanup during finally: {e}", exc_info=True)
try:
yield f"\n⚠️ Ошибка при очистке контейнеров preset'а: {e}\n"
except GeneratorExit:
pass
except Exception as e:
yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n"
return
else:
# Проверка существования preset'а в файловой системе
yield f"❌ Ошибка при запуске контейнера: {str(e)}\n"
import traceback
yield f"Детали: {traceback.format_exc()}\n"
# Пытаемся удалить контейнер, если он был создан
try:
container = self.docker_client.client.containers.get(container_name)
container.remove(force=True)
except:
pass
# Шифрование vault файлов
yield "\n🔒 Шифрование vault файлов...\n"
await self.encrypt_vault_files()
# Проверяем код возврата контейнера (если не было остановки)
if not (stop_event and stop_event()) and container:
try:
container.reload()
exit_code = container.attrs.get("State", {}).get("ExitCode", -1)
if exit_code == 0:
yield "\n✅ Тестирование завершено успешно\n"
elif exit_code == -1:
yield "\n✅ Тестирование preset'а завершено\n"
else:
yield f"\n❌ Тестирование завершено с ошибкой (код: {exit_code})\n"
except:
yield "\n✅ Тестирование preset'а завершено\n"
except GeneratorExit:
# Генератор закрыт - останавливаем контейнер и очищаем ресурсы
# НЕ используем yield здесь, так как генератор уже закрывается
if container:
try:
container.stop(timeout=5)
container.remove(force=True)
logger.info(f"Container {container_name} stopped and removed due to GeneratorExit")
except Exception as e:
logger.error(f"Error removing container on GeneratorExit: {e}")
# Запускаем cleanup при закрытии генератора (без yield)
try:
preset_data = self.load_preset(preset_name, preset_category)
except FileNotFoundError as e:
yield f"❌ Ошибка: {str(e)}\n"
yield "💡 Убедитесь, что preset существует в БД или файловой системе\n"
return
if preset_content or preset_name:
await self._cleanup_preset_containers(preset_name, preset_category)
logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit")
except Exception as e:
logger.error(f"Error in cleanup on GeneratorExit: {e}")
raise
except Exception as e:
# Обрабатываем другие исключения
if container:
try:
container.stop(timeout=5)
container.remove(force=True)
except:
pass
# Запускаем cleanup при ошибке (без yield, так как генератор может быть закрыт)
try:
if preset_content or preset_name:
await self._cleanup_preset_containers(preset_name, preset_category)
except:
pass
# Пытаемся отправить сообщение об ошибке, если генератор еще открыт
try:
yield f"\n❌ Критическая ошибка: {str(e)}\n"
except GeneratorExit:
# Генератор закрыт - это нормально
raise
except:
# Другие ошибки при yield - игнорируем
pass
# Расшифровка vault файлов
yield "🔓 Расшифровка vault файлов...\n"
await self.decrypt_vault_files()
async def _cleanup_preset_containers(self, preset_name: str, preset_category: str = "main"):
"""Очистка контейнеров из preset'а через destroy.yml"""
logger.info(f"Starting cleanup for preset {preset_name} (category: {preset_category})")
# Запуск ansible-controller контейнера
yield "🔧 Запуск ansible-controller контейнера...\n"
# Сначала пытаемся удалить контейнеры напрямую (быстрее и надежнее)
cleanup_success = False
try:
# Получаем список всех контейнеров
all_containers = self.docker_client.client.containers.list(all=True)
removed_count = 0
# Подготовка переменных окружения
env = {
"ANSIBLE_FORCE_COLOR": "1",
"MOLECULE_PRESET": preset_name,
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
"MOLECULE_VAULT_ENABLED": "false"
}
for cont in all_containers:
try:
cont.reload()
cont_name = cont.name
# Проверяем, что контейнер в сети labnet или имеет короткое имя (тестовые контейнеры u1, u2 и т.д.)
networks = cont.attrs.get("NetworkSettings", {}).get("Networks", {})
is_test_container = False
if role_name:
env["MOLECULE_ROLE_NAME"] = role_name
yield f"📋 Тестируется роль: {role_name}\n"
if "labnet" in networks:
is_test_container = True
elif cont_name and len(cont_name) <= 10 and (cont_name.startswith("u") or cont_name.startswith("test-")):
is_test_container = True
yield f"📋 Используется пресет: {preset_name}\n\n"
if is_test_container:
try:
if cont.status != 'exited':
cont.stop(timeout=5)
cont.remove(force=True)
removed_count += 1
logger.info(f"Removed container {cont_name} during direct cleanup")
except Exception as remove_error:
logger.warning(f"Error removing container {cont_name}: {remove_error}")
except Exception as cont_error:
logger.debug(f"Error processing container: {cont_error}")
# Команда для выполнения в контейнере
docker_cmd = [
"docker", "run", "--rm",
"--name", f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"-v", f"{self.project_root}:/workspace",
"-w", "/workspace",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-u", "root"
]
if removed_count > 0:
logger.info(f"Direct cleanup removed {removed_count} containers")
cleanup_success = True
except Exception as direct_cleanup_error:
logger.error(f"Error in direct cleanup: {direct_cleanup_error}")
# Добавляем переменные окружения
for key, value in env.items():
docker_cmd.extend(["-e", f"{key}={value}"])
# Также пытаемся запустить destroy.yml для полной очистки
try:
# Запускаем destroy.yml в отдельном контейнере для очистки
env = {
"ANSIBLE_FORCE_COLOR": "1",
"MOLECULE_PRESET": preset_name,
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
"MOLECULE_VAULT_ENABLED": "false"
}
docker_cmd.extend([
"inecs/ansible-lab:ansible-controller-latest",
"bash", "-c", self._build_test_command(role_name)
])
cleanup_container_name = f"ansible-cleanup-{datetime.now().strftime('%Y%m%d%H%M%S')}"
# Запуск процесса
process = await asyncio.create_subprocess_exec(
*docker_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(self.project_root)
)
volumes = {
str(self.host_project_root): {"bind": "/workspace", "mode": "rw"},
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
}
# Потоковый вывод
async for line in process.stdout:
yield line.decode('utf-8', errors='replace')
cleanup_container = self.docker_client.client.containers.run(
image="inecs/ansible-lab:ansible-controller-latest",
name=cleanup_container_name,
command=["bash", "-c", "cd molecule/default && ansible-playbook -i localhost, destroy.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace"],
environment=env,
volumes=volumes,
working_dir="/workspace",
user="root",
detach=True,
remove=False,
auto_remove=False
)
await process.wait()
# Ждем завершения cleanup
try:
exit_code = cleanup_container.wait(timeout=30)["StatusCode"]
if exit_code == 0:
logger.info(f"Destroy.yml cleanup completed successfully")
cleanup_success = True
else:
logger.warning(f"Cleanup container exited with code {exit_code}")
except Exception as e:
logger.error(f"Error waiting for cleanup container: {e}")
finally:
# Удаляем cleanup контейнер
try:
cleanup_container.stop(timeout=5)
cleanup_container.remove(force=True)
except Exception as e:
logger.debug(f"Error removing cleanup container: {e}")
except Exception as e:
logger.error(f"Error running destroy.yml cleanup: {e}")
# Шифрование vault файлов
yield "\n🔒 Шифрование vault файлов...\n"
await self.encrypt_vault_files()
if not cleanup_success:
logger.warning(f"Cleanup may not have completed successfully for preset {preset_name}")
if process.returncode == 0:
yield "\n✅ Тестирование завершено успешно\n"
else:
yield f"\n❌ Тестирование завершено с ошибкой (код: {process.returncode})\n"
def _build_test_command(self, role_name: Optional[str] = None) -> str:
def _build_test_command(self, role_name: Optional[str] = None, preset_name: Optional[str] = None, preset_category: str = "main") -> str:
"""Построение команды для тестирования"""
commands = [
"echo -e '\\033[33m=== СОЗДАНИЕ ТЕСТОВЫХ КОНТЕЙНЕРОВ ==='",
"echo ''",
"mkdir -p /tmp/molecule_workspace/inventory",
]
# Для k8s пресетов сначала запускаем create_k8s_cluster.py
is_k8s_preset = False
if preset_category == "k8s":
is_k8s_preset = True
elif preset_name:
if preset_name.startswith("k8s-") or preset_name in ["kubernetes", "k8s-full"]:
is_k8s_preset = True
if is_k8s_preset:
# Определяем путь к preset файлу
# Для k8s пресетов файл должен быть в /workspace/molecule/presets/k8s/
if preset_category == "k8s":
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
elif preset_name:
k8s_names = ["kubernetes", "k8s-full"]
if preset_name.startswith("k8s-") or preset_name in k8s_names:
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
else:
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
else:
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
# Формируем команду для создания Kind кластера
script_path = "/workspace/scripts/create_k8s_cluster.py"
k8s_cmd_str = f"test -f {preset_file_path} && python3 {script_path} {preset_file_path} ansible-controller || echo Ошибка при создании Kind кластера"
commands.extend([
"echo -e '\\033[33m=== СОЗДАНИЕ KUBERNETES КЛАСТЕРА ==='",
"echo ''",
k8s_cmd_str,
"echo ''",
"echo -e '\\033[33m=== СОЗДАНИЕ DOCKER КОНТЕЙНЕРОВ ==='",
"echo ''",
])
commands.extend([
"cd molecule/default",
"ansible-playbook -i localhost, create.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace",
"echo ''",
@@ -276,7 +742,7 @@ class MoleculeExecutor:
"echo ''",
"echo -e '\\033[33m=== ЗАПУСК ROLES/DEPLOY.YML НА ТЕСТОВЫХ КОНТЕЙНЕРАХ ===\\033[0m'",
"echo ''"
]
])
# Добавляем команду для deploy.yml с фильтрацией по роли
if role_name:

View File

@@ -45,6 +45,7 @@ services:
environment:
- PROJECT_ROOT=/workspace
- PROJECT_NAME=devops-lab
- HOST_PROJECT_ROOT=${HOST_PROJECT_ROOT:-${PWD}/..}
- API_HOST=0.0.0.0
- API_PORT=8000
- API_RELOAD=true

View File

@@ -25,7 +25,7 @@ redis==4.6.0 # Celery 5.3.4 требует redis <5.0.0
websockets==12.0
# Docker
docker==6.1.3
docker==7.1.0
# Git
GitPython==3.1.40

View File

@@ -61,65 +61,86 @@ def load_presets():
skipped_count = 0
error_count = 0
# Основные preset'ы
# Функция для импорта одного пресета
def import_preset(preset_file, category='main'):
try:
with open(preset_file) as f:
content = f.read()
preset_data = yaml.safe_load(content) or {}
# Извлечение описания из комментария
description = None
for line in content.split('\n'):
if line.strip().startswith('#description:'):
description = line.split('#description:')[1].strip()
break
# Проверка существования в БД
result = connection.execute(
text("SELECT id FROM presets WHERE name = :name"),
{"name": preset_file.stem}
)
if result.fetchone():
print(f"⏭️ Пропущен (уже существует): {preset_file.stem}")
return 'skipped'
# Вставка в БД
# Преобразуем dict/list в JSON строки для PostgreSQL
hosts_json = json.dumps(preset_data.get('hosts', []))
images_json = json.dumps(preset_data.get('images', {}))
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
connection.execute(
text("""
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
"""),
{
'name': preset_file.stem,
'category': category,
'description': description,
'content': content,
'docker_network': preset_data.get('docker_network'),
'hosts': hosts_json,
'images': images_json,
'systemd_defaults': systemd_defaults_json,
'kind_clusters': kind_clusters_json,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
)
connection.commit()
print(f"✅ Загружен: {preset_file.stem}")
return 'loaded'
except Exception as e:
print(f"❌ Ошибка при загрузке {preset_file.name}: {e}")
return 'error'
# Основные preset'ы из корня папки presets
if presets_dir.exists():
for preset_file in presets_dir.glob("*.yml"):
if preset_file.name == "deploy.yml":
continue
try:
with open(preset_file) as f:
content = f.read()
preset_data = yaml.safe_load(content) or {}
# Извлечение описания из комментария
description = None
for line in content.split('\n'):
if line.strip().startswith('#description:'):
description = line.split('#description:')[1].strip()
break
# Проверка существования в БД
result = connection.execute(
text("SELECT id FROM presets WHERE name = :name"),
{"name": preset_file.stem}
)
if result.fetchone():
print(f"⏭️ Пропущен (уже существует): {preset_file.stem}")
skipped_count += 1
continue
# Вставка в БД
# Преобразуем dict/list в JSON строки для PostgreSQL
hosts_json = json.dumps(preset_data.get('hosts', []))
images_json = json.dumps(preset_data.get('images', {}))
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
connection.execute(
text("""
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
"""),
{
'name': preset_file.stem,
'category': 'main',
'description': description,
'content': content,
'docker_network': preset_data.get('docker_network'),
'hosts': hosts_json,
'images': images_json,
'systemd_defaults': systemd_defaults_json,
'kind_clusters': kind_clusters_json,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
)
connection.commit()
print(f"✅ Загружен: {preset_file.stem}")
result = import_preset(preset_file, category='main')
if result == 'loaded':
loaded_count += 1
except Exception as e:
print(f"❌ Ошибка при загрузке {preset_file.name}: {e}")
elif result == 'skipped':
skipped_count += 1
elif result == 'error':
error_count += 1
# Пресеты из папки examples
examples_dir = presets_dir / "examples"
if examples_dir.exists():
print(f"📁 Поиск пресетов в examples: {examples_dir}")
for preset_file in examples_dir.glob("*.yml"):
result = import_preset(preset_file, category='main')
if result == 'loaded':
loaded_count += 1
elif result == 'skipped':
skipped_count += 1
elif result == 'error':
error_count += 1
# K8s preset'ы

View File

@@ -98,6 +98,8 @@ class PresetService:
description: str = "",
hosts: List[Dict] = None,
category: str = "main",
images: Dict = None,
systemd_defaults: Dict = None,
created_by: Optional[str] = None
) -> Preset:
"""Создание нового preset'а в БД"""
@@ -107,7 +109,12 @@ class PresetService:
raise ValueError(f"Preset '{preset_name}' уже существует")
# Генерация содержимого preset'а
content = PresetService._generate_preset_content(description, hosts or [])
content = PresetService._generate_preset_content_from_form(
description=description,
hosts=hosts or [],
images=images or {},
systemd_defaults=systemd_defaults or {}
)
# Парсинг для извлечения данных
data = yaml.safe_load(content)
@@ -234,32 +241,11 @@ class PresetService:
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 = images or {}
# Объединяем с переданными образами
final_images = {**default_images, **(images or {})}
# Systemd defaults по умолчанию
default_systemd = {
# Используем переданные настройки systemd или дефолтные, если не переданы
final_systemd = systemd_defaults or {
"privileged": True,
"command": "/sbin/init",
"volumes": ["/sys/fs/cgroup:/sys/fs/cgroup:rw"],
@@ -267,9 +253,6 @@ class PresetService:
"capabilities": ["SYS_ADMIN"]
}
# Объединяем с переданными настройками
final_systemd = {**default_systemd, **(systemd_defaults or {})}
# Заголовок
content = f"""---
#description: {description or "Пользовательский preset"}

View File

@@ -91,10 +91,7 @@
<i class="fas fa-info-circle"></i>
</a>
<button
hx-delete="/api/v1/dockerfiles/{{ dockerfile.id }}"
hx-confirm="Удалить Dockerfile '{{ dockerfile.name }}'?"
hx-target="closest tr"
hx-swap="outerHTML"
onclick="deleteDockerfile({{ dockerfile.id }}, '{{ dockerfile.name }}', this)"
class="btn btn-outline-danger"
title="Удалить"
>
@@ -119,4 +116,89 @@
{% endif %}
</div>
</div>
{% block scripts %}
<script>
async function deleteDockerfile(dockerfileId, dockerfileName, button) {
// Показываем модальное окно подтверждения
const confirmed = await showConfirmModal(
`Вы уверены, что хотите удалить Dockerfile '${dockerfileName}'?`,
'Подтверждение удаления'
);
if (!confirmed) {
return;
}
// Отключаем кнопку во время запроса
button.disabled = true;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await fetch(`/api/v1/dockerfiles/${dockerfileId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
if (data.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
data.message || `Dockerfile '${dockerfileName}' успешно удален`,
'success',
'Успешно',
function() {
// После закрытия модального окна удаляем строку из таблицы
const row = button.closest('tr');
if (row) {
row.remove();
}
}
);
} else {
// Если функция недоступна, просто удаляем строку
const row = button.closest('tr');
if (row) {
row.remove();
}
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const errorData = await response.json();
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении Dockerfile';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении Dockerfile', 'error');
} else {
alert('Ошибка при удалении Dockerfile');
}
}
}
} catch (error) {
console.error('Ошибка при удалении Dockerfile:', error);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении Dockerfile', 'error');
} else {
alert('Ошибка при удалении Dockerfile');
}
} finally {
// Восстанавливаем кнопку
button.disabled = false;
button.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -31,10 +31,7 @@
<i class="fas fa-edit"></i>
</a>
<button
hx-delete="/api/v1/playbooks/{{ playbook.id }}"
hx-confirm="Удалить playbook '{{ playbook.name }}'?"
hx-target="closest .col-12"
hx-swap="outerHTML"
onclick="deletePlaybook({{ playbook.id }}, '{{ playbook.name }}', this)"
class="btn btn-sm btn-outline-danger"
title="Удалить"
>
@@ -93,4 +90,89 @@
</div>
{% endfor %}
</div>
{% block scripts %}
<script>
async function deletePlaybook(playbookId, playbookName, button) {
// Показываем модальное окно подтверждения
const confirmed = await showConfirmModal(
`Вы уверены, что хотите удалить playbook '${playbookName}'?`,
'Подтверждение удаления'
);
if (!confirmed) {
return;
}
// Отключаем кнопку во время запроса
button.disabled = true;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await fetch(`/api/v1/playbooks/${playbookId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
if (data.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
data.message || `Playbook '${playbookName}' успешно удален`,
'success',
'Успешно',
function() {
// После закрытия модального окна удаляем карточку
const card = button.closest('.col-12');
if (card) {
card.remove();
}
}
);
} else {
// Если функция недоступна, просто удаляем карточку
const card = button.closest('.col-12');
if (card) {
card.remove();
}
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const errorData = await response.json();
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении playbook';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении playbook', 'error');
} else {
alert('Ошибка при удалении playbook');
}
}
}
} catch (error) {
console.error('Ошибка при удалении playbook:', error);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении playbook', 'error');
} else {
alert('Ошибка при удалении playbook');
}
} finally {
// Восстанавливаем кнопку
button.disabled = false;
button.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -11,11 +11,10 @@
{% endblock %}
{% block content %}
<div x-data="presetCreator()">
<div x-data="presetCreator()" x-init="init()">
<form
hx-post="/api/v1/presets/create"
hx-target="#result"
hx-swap="innerHTML"
hx-swap="none"
@submit.prevent="submitForm"
class="card"
>
@@ -60,6 +59,89 @@
</div>
</div>
<!-- Docker образы -->
<div class="card-header">
<h5 class="mb-0">Docker образы</h5>
</div>
<div class="card-body">
<div class="mb-3">
<div class="space-y-2" x-ref="imagesContainer">
<template x-for="(image, key) in formData.images" :key="key">
<div class="row g-2 mb-2 align-items-center">
<div class="col-12 col-md-4">
<input
type="text"
:value="key"
class="form-control"
readonly
disabled
>
</div>
<div class="col-12 col-md-6">
<input
type="text"
x-model="formData.images[key]"
class="form-control"
placeholder="inecs/ansible-lab:ubuntu22-latest"
>
</div>
<div class="col-12 col-md-2">
<button
type="button"
@click="removeImage(key)"
class="btn btn-danger btn-sm w-100"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</template>
</div>
</div>
<div class="row g-2 mb-3 align-items-end">
<div class="col-12 col-md-4">
<label class="form-label small">Dockerfile</label>
<select
x-model="selectedDockerfile"
@change="onDockerfileSelected"
class="form-select"
:disabled="!dockerfilesLoaded"
>
<option value="">Выберите Dockerfile...</option>
<template x-for="dockerfile in dockerfiles" :key="dockerfile.id">
<option :value="dockerfile.name" x-text="dockerfile.name"></option>
</template>
</select>
<div class="form-text" x-show="!dockerfilesLoaded">
<i class="fas fa-spinner fa-spin me-1"></i>Загрузка Dockerfiles...
</div>
<div class="form-text text-danger" x-show="dockerfilesLoaded && dockerfiles.length === 0">
<i class="fas fa-exclamation-triangle me-1"></i>Dockerfiles не найдены
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label small">Значение образа</label>
<input
type="text"
x-model="newImageValue"
class="form-control"
placeholder="inecs/ansible-lab:ubuntu25"
>
</div>
<div class="col-12 col-md-2">
<button
type="button"
@click="addImage"
class="btn btn-outline-primary w-100"
>
<i class="fas fa-plus"></i>
</button>
</div>
</div>
</div>
<div class="card-header">
<h5 class="mb-0">Хосты</h5>
</div>
@@ -84,18 +166,10 @@
x-model="host.family"
class="form-select"
>
<option value="ubuntu20">Ubuntu 20</option>
<option value="ubuntu22">Ubuntu 22</option>
<option value="ubuntu24">Ubuntu 24</option>
<option value="debian11">Debian 11</option>
<option value="debian12">Debian 12</option>
<option value="centos7">CentOS 7</option>
<option value="centos8">CentOS 8</option>
<option value="centos9">CentOS 9</option>
<option value="alma">AlmaLinux</option>
<option value="rocky">Rocky Linux</option>
<option value="rhel">RHEL</option>
<option value="astra">Astra Linux</option>
<option value="">Выберите образ...</option>
<template x-for="(image, key) in formData.images" :key="key">
<option :value="key" x-text="key"></option>
</template>
</select>
</div>
<div class="col-12 col-md-4">
@@ -134,15 +208,89 @@
</button>
</div>
<!-- Systemd Defaults -->
<div class="card-header">
<h5 class="mb-0">Systemd Defaults</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
x-model="formData.systemd_defaults.privileged"
id="privileged"
>
<label class="form-check-label" for="privileged">
Privileged
</label>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Command</label>
<input
type="text"
x-model="formData.systemd_defaults.command"
class="form-control"
placeholder="/sbin/init"
>
</div>
<div class="col-12">
<label class="form-label">Volumes (по одному на строку)</label>
<textarea
x-model="formData.systemd_defaults.volumes_str"
@input="updateVolumes"
rows="3"
class="form-control"
placeholder="/sys/fs/cgroup:/sys/fs/cgroup:rw"
>/sys/fs/cgroup:/sys/fs/cgroup:rw</textarea>
</div>
<div class="col-12">
<label class="form-label">Tmpfs (через запятую)</label>
<input
type="text"
x-model="formData.systemd_defaults.tmpfs_str"
@input="updateTmpfs"
class="form-control"
placeholder="/run, /run/lock"
>
</div>
<div class="col-12">
<label class="form-label">Capabilities (через запятую)</label>
<input
type="text"
x-model="formData.systemd_defaults.capabilities_str"
@input="updateCapabilities"
class="form-control"
placeholder="SYS_ADMIN"
>
</div>
</div>
</div>
<!-- Скрытые поля -->
<input
type="hidden"
name="hosts"
:value="JSON.stringify(formData.hosts.map(h => ({name: h.name, family: h.family, groups: h.groups})))"
>
<!-- Результат -->
<div id="result" class="card-body border-top"></div>
<input
type="hidden"
name="images"
:value="JSON.stringify(formData.images)"
>
<input
type="hidden"
name="systemd_defaults"
:value="JSON.stringify({
privileged: formData.systemd_defaults.privileged,
command: formData.systemd_defaults.command,
volumes: formData.systemd_defaults.volumes,
tmpfs: formData.systemd_defaults.tmpfs,
capabilities: formData.systemd_defaults.capabilities
})"
>
<!-- Кнопки -->
<div class="card-footer">
@@ -169,15 +317,43 @@ function presetCreator() {
category: 'main',
hosts: [{
name: 'u1',
family: 'ubuntu22',
family: '',
groups_str: 'test, web',
groups: ['test', 'web']
}]
}],
images: {},
systemd_defaults: {
privileged: true,
command: '/sbin/init',
volumes_str: '/sys/fs/cgroup:/sys/fs/cgroup:rw',
volumes: ['/sys/fs/cgroup:/sys/fs/cgroup:rw'],
tmpfs_str: '/run, /run/lock',
tmpfs: ['/run', '/run/lock'],
capabilities_str: 'SYS_ADMIN',
capabilities: ['SYS_ADMIN']
}
},
dockerfiles: {{ dockerfiles | tojson }},
dockerfilesLoaded: true,
selectedDockerfile: '',
newImageKey: '',
newImageValue: '',
init() {
// Dockerfiles уже загружены из шаблона
console.log('Загружено Dockerfiles из шаблона:', this.dockerfiles.length);
},
onDockerfileSelected() {
if (this.selectedDockerfile) {
// Устанавливаем ключ образа из выбранного Dockerfile
this.newImageKey = this.selectedDockerfile;
// Автоматически подставляем значение образа в формате inecs/ansible-lab:{name}
this.newImageValue = `inecs/ansible-lab:${this.selectedDockerfile}`;
}
},
addHost() {
this.formData.hosts.push({
name: `u${this.formData.hosts.length + 1}`,
family: 'ubuntu22',
family: '',
groups_str: 'test',
groups: ['test']
});
@@ -189,10 +365,99 @@ function presetCreator() {
const host = this.formData.hosts[index];
host.groups = host.groups_str.split(',').map(g => g.trim()).filter(g => g);
},
addImage() {
if (this.newImageKey && this.newImageValue) {
this.formData.images[this.newImageKey] = this.newImageValue;
this.newImageKey = '';
this.newImageValue = '';
this.selectedDockerfile = '';
}
},
removeImage(key) {
delete this.formData.images[key];
},
updateVolumes() {
this.formData.systemd_defaults.volumes =
this.formData.systemd_defaults.volumes_str
.split('\n')
.map(v => v.trim())
.filter(v => v);
},
updateTmpfs() {
this.formData.systemd_defaults.tmpfs =
this.formData.systemd_defaults.tmpfs_str
.split(',')
.map(t => t.trim())
.filter(t => t);
},
updateCapabilities() {
this.formData.systemd_defaults.capabilities =
this.formData.systemd_defaults.capabilities_str
.split(',')
.map(c => c.trim())
.filter(c => c);
},
submitForm(event) {
// Обновляем массивы перед отправкой
this.updateVolumes();
this.updateTmpfs();
this.updateCapabilities();
// HTMX обработает отправку
}
}
}
// Обработка ответа от HTMX для создания пресета
document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:afterRequest', function(event) {
// Проверяем, что это запрос на создание пресета
if (event.detail.path === '/api/v1/presets/create') {
if (event.detail.xhr.status === 201 || event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || `Preset '${response.preset_name}' успешно создан`,
'success',
'Успешно',
function() {
// После закрытия модального окна перенаправляем на страницу списка пресетов
window.location.href = '/presets';
}
);
} else {
// Если функция недоступна, просто перенаправляем
window.location.href = '/presets';
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при создании preset', 'error');
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при создании preset';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при создании preset', 'error');
} else {
alert('Ошибка при создании preset');
}
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -4,39 +4,17 @@
{% block page_title %}Preset: {{ preset.name }}{% endblock %}
{% block header_actions %}
<div class="btn-group">
<button
type="button"
class="btn btn-success btn-sm"
onclick="startPresetTest()"
title="Запустить тест preset'а"
>
<i class="fas fa-play me-2"></i>
Запустить
</button>
<button
type="button"
class="btn btn-warning btn-sm"
onclick="stopPresetTest()"
title="Остановить тест"
id="stop-btn"
style="display: none;"
>
<i class="fas fa-stop me-2"></i>
Остановить
</button>
<button
type="button"
class="btn btn-info btn-sm"
onclick="restartPresetTest()"
title="Перезапустить тест"
id="restart-btn"
style="display: none;"
>
<i class="fas fa-redo me-2"></i>
Перезапустить
</button>
</div>
<button
type="button"
class="btn btn-success btn-sm"
onclick="openPresetTestModal()"
title="Запустить тест preset'а"
data-bs-toggle="modal"
data-bs-target="#presetTestModal"
>
<i class="fas fa-play me-2"></i>
Запустить
</button>
<a href="/presets/{{ preset.name }}/edit?category={{ preset.category }}" class="btn btn-primary btn-sm">
<i class="fas fa-edit me-2"></i>
Редактировать
@@ -49,7 +27,7 @@
{% block content %}
<div class="row">
<div class="col-12 col-lg-8">
<div class="col-12">
<!-- Информация о preset'е -->
<div class="card mb-3">
<div class="card-header">
@@ -83,7 +61,7 @@
<div class="mb-3">
<strong>Хосты:</strong>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<table class="table table-sm table-bordered w-100">
<thead>
<tr>
<th>Имя</th>
@@ -144,24 +122,65 @@
</div>
</div>
<div class="col-12 col-lg-4">
<!-- Логи тестирования -->
<div class="card" id="test-logs-card" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Логи тестирования</h5>
</div>
<!-- Модальное окно для тестирования preset'а -->
<div class="modal fade" id="presetTestModal" tabindex="-1" aria-labelledby="presetTestModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="presetTestModalLabel">
<i class="fas fa-vial me-2"></i>
Тестирование preset'а: {{ preset.name }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть" onclick="stopPresetTest()"></button>
</div>
<div class="modal-body">
<div class="log-container" id="preset-test-logs">
<div class="text-center text-muted py-5">
<i class="fas fa-spinner fa-spin fa-2x mb-3"></i>
<p>Подключение к серверу...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-outline-light"
onclick="clearTestLogs()"
class="btn btn-warning"
onclick="stopPresetTest()"
id="modal-stop-btn"
title="Остановить тест"
>
<i class="fas fa-stop me-2"></i>
Остановить
</button>
<button
type="button"
class="btn btn-info"
onclick="restartPresetTest()"
id="modal-restart-btn"
title="Перезапустить тест"
>
<i class="fas fa-redo me-2"></i>
Перезапустить
</button>
<button
type="button"
class="btn btn-secondary"
onclick="clearPresetTestLogs()"
title="Очистить логи"
>
<i class="fas fa-trash"></i>
<i class="fas fa-trash me-2"></i>
Очистить
</button>
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
onclick="stopPresetTest()"
>
Закрыть
</button>
</div>
<div class="card-body p-0">
<div class="log-container" id="test-logs" style="height: 600px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.75rem;">
<!-- Логи будут добавлены через WebSocket -->
</div>
</div>
</div>
</div>
@@ -172,31 +191,59 @@
<script>
let testWebSocket = null;
let testRunning = false;
let presetTestModal = null;
function startPresetTest() {
// Инициализация модального окна
document.addEventListener('DOMContentLoaded', function() {
presetTestModal = new bootstrap.Modal(document.getElementById('presetTestModal'));
// Обработка закрытия модального окна
document.getElementById('presetTestModal').addEventListener('hidden.bs.modal', function() {
stopPresetTest();
});
});
function openPresetTestModal() {
if (testRunning) {
alert('Тест уже запущен');
return;
}
// Показываем логи
const logsCard = document.getElementById('test-logs-card');
const logsContainer = document.getElementById('test-logs');
logsCard.style.display = 'block';
// Очищаем логи
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '<div class="text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x mb-3"></i><p>Подключение к серверу...</p></div>';
// Показываем модальное окно
presetTestModal.show();
// Запускаем тест
setTimeout(() => {
startPresetTest();
}, 300);
}
function startPresetTest() {
if (testRunning) {
return;
}
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '';
// Показываем кнопки управления
document.getElementById('stop-btn').style.display = 'inline-block';
document.getElementById('restart-btn').style.display = 'inline-block';
document.getElementById('modal-stop-btn').disabled = false;
document.getElementById('modal-restart-btn').disabled = false;
testRunning = true;
// Создаем WebSocket подключение
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/preset/test/{{ preset.name }}?category={{ preset.category }}`);
const wsUrl = `${protocol}//${window.location.host}/ws/preset/test/{{ preset.name }}?category={{ preset.category }}`;
const ws = new WebSocket(wsUrl);
testWebSocket = ws;
ws.onopen = () => {
addLog('info', '🔌 Подключено к серверу');
ws.send(JSON.stringify({
action: 'start',
preset_name: '{{ preset.name }}',
@@ -205,64 +252,195 @@ function startPresetTest() {
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
try {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') {
const logLine = document.createElement('div');
logLine.className = `log-line log-${data.level || 'info'}`;
logLine.textContent = data.data;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
} else if (data.type === 'error') {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = data.data;
logsContainer.appendChild(errorLine);
} else if (data.type === 'complete') {
const completeLine = document.createElement('div');
completeLine.className = 'log-line log-info';
completeLine.textContent = data.data || '✅ Тестирование завершено';
logsContainer.appendChild(completeLine);
testRunning = false;
document.getElementById('stop-btn').style.display = 'none';
document.getElementById('restart-btn').style.display = 'none';
ws.close();
if (data.type === 'log' || data.type === 'info') {
addLog(data.level || 'info', data.data);
} else if (data.type === 'error') {
addLog('error', data.data);
} else if (data.type === 'complete') {
addLog('info', data.data || '✅ Тестирование завершено');
testRunning = false;
document.getElementById('modal-stop-btn').disabled = true;
document.getElementById('modal-restart-btn').disabled = false;
ws.close();
}
} catch (e) {
console.error('Ошибка парсинга сообщения:', e);
}
};
ws.onerror = (error) => {
const errorLine = document.createElement('div');
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(errorLine);
addLog('error', `❌ Ошибка подключения WebSocket`);
testRunning = false;
document.getElementById('modal-stop-btn').disabled = true;
};
ws.onclose = () => {
testRunning = false;
console.log('WebSocket закрыт');
if (testWebSocket === ws) {
testWebSocket = null;
}
};
}
function stopPresetTest() {
if (testWebSocket && testWebSocket.readyState === WebSocket.OPEN) {
testWebSocket.send(JSON.stringify({ action: 'stop' }));
addLog('info', '⏹️ Остановка тестирования...');
}
if (testWebSocket) {
testWebSocket.close();
testWebSocket = null;
}
testRunning = false;
document.getElementById('stop-btn').style.display = 'none';
document.getElementById('restart-btn').style.display = 'none';
document.getElementById('modal-stop-btn').disabled = true;
document.getElementById('modal-restart-btn').disabled = false;
}
function restartPresetTest() {
stopPresetTest();
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '';
setTimeout(() => {
startPresetTest();
}, 1000);
}, 500);
}
function clearTestLogs() {
document.getElementById('test-logs').innerHTML = '';
function clearPresetTestLogs() {
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '';
}
function addLog(level, message) {
const logsContainer = document.getElementById('preset-test-logs');
const logLine = document.createElement('div');
logLine.className = `log-line log-${level}`;
// Цвета для разных уровней логов
const colors = {
'error': '#f48771',
'warning': '#d19a66',
'info': '#61afef',
'success': '#98c379',
'debug': '#abb2bf'
};
logLine.style.color = colors[level] || colors['debug'];
logLine.textContent = message;
logsContainer.appendChild(logLine);
logsContainer.scrollTop = logsContainer.scrollHeight;
}
</script>
<style>
/* Стили для модального окна тестирования preset'а - как у модального окна логов сборки */
#presetTestModal .modal-dialog {
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
height: 100vh !important;
max-height: 100vh !important;
}
#presetTestModal .modal-content {
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
#presetTestModal .modal-header {
flex-shrink: 0 !important;
padding: 1rem !important;
height: auto !important;
}
#presetTestModal .modal-body {
flex: 1 1 0 !important;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
min-height: 0 !important;
max-height: none !important;
height: auto !important;
display: flex !important;
flex-direction: column !important;
position: relative !important;
}
#preset-test-logs {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
box-sizing: border-box !important;
overflow: auto !important;
background: #1e1e1e !important;
color: #d4d4d4 !important;
padding: 1rem !important;
font-family: 'Courier New', monospace !important;
font-size: 0.875rem !important;
white-space: pre !important;
word-wrap: normal !important;
overflow-wrap: normal !important;
}
#presetTestModal .modal-footer {
flex-shrink: 0 !important;
padding: 1rem !important;
height: auto !important;
}
/* Дополнительные стили для гарантии полной высоты */
#presetTestModal.show .modal-body,
#presetTestModal.showing .modal-body {
height: calc(100vh - 180px) !important;
min-height: calc(100vh - 180px) !important;
max-height: calc(100vh - 180px) !important;
}
#presetTestModal.show #preset-test-logs,
#presetTestModal.showing #preset-test-logs {
height: calc(100vh - 180px) !important;
min-height: calc(100vh - 180px) !important;
max-height: calc(100vh - 180px) !important;
}
/* Стили для строк логов */
#preset-test-logs .log-line {
margin-bottom: 0.25rem;
line-height: 1.5;
}
#preset-test-logs .log-error {
color: #f48771 !important;
}
#preset-test-logs .log-warning {
color: #d19a66 !important;
}
#preset-test-logs .log-info {
color: #61afef !important;
}
#preset-test-logs .log-success {
color: #98c379 !important;
}
#preset-test-logs .log-debug {
color: #abb2bf !important;
}
</style>
{% endblock %}

View File

@@ -14,8 +14,7 @@
<div x-data="presetEditor()">
<form
hx-post="/api/v1/presets/{{ preset.name }}/update?category={{ preset.category }}"
hx-target="#result"
hx-swap="innerHTML"
hx-swap="none"
@submit.prevent="submitForm"
class="card"
>
@@ -374,9 +373,6 @@
>
{% endif %}
<!-- Результат -->
<div id="result" class="card-body border-top"></div>
<!-- Кнопки -->
<div class="card-footer">
<div class="d-flex gap-2">
@@ -514,5 +510,58 @@ function presetEditor() {
}
}
}
// Обработка ответа от HTMX для обновления пресета
document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:afterRequest', function(event) {
// Проверяем, что это запрос на обновление пресета
if (event.detail.path && event.detail.path.includes('/api/v1/presets/') && event.detail.path.includes('/update')) {
if (event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || 'Preset успешно обновлен',
'success',
'Успешно',
function() {
// После закрытия модального окна обновляем страницу
location.reload();
}
);
} else {
// Если функция недоступна, просто обновляем страницу
location.reload();
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении preset', 'error');
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при обновлении preset';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении preset', 'error');
} else {
alert('Ошибка при обновлении preset');
}
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -115,10 +115,7 @@
<i class="fas fa-edit"></i>
</a>
<button
hx-delete="/api/v1/presets/{{ preset.name }}?category={{ preset.category or 'main' }}"
hx-confirm="Удалить preset '{{ preset.name }}'?"
hx-target="closest tr"
hx-swap="outerHTML"
onclick="deletePreset('{{ preset.name }}', '{{ preset.category or 'main' }}', this)"
class="btn btn-outline-danger"
title="Удалить"
>
@@ -225,3 +222,95 @@
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
async function deletePreset(presetName, category, button) {
// Показываем модальное окно подтверждения
const confirmed = await showConfirmModal(
`Вы уверены, что хотите удалить preset '${presetName}'?`,
'Подтверждение удаления'
);
if (!confirmed) {
return;
}
// Отключаем кнопку во время запроса
button.disabled = true;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await fetch(`/api/v1/presets/${presetName}?category=${category}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
if (data.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
data.message || `Preset '${presetName}' успешно удален`,
'success',
'Успешно',
function() {
// После закрытия модального окна удаляем строку из таблицы
const row = button.closest('tr');
if (row) {
row.remove();
// Обновляем счетчик, если нужно
const totalSpan = document.querySelector('.text-muted.small strong');
if (totalSpan) {
const currentTotal = parseInt(totalSpan.textContent) || 0;
totalSpan.textContent = Math.max(0, currentTotal - 1);
}
}
}
);
} else {
// Если функция недоступна, просто удаляем строку
const row = button.closest('tr');
if (row) {
row.remove();
}
}
}
} else {
// Ошибка - показываем в модальном окне
try {
const errorData = await response.json();
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении preset';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении preset', 'error');
} else {
alert('Ошибка при удалении preset');
}
}
}
} catch (error) {
console.error('Ошибка при удалении preset:', error);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при удалении preset', 'error');
} else {
alert('Ошибка при удалении preset');
}
} finally {
// Восстанавливаем кнопку
button.disabled = false;
button.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -24,8 +24,7 @@
<div class="card-body">
<form
hx-post="/api/v1/profile/docker-settings"
hx-target="#docker-result"
hx-swap="innerHTML"
hx-swap="none"
>
<div class="mb-3">
<label class="form-label">Имя пользователя</label>
@@ -66,8 +65,6 @@
</div>
</div>
<div id="docker-result" class="mb-3"></div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-save me-2"></i>
Сохранить настройки Docker Hub
@@ -89,8 +86,7 @@
<div class="card-body">
<form
hx-post="/api/v1/profile/docker-settings"
hx-target="#harbor-result"
hx-swap="innerHTML"
hx-swap="none"
>
<div class="mb-3">
<label class="form-label">URL Harbor</label>
@@ -139,8 +135,6 @@
</div>
</div>
<div id="harbor-result" class="mb-3"></div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-save me-2"></i>
Сохранить настройки Harbor
@@ -150,4 +144,57 @@
</div>
</div>
</div>
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Обработка успешного сохранения настроек Docker
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.path === '/api/v1/profile/docker-settings' && event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || 'Настройки успешно сохранены',
'success',
'Успешно',
function() {
// После закрытия модального окна обновляем страницу
location.reload();
}
);
} else {
// Если функция недоступна, просто обновляем страницу
location.reload();
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при сохранении настроек', 'error');
}
}
} else if (event.detail.path === '/api/v1/profile/docker-settings' && event.detail.xhr.status !== 200) {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при сохранении настроек';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при сохранении настроек', 'error');
} else {
alert('Ошибка при сохранении настроек');
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -255,8 +255,7 @@
<form
hx-post="/api/v1/profile"
hx-target="#result"
hx-swap="innerHTML"
hx-swap="none"
>
<div class="mb-3">
<label class="form-label">Имя пользователя</label>
@@ -292,8 +291,6 @@
>
</div>
<div id="result" class="mb-3"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>
@@ -318,20 +315,48 @@
document.addEventListener('DOMContentLoaded', function() {
// Обработка успешного сохранения профиля
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id === 'result' && event.detail.xhr.status === 200) {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем сообщение об успехе
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '<div class="alert alert-success alert-dismissible fade show" role="alert">' +
'<i class="fas fa-check-circle me-2"></i>' + response.message +
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>' +
'</div>';
// Обновляем страницу через 1.5 секунды
setTimeout(() => {
location.reload();
}, 1500);
if (event.detail.path === '/api/v1/profile' && event.detail.xhr.status === 200) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
// Показываем модальное окно с успешным сообщением
if (window.showMessageModal) {
window.showMessageModal(
response.message || 'Профиль успешно обновлен',
'success',
'Успешно',
function() {
// После закрытия модального окна обновляем страницу
location.reload();
}
);
} else {
// Если функция недоступна, просто обновляем страницу
location.reload();
}
}
} catch (e) {
console.error('Ошибка парсинга ответа:', e);
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении профиля', 'error');
}
}
} else if (event.detail.path === '/api/v1/profile' && event.detail.xhr.status !== 200) {
// Ошибка - показываем в модальном окне
try {
const response = JSON.parse(event.detail.xhr.responseText);
const errorMessage = response.detail || response.message || 'Ошибка при обновлении профиля';
if (window.showMessageModal) {
window.showMessageModal(errorMessage, 'error');
} else {
alert(errorMessage);
}
} catch (e) {
if (window.showMessageModal) {
window.showMessageModal('Ошибка при обновлении профиля', 'error');
} else {
alert('Ошибка при обновлении профиля');
}
}
}
});

View File

@@ -1,55 +0,0 @@
---
# ПРЕСЕТ: Kubernetes + Istio Full Stack (1 кластер с полным стеком)
#
# Описание: Полноценный Kubernetes кластер с полным стеком Istio
# - 1 Kind кластер с 3 workers
# - Полный Istio service mesh с Kiali
# - Prometheus + Grafana для мониторинга
# - Jaeger для трассировки
# - Все аддоны включены
#
# Использование: make lab-test SCENARIO=universal LAB_SPEC=molecule/presets/k8s-istio-full.yml
#
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
docker_network: labnet
kind_clusters:
- name: istio-full
workers: 3
api_port: 6443
addons:
ingress_nginx: true
metrics_server: true
istio: true
kiali: true
prometheus_stack: true
ingress_host_http_port: 8081
ingress_host_https_port: 8443
images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
rhel: "quay.io/centos/centos:stream9-systemd"
systemd_defaults:
privileged: true
command: "/sbin/init"
volumes:
- "/sys/fs/cgroup:/sys/fs/cgroup:ro"
tmpfs:
- "/run"
- "/run/lock"
capabilities:
- "SYS_ADMIN"
hosts:
- name: k8s-controller
group: controllers
family: debian
publish:
- "6443:6443"
- "9090:9090" # Prometheus
- "3000:3000" # Grafana
- "16686:16686" # Jaeger
- "20001:20001" # Kiali

View File

@@ -47,8 +47,10 @@ kind_clusters:
ingress_host_https_port: 8445
images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
rhel: "quay.io/centos/centos:stream9-systemd"
debian: "inecs/ansible-lab:ubuntu22-latest"
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
rhel: "inecs/ansible-lab:rhel-latest"
centos: "inecs/ansible-lab:centos9-latest"
systemd_defaults:
privileged: true

View File

@@ -1,46 +0,0 @@
---
# ПРЕСЕТ: Kubernetes Single Node (1 кластер)
#
# Описание: Одиночный Kind кластер для простого тестирования K8s ролей
# - 1 Kind кластер с 1 worker
# - Базовые аддоны: Ingress NGINX, Metrics Server
# - Простая конфигурация для быстрого старта
#
# Использование: make lab-test SCENARIO=universal LAB_SPEC=molecule/presets/k8s-single.yml
#
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
docker_network: labnet
kind_clusters:
- name: single
workers: 1
api_port: 6443
addons:
ingress_nginx: true
metrics_server: true
ingress_host_http_port: 8081
ingress_host_https_port: 8443
images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
rhel: "quay.io/centos/centos:stream9-systemd"
systemd_defaults:
privileged: true
command: "/sbin/init"
volumes:
- "/sys/fs/cgroup:/sys/fs/cgroup:ro"
tmpfs:
- "/run"
- "/run/lock"
capabilities:
- "SYS_ADMIN"
hosts:
- name: k8s-controller
group: controllers
family: debian
publish:
- "6443:6443"

View File

@@ -1,66 +0,0 @@
---
#description: Пресет для тестирования с Kubernetes Kind кластером
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
docker_network: labnet
generated_inventory: "{{ molecule_ephemeral_directory }}/inventory/hosts.ini"
# systemd-ready образы
images:
alt: "inecs/ansible-lab:alt-linux-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"
systemd_defaults:
privileged: true
command: "/sbin/init"
volumes:
- "/sys/fs/cgroup:/sys/fs/cgroup:rw"
tmpfs: ["/run", "/run/lock"]
capabilities: ["SYS_ADMIN"]
# Kind кластеры с полным набором аддонов
kind_clusters:
- name: lab
workers: 2
api_port: 6443
addons:
ingress_nginx: true
metrics_server: true
istio: true
kiali: true
prometheus_stack: true
# Порты для доступа к аддонам извне
# Ingress HTTP: http://localhost:8081
# Ingress HTTPS: https://localhost:8443
# Prometheus: http://localhost:9090
# Grafana: http://localhost:3000 (admin/admin)
# Kiali: http://localhost:20001
# Metrics Server: http://localhost:4443
addon_ports:
ingress_http: 8081
ingress_https: 8443
prometheus: 9090
grafana: 3000
kiali: 20001
metrics_server: 4443
hosts: []
# # Стандартный набор - 2 хоста для базового тестирования (стабильные ОС)
# - name: u1
# family: ubuntu22
# groups: [test, web]