Исправление синтаксической ошибки в 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 shell - Открыть shell в контейнере web"
@echo " make clean - Очистить контейнеры и volumes" @echo " make clean - Очистить контейнеры и volumes"
@echo " make rebuild - Пересобрать и перезапустить" @echo " make rebuild - Пересобрать и перезапустить"
@echo " make migrate - Применить миграции БД"
@echo " make load-presets - Импортировать пресеты из файловой системы"
build: build:
$(COMPOSE) build $(COMPOSE) build
@@ -58,3 +60,9 @@ clean:
status: status:
$(COMPOSE) ps $(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 ingress_host_https_port: 8443
images: images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy" debian: "inecs/ansible-lab:ubuntu22-latest"
rhel: "quay.io/centos/centos:stream9-systemd" ubuntu: "inecs/ansible-lab:ubuntu22-latest"
rhel: "inecs/ansible-lab:rhel-latest"
centos: "inecs/ansible-lab:centos9-latest"
systemd_defaults: systemd_defaults:
privileged: true privileged: true

View File

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

View File

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

View File

@@ -39,59 +39,70 @@ def upgrade():
presets_dir = old_presets_dir presets_dir = old_presets_dir
k8s_presets_dir = presets_dir / "k8s" 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(): if presets_dir.exists():
for preset_file in presets_dir.glob("*.yml"): for preset_file in presets_dir.glob("*.yml"):
if preset_file.name == "deploy.yml": if preset_file.name == "deploy.yml":
continue continue
import_preset(preset_file, category='main')
try: # Пресеты из папки examples
with open(preset_file) as f: examples_dir = presets_dir / "examples"
content = f.read() if examples_dir.exists():
preset_data = yaml.safe_load(content) or {} for preset_file in examples_dir.glob("*.yml"):
import_preset(preset_file, category='main')
# Извлечение описания из комментария
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}")
# K8s preset'ы # K8s preset'ы
if k8s_presets_dir.exists(): if k8s_presets_dir.exists():

View File

@@ -313,11 +313,24 @@ async def delete_dockerfile(
db: AsyncSession = Depends(get_async_db) db: AsyncSession = Depends(get_async_db)
): ):
"""Удаление Dockerfile""" """Удаление 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) deleted = await DockerfileService.delete_dockerfile(db, dockerfile_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Dockerfile не найден") 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") @router.get("/api/v1/dockerfiles")

View File

@@ -206,11 +206,24 @@ async def delete_playbook(
db: AsyncSession = Depends(get_async_db) db: AsyncSession = Depends(get_async_db)
): ):
"""Удаление playbook""" """Удаление 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) deleted = await PlaybookService.delete_playbook(db, playbook_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Playbook не найден") 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") @router.get("/api/v1/playbooks")

View File

@@ -11,10 +11,14 @@ from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional
import yaml import yaml
import json import json
import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.services.preset_service import PresetService from app.services.preset_service import PresetService
from app.db.session import get_async_db from app.db.session import get_async_db
from app.auth.deps import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
templates_path = Path(__file__).parent.parent.parent.parent / "templates" 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) @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'а""" """Страница создания 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( return templates.TemplateResponse(
"pages/presets/create.html", "pages/presets/create.html",
{"request": request} {
"request": request,
"dockerfiles": dockerfiles_list
}
) )
@@ -129,6 +156,8 @@ async def create_preset_api(
description: str = Form(""), description: str = Form(""),
category: str = Form("main"), category: str = Form("main"),
hosts: str = Form(""), hosts: str = Form(""),
images: str = Form(""),
systemd_defaults: str = Form(""),
db: AsyncSession = Depends(get_async_db) db: AsyncSession = Depends(get_async_db)
): ):
"""API endpoint для создания preset'а""" """API endpoint для создания preset'а"""
@@ -137,12 +166,22 @@ async def create_preset_api(
if hosts: if hosts:
hosts_list = json.loads(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( preset = await PresetService.create_preset(
db=db, db=db,
preset_name=preset_name, preset_name=preset_name,
description=description, description=description,
hosts=hosts_list, hosts=hosts_list,
category=category category=category,
images=images_dict,
systemd_defaults=systemd_defaults_dict
) )
return JSONResponse(content={ return JSONResponse(content={
@@ -270,6 +309,19 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
"""WebSocket для live логов тестирования preset'а""" """WebSocket для live логов тестирования preset'а"""
await websocket.accept() 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: try:
# Получаем preset из БД # Получаем preset из БД
async for db in get_async_db(): 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 preset_content = preset.content
break 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'а # Запуск тестирования preset'а
from app.core.molecule_executor import MoleculeExecutor from app.core.molecule_executor import MoleculeExecutor
executor = MoleculeExecutor() executor = MoleculeExecutor()
@@ -309,34 +349,99 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
"data": f"🚀 Запуск тестирования preset'а '{preset_name}'..." "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( try:
role_name=None, async for line in executor.test_role(
preset_name=preset_name, role_name=None,
preset_content=preset_content, preset_name=preset_name,
preset_category=category, preset_content=preset_content,
stream=True preset_category=category,
): stream=True,
line = line.rstrip() stop_event=lambda: stop_requested,
if not line: container_ref=container_ref
continue ):
if stop_requested:
break
log_type = executor.detect_log_level(line) line = line.rstrip()
if not line:
continue
await websocket.send_json({ log_type = executor.detect_log_level(line)
"type": "log",
"level": log_type,
"data": line
})
await websocket.send_json({ try:
"type": "complete", await websocket.send_json({
"status": "success", "type": "log",
"data": "✅ Тестирование preset'а завершено" "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: try:
executor._temp_preset_files[preset_name].unlink() executor._temp_preset_files[preset_name].unlink()
del executor._temp_preset_files[preset_name] del executor._temp_preset_files[preset_name]
@@ -344,14 +449,22 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
pass pass
except WebSocketDisconnect: except WebSocketDisconnect:
# Соединение закрыто клиентом - это нормально
pass
except GeneratorExit:
# Генератор закрыт - это нормально
pass pass
except Exception as e: except Exception as e:
import traceback import traceback
error_msg = f"❌ Ошибка: {str(e)}\n{traceback.format_exc()}" error_msg = f"❌ Ошибка: {str(e)}"
await websocket.send_json({ try:
"type": "error", await websocket.send_json({
"data": error_msg "type": "error",
}) "data": error_msg
})
except:
pass
logger.error(f"Error in preset_test_websocket: {e}\n{traceback.format_exc()}")
finally: finally:
try: try:
await websocket.close() await websocket.close()

View File

@@ -5,6 +5,7 @@ Docker клиент для управления контейнерами
""" """
import docker import docker
from docker import APIClient
import os import os
from typing import List, Dict, Optional from typing import List, Dict, Optional
from app.core.config import settings from app.core.config import settings
@@ -25,65 +26,44 @@ class DockerClient:
"""Ленивая инициализация Docker клиента""" """Ленивая инициализация Docker клиента"""
if self._client is None: if self._client is None:
try: try:
# Получаем DOCKER_HOST из настроек или окружения # Временно удаляем DOCKER_HOST из окружения, если он установлен
docker_host = os.getenv("DOCKER_HOST", settings.DOCKER_HOST) # Это необходимо, так как Docker SDK может неправильно парсить его
original_docker_host = os.environ.pop("DOCKER_HOST", None)
logger.info(f"Initializing Docker client with DOCKER_HOST: {docker_host}") try:
# Пробуем docker.from_env() без DOCKER_HOST
# Если DOCKER_HOST начинается с unix://, извлекаем путь к socket logger.info("Trying docker.from_env() without DOCKER_HOST")
if docker_host.startswith("unix://"): self._client = docker.from_env()
socket_path = docker_host.replace("unix://", "") self._client.ping()
# Убеждаемся, что путь начинается с / logger.info("Docker client initialized successfully with docker.from_env()")
if not socket_path.startswith("/"): except Exception as e1:
socket_path = "/" + socket_path logger.warning(f"docker.from_env() failed: {e1}")
# Если from_env не работает, пробуем прямой base_url
# 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: 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 = docker.DockerClient(base_url=base_url)
# Проверяем подключение сразу
self._client.ping() self._client.ping()
logger.info(f"Successfully created Docker client with base_url={base_url}") logger.info("Docker client initialized successfully with direct base_url")
except Exception as e: except Exception as e2:
logger.error(f"Failed to create Docker client with base_url={base_url}: {e}") logger.error(f"Direct base_url also failed: {e2}")
# Пробуем альтернативный формат (без префикса unix://) # Последняя попытка - используем APIClient
try: try:
# Некоторые версии SDK могут требовать просто путь logger.info("Trying APIClient as last resort")
# Но это не работает, так как base_url должен быть полный URL api_client = APIClient(base_url="unix:///var/run/docker.sock")
# Поэтому пробуем стандартный формат по умолчанию api_client.version()
logger.warning("Trying default socket path") # Если APIClient работает, создаем DockerClient
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock") self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
self._client.ping() self._client.ping()
logger.info("Successfully created Docker client with default socket") logger.info("Docker client initialized successfully with APIClient")
except Exception as e2: except Exception as e3:
logger.error(f"All methods failed. Last error: {e2}") logger.error(f"All methods failed. Last error: {e3}")
raise raise
elif docker_host.startswith("/"): finally:
# Прямой путь к socket - используем base_url с префиксом unix:// # Восстанавливаем DOCKER_HOST, если он был установлен
base_url = f"unix://{docker_host}" if original_docker_host:
logger.info(f"Using direct socket path: {base_url}") os.environ["DOCKER_HOST"] = original_docker_host
self._client = docker.DockerClient(base_url=base_url)
else:
# Для других форматов (tcp://, http:// и т.д.) используем from_env
# Но сначала проверяем, не установлена ли переменная DOCKER_HOST
if "DOCKER_HOST" in os.environ:
# Если DOCKER_HOST установлен, но не unix://, используем from_env
logger.info("Using docker.from_env()")
self._client = docker.from_env()
else:
# Если DOCKER_HOST не установлен, используем стандартный socket
logger.info("Using default socket: unix:///var/run/docker.sock")
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
# Проверка подключения
self._client.ping()
logger.info("Docker client initialized successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Docker client: {e}") logger.error(f"Failed to initialize Docker client: {e}")
logger.error(f"DOCKER_HOST env: {os.getenv('DOCKER_HOST', 'not set')}") logger.error(f"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 from datetime import datetime
import yaml import yaml
import tempfile import tempfile
import logging
from app.core.config import settings from app.core.config import settings
from app.core.docker_client import DockerClient from app.core.docker_client import DockerClient
logger = logging.getLogger(__name__)
class MoleculeExecutor: class MoleculeExecutor:
"""Выполнение Molecule тестов без использования Makefile""" """Выполнение Molecule тестов без использования Makefile"""
def __init__(self): 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" self.molecule_dir = self.project_root / "molecule" / "default"
# Пресеты теперь находятся в alembic/presets # Пресеты для Molecule должны находиться в molecule/presets
# Находим путь к alembic относительно текущего файла # чтобы create.yml мог их найти по пути /workspace/molecule/presets/
current_file = Path(__file__) # ВАЖНО: presets_dir должен быть относительно project_root, который внутри контейнера = /workspace
alembic_dir = current_file.parent.parent / "alembic" # Это гарантирует, что файлы будут доступны внутри ansible-controller контейнера
self.presets_dir = alembic_dir / "presets" self.presets_dir = Path("/workspace") / "molecule" / "presets"
# Если не найдено, пробуем старый путь (для обратной совместимости)
if not self.presets_dir.exists():
self.presets_dir = self.project_root / "molecule" / "presets"
self.docker_client = DockerClient() self.docker_client = DockerClient()
self._temp_preset_files = {} # Кэш временных файлов preset'ов self._temp_preset_files = {} # Кэш временных файлов preset'ов
@@ -158,7 +222,9 @@ class MoleculeExecutor:
preset_name: str = "default", preset_name: str = "default",
preset_content: Optional[str] = None, preset_content: Optional[str] = None,
preset_category: str = "main", preset_category: str = "main",
stream: bool = False stream: bool = False,
stop_event: Optional[callable] = None,
container_ref: Optional[object] = None
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
Тестирование роли через Molecule Тестирование роли через Molecule
@@ -169,96 +235,496 @@ class MoleculeExecutor:
preset_content: Содержимое preset'а из БД (если None - загружается из файла) preset_content: Содержимое preset'а из БД (если None - загружается из файла)
preset_category: Категория preset'а (main или k8s) preset_category: Категория preset'а (main или k8s)
stream: Если True, возвращает генератор строк для WebSocket stream: Если True, возвращает генератор строк для WebSocket
stop_event: Функция для проверки флага остановки
container_ref: Объект для хранения ссылки на контейнер
Yields: Yields:
Строки вывода команды Строки вывода команды
""" """
# Если preset_content передан, создаем временный файл из БД container = None
if preset_content: 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: try:
self.create_temp_preset_file(preset_name, preset_content, preset_category) # Подготавливаем volumes для монтирования
preset_data = self.load_preset_from_db(preset_content) 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: except Exception as e:
yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n" yield f"❌ Ошибка при запуске контейнера: {str(e)}\n"
return import traceback
else: yield f"Детали: {traceback.format_exc()}\n"
# Проверка существования preset'а в файловой системе # Пытаемся удалить контейнер, если он был создан
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: try:
preset_data = self.load_preset(preset_name, preset_category) if preset_content or preset_name:
except FileNotFoundError as e: await self._cleanup_preset_containers(preset_name, preset_category)
yield f"❌ Ошибка: {str(e)}\n" logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit")
yield "💡 Убедитесь, что preset существует в БД или файловой системе\n" except Exception as e:
return 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 файлов async def _cleanup_preset_containers(self, preset_name: str, preset_category: str = "main"):
yield "🔓 Расшифровка vault файлов...\n" """Очистка контейнеров из preset'а через destroy.yml"""
await self.decrypt_vault_files() 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
# Подготовка переменных окружения for cont in all_containers:
env = { try:
"ANSIBLE_FORCE_COLOR": "1", cont.reload()
"MOLECULE_PRESET": preset_name, cont_name = cont.name
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace", # Проверяем, что контейнер в сети labnet или имеет короткое имя (тестовые контейнеры u1, u2 и т.д.)
"MOLECULE_VAULT_ENABLED": "false" networks = cont.attrs.get("NetworkSettings", {}).get("Networks", {})
} is_test_container = False
if role_name: if "labnet" in networks:
env["MOLECULE_ROLE_NAME"] = role_name is_test_container = True
yield f"📋 Тестируется роль: {role_name}\n" 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}")
# Команда для выполнения в контейнере if removed_count > 0:
docker_cmd = [ logger.info(f"Direct cleanup removed {removed_count} containers")
"docker", "run", "--rm", cleanup_success = True
"--name", f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}", except Exception as direct_cleanup_error:
"-v", f"{self.project_root}:/workspace", logger.error(f"Error in direct cleanup: {direct_cleanup_error}")
"-w", "/workspace",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-u", "root"
]
# Добавляем переменные окружения # Также пытаемся запустить destroy.yml для полной очистки
for key, value in env.items(): try:
docker_cmd.extend(["-e", f"{key}={value}"]) # Запускаем destroy.yml в отдельном контейнере для очистки
env = {
"ANSIBLE_FORCE_COLOR": "1",
"MOLECULE_PRESET": preset_name,
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
"MOLECULE_VAULT_ENABLED": "false"
}
docker_cmd.extend([ cleanup_container_name = f"ansible-cleanup-{datetime.now().strftime('%Y%m%d%H%M%S')}"
"inecs/ansible-lab:ansible-controller-latest",
"bash", "-c", self._build_test_command(role_name)
])
# Запуск процесса volumes = {
process = await asyncio.create_subprocess_exec( str(self.host_project_root): {"bind": "/workspace", "mode": "rw"},
*docker_cmd, "/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
stdout=asyncio.subprocess.PIPE, }
stderr=asyncio.subprocess.STDOUT,
cwd=str(self.project_root)
)
# Потоковый вывод cleanup_container = self.docker_client.client.containers.run(
async for line in process.stdout: image="inecs/ansible-lab:ansible-controller-latest",
yield line.decode('utf-8', errors='replace') 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 файлов if not cleanup_success:
yield "\n🔒 Шифрование vault файлов...\n" logger.warning(f"Cleanup may not have completed successfully for preset {preset_name}")
await self.encrypt_vault_files()
if process.returncode == 0: def _build_test_command(self, role_name: Optional[str] = None, preset_name: Optional[str] = None, preset_category: str = "main") -> str:
yield "\n✅ Тестирование завершено успешно\n"
else:
yield f"\n❌ Тестирование завершено с ошибкой (код: {process.returncode})\n"
def _build_test_command(self, role_name: Optional[str] = None) -> str:
"""Построение команды для тестирования""" """Построение команды для тестирования"""
commands = [ commands = [
"echo -e '\\033[33m=== СОЗДАНИЕ ТЕСТОВЫХ КОНТЕЙНЕРОВ ==='", "echo -e '\\033[33m=== СОЗДАНИЕ ТЕСТОВЫХ КОНТЕЙНЕРОВ ==='",
"echo ''", "echo ''",
"mkdir -p /tmp/molecule_workspace/inventory", "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", "cd molecule/default",
"ansible-playbook -i localhost, create.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace", "ansible-playbook -i localhost, create.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace",
"echo ''", "echo ''",
@@ -276,7 +742,7 @@ class MoleculeExecutor:
"echo ''", "echo ''",
"echo -e '\\033[33m=== ЗАПУСК ROLES/DEPLOY.YML НА ТЕСТОВЫХ КОНТЕЙНЕРАХ ===\\033[0m'", "echo -e '\\033[33m=== ЗАПУСК ROLES/DEPLOY.YML НА ТЕСТОВЫХ КОНТЕЙНЕРАХ ===\\033[0m'",
"echo ''" "echo ''"
] ])
# Добавляем команду для deploy.yml с фильтрацией по роли # Добавляем команду для deploy.yml с фильтрацией по роли
if role_name: if role_name:

View File

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

View File

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

View File

@@ -61,65 +61,86 @@ def load_presets():
skipped_count = 0 skipped_count = 0
error_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(): if presets_dir.exists():
for preset_file in presets_dir.glob("*.yml"): for preset_file in presets_dir.glob("*.yml"):
if preset_file.name == "deploy.yml": if preset_file.name == "deploy.yml":
continue continue
result = import_preset(preset_file, category='main')
try: if result == 'loaded':
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}")
loaded_count += 1 loaded_count += 1
except Exception as e: elif result == 'skipped':
print(f"❌ Ошибка при загрузке {preset_file.name}: {e}") 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 error_count += 1
# K8s preset'ы # K8s preset'ы

View File

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

View File

@@ -91,10 +91,7 @@
<i class="fas fa-info-circle"></i> <i class="fas fa-info-circle"></i>
</a> </a>
<button <button
hx-delete="/api/v1/dockerfiles/{{ dockerfile.id }}" onclick="deleteDockerfile({{ dockerfile.id }}, '{{ dockerfile.name }}', this)"
hx-confirm="Удалить Dockerfile '{{ dockerfile.name }}'?"
hx-target="closest tr"
hx-swap="outerHTML"
class="btn btn-outline-danger" class="btn btn-outline-danger"
title="Удалить" title="Удалить"
> >
@@ -119,4 +116,89 @@
{% endif %} {% endif %}
</div> </div>
</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 %} {% endblock %}

View File

@@ -31,10 +31,7 @@
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<button <button
hx-delete="/api/v1/playbooks/{{ playbook.id }}" onclick="deletePlaybook({{ playbook.id }}, '{{ playbook.name }}', this)"
hx-confirm="Удалить playbook '{{ playbook.name }}'?"
hx-target="closest .col-12"
hx-swap="outerHTML"
class="btn btn-sm btn-outline-danger" class="btn btn-sm btn-outline-danger"
title="Удалить" title="Удалить"
> >
@@ -93,4 +90,89 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </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 %} {% endblock %}

View File

@@ -11,11 +11,10 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div x-data="presetCreator()"> <div x-data="presetCreator()" x-init="init()">
<form <form
hx-post="/api/v1/presets/create" hx-post="/api/v1/presets/create"
hx-target="#result" hx-swap="none"
hx-swap="innerHTML"
@submit.prevent="submitForm" @submit.prevent="submitForm"
class="card" class="card"
> >
@@ -60,6 +59,89 @@
</div> </div>
</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"> <div class="card-header">
<h5 class="mb-0">Хосты</h5> <h5 class="mb-0">Хосты</h5>
</div> </div>
@@ -84,18 +166,10 @@
x-model="host.family" x-model="host.family"
class="form-select" class="form-select"
> >
<option value="ubuntu20">Ubuntu 20</option> <option value="">Выберите образ...</option>
<option value="ubuntu22">Ubuntu 22</option> <template x-for="(image, key) in formData.images" :key="key">
<option value="ubuntu24">Ubuntu 24</option> <option :value="key" x-text="key"></option>
<option value="debian11">Debian 11</option> </template>
<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>
</select> </select>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
@@ -134,15 +208,89 @@
</button> </button>
</div> </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 <input
type="hidden" type="hidden"
name="hosts" name="hosts"
:value="JSON.stringify(formData.hosts.map(h => ({name: h.name, family: h.family, groups: h.groups})))" :value="JSON.stringify(formData.hosts.map(h => ({name: h.name, family: h.family, groups: h.groups})))"
> >
<input
<!-- Результат --> type="hidden"
<div id="result" class="card-body border-top"></div> 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"> <div class="card-footer">
@@ -169,15 +317,43 @@ function presetCreator() {
category: 'main', category: 'main',
hosts: [{ hosts: [{
name: 'u1', name: 'u1',
family: 'ubuntu22', family: '',
groups_str: 'test, web', groups_str: 'test, web',
groups: ['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() { addHost() {
this.formData.hosts.push({ this.formData.hosts.push({
name: `u${this.formData.hosts.length + 1}`, name: `u${this.formData.hosts.length + 1}`,
family: 'ubuntu22', family: '',
groups_str: 'test', groups_str: 'test',
groups: ['test'] groups: ['test']
}); });
@@ -189,10 +365,99 @@ function presetCreator() {
const host = this.formData.hosts[index]; const host = this.formData.hosts[index];
host.groups = host.groups_str.split(',').map(g => g.trim()).filter(g => g); 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) { submitForm(event) {
// Обновляем массивы перед отправкой
this.updateVolumes();
this.updateTmpfs();
this.updateCapabilities();
// HTMX обработает отправку // 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> </script>
{% endblock %} {% endblock %}

View File

@@ -4,39 +4,17 @@
{% block page_title %}Preset: {{ preset.name }}{% endblock %} {% block page_title %}Preset: {{ preset.name }}{% endblock %}
{% block header_actions %} {% block header_actions %}
<div class="btn-group"> <button
<button type="button"
type="button" class="btn btn-success btn-sm"
class="btn btn-success btn-sm" onclick="openPresetTestModal()"
onclick="startPresetTest()" title="Запустить тест preset'а"
title="Запустить тест preset'а" data-bs-toggle="modal"
> data-bs-target="#presetTestModal"
<i class="fas fa-play me-2"></i> >
Запустить <i class="fas fa-play me-2"></i>
</button> Запустить
<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>
<a href="/presets/{{ preset.name }}/edit?category={{ preset.category }}" class="btn btn-primary btn-sm"> <a href="/presets/{{ preset.name }}/edit?category={{ preset.category }}" class="btn btn-primary btn-sm">
<i class="fas fa-edit me-2"></i> <i class="fas fa-edit me-2"></i>
Редактировать Редактировать
@@ -49,7 +27,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-12 col-lg-8"> <div class="col-12">
<!-- Информация о preset'е --> <!-- Информация о preset'е -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
@@ -83,7 +61,7 @@
<div class="mb-3"> <div class="mb-3">
<strong>Хосты:</strong> <strong>Хосты:</strong>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-bordered"> <table class="table table-sm table-bordered w-100">
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Имя</th>
@@ -144,24 +122,65 @@
</div> </div>
</div> </div>
<div class="col-12 col-lg-4"> </div>
<!-- Логи тестирования -->
<div class="card" id="test-logs-card" style="display: none;"> <!-- Модальное окно для тестирования preset'а -->
<div class="card-header d-flex justify-content-between align-items-center"> <div class="modal fade" id="presetTestModal" tabindex="-1" aria-labelledby="presetTestModalLabel" aria-hidden="true">
<h5 class="mb-0">Логи тестирования</h5> <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 <button
type="button" type="button"
class="btn btn-sm btn-outline-light" class="btn btn-warning"
onclick="clearTestLogs()" 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="Очистить логи" 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> </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> </div>
</div> </div>
@@ -172,31 +191,59 @@
<script> <script>
let testWebSocket = null; let testWebSocket = null;
let testRunning = false; 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) { if (testRunning) {
alert('Тест уже запущен'); alert('Тест уже запущен');
return; return;
} }
// Показываем логи // Очищаем логи
const logsCard = document.getElementById('test-logs-card'); const logsContainer = document.getElementById('preset-test-logs');
const logsContainer = document.getElementById('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>';
logsCard.style.display = 'block';
// Показываем модальное окно
presetTestModal.show();
// Запускаем тест
setTimeout(() => {
startPresetTest();
}, 300);
}
function startPresetTest() {
if (testRunning) {
return;
}
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = ''; logsContainer.innerHTML = '';
// Показываем кнопки управления // Показываем кнопки управления
document.getElementById('stop-btn').style.display = 'inline-block'; document.getElementById('modal-stop-btn').disabled = false;
document.getElementById('restart-btn').style.display = 'inline-block'; document.getElementById('modal-restart-btn').disabled = false;
testRunning = true; testRunning = true;
// Создаем WebSocket подключение // Создаем WebSocket подключение
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 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; testWebSocket = ws;
ws.onopen = () => { ws.onopen = () => {
addLog('info', '🔌 Подключено к серверу');
ws.send(JSON.stringify({ ws.send(JSON.stringify({
action: 'start', action: 'start',
preset_name: '{{ preset.name }}', preset_name: '{{ preset.name }}',
@@ -205,64 +252,195 @@ function startPresetTest() {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); try {
const data = JSON.parse(event.data);
if (data.type === 'log' || data.type === 'info') { if (data.type === 'log' || data.type === 'info') {
const logLine = document.createElement('div'); addLog(data.level || 'info', data.data);
logLine.className = `log-line log-${data.level || 'info'}`; } else if (data.type === 'error') {
logLine.textContent = data.data; addLog('error', data.data);
logsContainer.appendChild(logLine); } else if (data.type === 'complete') {
logsContainer.scrollTop = logsContainer.scrollHeight; addLog('info', data.data || '✅ Тестирование завершено');
} else if (data.type === 'error') { testRunning = false;
const errorLine = document.createElement('div'); document.getElementById('modal-stop-btn').disabled = true;
errorLine.className = 'log-line log-error'; document.getElementById('modal-restart-btn').disabled = false;
errorLine.textContent = data.data; ws.close();
logsContainer.appendChild(errorLine); }
} else if (data.type === 'complete') { } catch (e) {
const completeLine = document.createElement('div'); console.error('Ошибка парсинга сообщения:', e);
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();
} }
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
const errorLine = document.createElement('div'); addLog('error', `❌ Ошибка подключения WebSocket`);
errorLine.className = 'log-line log-error';
errorLine.textContent = `❌ Ошибка подключения: ${error}`;
logsContainer.appendChild(errorLine);
testRunning = false; testRunning = false;
document.getElementById('modal-stop-btn').disabled = true;
}; };
ws.onclose = () => { ws.onclose = () => {
testRunning = false; testRunning = false;
console.log('WebSocket закрыт'); if (testWebSocket === ws) {
testWebSocket = null;
}
}; };
} }
function stopPresetTest() { function stopPresetTest() {
if (testWebSocket && testWebSocket.readyState === WebSocket.OPEN) { if (testWebSocket && testWebSocket.readyState === WebSocket.OPEN) {
testWebSocket.send(JSON.stringify({ action: 'stop' })); testWebSocket.send(JSON.stringify({ action: 'stop' }));
addLog('info', '⏹️ Остановка тестирования...');
}
if (testWebSocket) {
testWebSocket.close(); testWebSocket.close();
testWebSocket = null;
} }
testRunning = false; testRunning = false;
document.getElementById('stop-btn').style.display = 'none'; document.getElementById('modal-stop-btn').disabled = true;
document.getElementById('restart-btn').style.display = 'none'; document.getElementById('modal-restart-btn').disabled = false;
} }
function restartPresetTest() { function restartPresetTest() {
stopPresetTest(); stopPresetTest();
const logsContainer = document.getElementById('preset-test-logs');
logsContainer.innerHTML = '';
setTimeout(() => { setTimeout(() => {
startPresetTest(); startPresetTest();
}, 1000); }, 500);
} }
function clearTestLogs() { function clearPresetTestLogs() {
document.getElementById('test-logs').innerHTML = ''; 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> </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 %} {% endblock %}

View File

@@ -14,8 +14,7 @@
<div x-data="presetEditor()"> <div x-data="presetEditor()">
<form <form
hx-post="/api/v1/presets/{{ preset.name }}/update?category={{ preset.category }}" hx-post="/api/v1/presets/{{ preset.name }}/update?category={{ preset.category }}"
hx-target="#result" hx-swap="none"
hx-swap="innerHTML"
@submit.prevent="submitForm" @submit.prevent="submitForm"
class="card" class="card"
> >
@@ -374,9 +373,6 @@
> >
{% endif %} {% endif %}
<!-- Результат -->
<div id="result" class="card-body border-top"></div>
<!-- Кнопки --> <!-- Кнопки -->
<div class="card-footer"> <div class="card-footer">
<div class="d-flex gap-2"> <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> </script>
{% endblock %} {% endblock %}

View File

@@ -115,10 +115,7 @@
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<button <button
hx-delete="/api/v1/presets/{{ preset.name }}?category={{ preset.category or 'main' }}" onclick="deletePreset('{{ preset.name }}', '{{ preset.category or 'main' }}', this)"
hx-confirm="Удалить preset '{{ preset.name }}'?"
hx-target="closest tr"
hx-swap="outerHTML"
class="btn btn-outline-danger" class="btn btn-outline-danger"
title="Удалить" title="Удалить"
> >
@@ -225,3 +222,95 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% 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"> <div class="card-body">
<form <form
hx-post="/api/v1/profile/docker-settings" hx-post="/api/v1/profile/docker-settings"
hx-target="#docker-result" hx-swap="none"
hx-swap="innerHTML"
> >
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Имя пользователя</label> <label class="form-label">Имя пользователя</label>
@@ -66,8 +65,6 @@
</div> </div>
</div> </div>
<div id="docker-result" class="mb-3"></div>
<button type="submit" class="btn btn-primary w-100"> <button type="submit" class="btn btn-primary w-100">
<i class="fas fa-save me-2"></i> <i class="fas fa-save me-2"></i>
Сохранить настройки Docker Hub Сохранить настройки Docker Hub
@@ -89,8 +86,7 @@
<div class="card-body"> <div class="card-body">
<form <form
hx-post="/api/v1/profile/docker-settings" hx-post="/api/v1/profile/docker-settings"
hx-target="#harbor-result" hx-swap="none"
hx-swap="innerHTML"
> >
<div class="mb-3"> <div class="mb-3">
<label class="form-label">URL Harbor</label> <label class="form-label">URL Harbor</label>
@@ -139,8 +135,6 @@
</div> </div>
</div> </div>
<div id="harbor-result" class="mb-3"></div>
<button type="submit" class="btn btn-primary w-100"> <button type="submit" class="btn btn-primary w-100">
<i class="fas fa-save me-2"></i> <i class="fas fa-save me-2"></i>
Сохранить настройки Harbor Сохранить настройки Harbor
@@ -150,4 +144,57 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -255,8 +255,7 @@
<form <form
hx-post="/api/v1/profile" hx-post="/api/v1/profile"
hx-target="#result" hx-swap="none"
hx-swap="innerHTML"
> >
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Имя пользователя</label> <label class="form-label">Имя пользователя</label>
@@ -292,8 +291,6 @@
> >
</div> </div>
<div id="result" class="mb-3"></div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i> <i class="fas fa-save me-2"></i>
@@ -318,20 +315,48 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Обработка успешного сохранения профиля // Обработка успешного сохранения профиля
document.body.addEventListener('htmx:afterRequest', function(event) { document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id === 'result' && event.detail.xhr.status === 200) { if (event.detail.path === '/api/v1/profile' && event.detail.xhr.status === 200) {
const response = JSON.parse(event.detail.xhr.responseText); try {
if (response.success) { 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">' + if (window.showMessageModal) {
'<i class="fas fa-check-circle me-2"></i>' + response.message + window.showMessageModal(
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>' + response.message || 'Профиль успешно обновлен',
'</div>'; 'success',
'Успешно',
// Обновляем страницу через 1.5 секунды function() {
setTimeout(() => { // После закрытия модального окна обновляем страницу
location.reload(); location.reload();
}, 1500); }
);
} 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 ingress_host_https_port: 8445
images: images:
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy" debian: "inecs/ansible-lab:ubuntu22-latest"
rhel: "quay.io/centos/centos:stream9-systemd" ubuntu: "inecs/ansible-lab:ubuntu22-latest"
rhel: "inecs/ansible-lab:rhel-latest"
centos: "inecs/ansible-lab:centos9-latest"
systemd_defaults: systemd_defaults:
privileged: true 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]