Исправление синтаксической ошибки в 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:
@@ -22,6 +22,8 @@ help:
|
||||
@echo " make shell - Открыть shell в контейнере web"
|
||||
@echo " make clean - Очистить контейнеры и volumes"
|
||||
@echo " make rebuild - Пересобрать и перезапустить"
|
||||
@echo " make migrate - Применить миграции БД"
|
||||
@echo " make load-presets - Импортировать пресеты из файловой системы"
|
||||
|
||||
build:
|
||||
$(COMPOSE) build
|
||||
@@ -58,3 +60,9 @@ clean:
|
||||
|
||||
status:
|
||||
$(COMPOSE) ps
|
||||
|
||||
migrate:
|
||||
$(COMPOSE) exec web bash -c "cd /app/app && alembic upgrade head"
|
||||
|
||||
load-presets:
|
||||
$(COMPOSE) exec web bash -c "cd /app/app && python scripts/load_presets.py"
|
||||
|
||||
@@ -29,8 +29,10 @@ kind_clusters:
|
||||
ingress_host_https_port: 8443
|
||||
|
||||
images:
|
||||
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
|
||||
rhel: "quay.io/centos/centos:stream9-systemd"
|
||||
debian: "inecs/ansible-lab:ubuntu22-latest"
|
||||
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
|
||||
rhel: "inecs/ansible-lab:rhel-latest"
|
||||
centos: "inecs/ansible-lab:centos9-latest"
|
||||
|
||||
systemd_defaults:
|
||||
privileged: true
|
||||
|
||||
@@ -47,8 +47,10 @@ kind_clusters:
|
||||
ingress_host_https_port: 8445
|
||||
|
||||
images:
|
||||
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
|
||||
rhel: "quay.io/centos/centos:stream9-systemd"
|
||||
debian: "inecs/ansible-lab:ubuntu22-latest"
|
||||
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
|
||||
rhel: "inecs/ansible-lab:rhel-latest"
|
||||
centos: "inecs/ansible-lab:centos9-latest"
|
||||
|
||||
systemd_defaults:
|
||||
privileged: true
|
||||
|
||||
@@ -24,8 +24,10 @@ kind_clusters:
|
||||
ingress_host_https_port: 8443
|
||||
|
||||
images:
|
||||
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
|
||||
rhel: "quay.io/centos/centos:stream9-systemd"
|
||||
debian: "inecs/ansible-lab:ubuntu22-latest"
|
||||
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
|
||||
rhel: "inecs/ansible-lab:rhel-latest"
|
||||
centos: "inecs/ansible-lab:centos9-latest"
|
||||
|
||||
systemd_defaults:
|
||||
privileged: true
|
||||
|
||||
@@ -39,59 +39,70 @@ def upgrade():
|
||||
presets_dir = old_presets_dir
|
||||
k8s_presets_dir = presets_dir / "k8s"
|
||||
|
||||
# Основные preset'ы
|
||||
# Функция для импорта preset'а
|
||||
def import_preset(preset_file, category='main'):
|
||||
try:
|
||||
with open(preset_file) as f:
|
||||
content = f.read()
|
||||
preset_data = yaml.safe_load(content) or {}
|
||||
|
||||
# Извлечение описания из комментария
|
||||
description = None
|
||||
for line in content.split('\n'):
|
||||
if line.strip().startswith('#description:'):
|
||||
description = line.split('#description:')[1].strip()
|
||||
break
|
||||
|
||||
# Проверка существования в БД
|
||||
result = connection.execute(
|
||||
sa.text("SELECT id FROM presets WHERE name = :name"),
|
||||
{"name": preset_file.stem}
|
||||
)
|
||||
if result.fetchone():
|
||||
return False
|
||||
|
||||
# Преобразуем dict/list в JSON строки для PostgreSQL
|
||||
hosts_json = json.dumps(preset_data.get('hosts', []))
|
||||
images_json = json.dumps(preset_data.get('images', {}))
|
||||
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
|
||||
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
|
||||
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
|
||||
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
|
||||
"""),
|
||||
{
|
||||
'name': preset_file.stem,
|
||||
'category': category,
|
||||
'description': description,
|
||||
'content': content,
|
||||
'docker_network': preset_data.get('docker_network'),
|
||||
'hosts': hosts_json,
|
||||
'images': images_json,
|
||||
'systemd_defaults': systemd_defaults_json,
|
||||
'kind_clusters': kind_clusters_json,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Ошибка при импорте preset {preset_file.name}: {e}")
|
||||
return False
|
||||
|
||||
# Основные preset'ы из корня папки presets
|
||||
if presets_dir.exists():
|
||||
for preset_file in presets_dir.glob("*.yml"):
|
||||
if preset_file.name == "deploy.yml":
|
||||
continue
|
||||
import_preset(preset_file, category='main')
|
||||
|
||||
try:
|
||||
with open(preset_file) as f:
|
||||
content = f.read()
|
||||
preset_data = yaml.safe_load(content) or {}
|
||||
|
||||
# Извлечение описания из комментария
|
||||
description = None
|
||||
for line in content.split('\n'):
|
||||
if line.strip().startswith('#description:'):
|
||||
description = line.split('#description:')[1].strip()
|
||||
break
|
||||
|
||||
# Проверка существования в БД
|
||||
result = connection.execute(
|
||||
sa.text("SELECT id FROM presets WHERE name = :name"),
|
||||
{"name": preset_file.stem}
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
# Преобразуем dict/list в JSON строки для PostgreSQL
|
||||
hosts_json = json.dumps(preset_data.get('hosts', []))
|
||||
images_json = json.dumps(preset_data.get('images', {}))
|
||||
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
|
||||
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
|
||||
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
|
||||
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
|
||||
"""),
|
||||
{
|
||||
'name': preset_file.stem,
|
||||
'category': 'main',
|
||||
'description': description,
|
||||
'content': content,
|
||||
'docker_network': preset_data.get('docker_network'),
|
||||
'hosts': hosts_json,
|
||||
'images': images_json,
|
||||
'systemd_defaults': systemd_defaults_json,
|
||||
'kind_clusters': kind_clusters_json,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при импорте preset {preset_file.name}: {e}")
|
||||
# Пресеты из папки examples
|
||||
examples_dir = presets_dir / "examples"
|
||||
if examples_dir.exists():
|
||||
for preset_file in examples_dir.glob("*.yml"):
|
||||
import_preset(preset_file, category='main')
|
||||
|
||||
# K8s preset'ы
|
||||
if k8s_presets_dir.exists():
|
||||
|
||||
@@ -313,11 +313,24 @@ async def delete_dockerfile(
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Удаление Dockerfile"""
|
||||
# Получаем имя Dockerfile до удаления
|
||||
dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id)
|
||||
if not dockerfile:
|
||||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||||
|
||||
dockerfile_name = dockerfile.name
|
||||
|
||||
# Удаляем Dockerfile
|
||||
deleted = await DockerfileService.delete_dockerfile(db, dockerfile_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Dockerfile не найден")
|
||||
|
||||
return {"message": "Dockerfile удален успешно"}
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"dockerfile_id": dockerfile_id,
|
||||
"dockerfile_name": dockerfile_name,
|
||||
"message": f"Dockerfile '{dockerfile_name}' успешно удален"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/v1/dockerfiles")
|
||||
|
||||
@@ -206,11 +206,24 @@ async def delete_playbook(
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Удаление playbook"""
|
||||
# Получаем имя playbook до удаления
|
||||
playbook = await PlaybookService.get_playbook(db, playbook_id)
|
||||
if not playbook:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
playbook_name = playbook.name
|
||||
|
||||
# Удаляем playbook
|
||||
deleted = await PlaybookService.delete_playbook(db, playbook_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Playbook не найден")
|
||||
|
||||
return {"message": "Playbook удален успешно"}
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"playbook_id": playbook_id,
|
||||
"playbook_name": playbook_name,
|
||||
"message": f"Playbook '{playbook_name}' успешно удален"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/v1/playbooks")
|
||||
|
||||
@@ -11,10 +11,14 @@ from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import yaml
|
||||
import json
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.services.preset_service import PresetService
|
||||
from app.db.session import get_async_db
|
||||
from app.auth.deps import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
templates_path = Path(__file__).parent.parent.parent.parent / "templates"
|
||||
@@ -75,11 +79,34 @@ async def get_presets_api(
|
||||
|
||||
|
||||
@router.get("/presets/create", response_class=HTMLResponse)
|
||||
async def create_preset_page(request: Request):
|
||||
async def create_preset_page(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""Страница создания preset'а"""
|
||||
from app.services.dockerfile_service import DockerfileService
|
||||
|
||||
# Загружаем список Dockerfiles из БД
|
||||
dockerfiles = await DockerfileService.list_dockerfiles(db, status="active")
|
||||
dockerfiles_list = [
|
||||
{
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"description": d.description,
|
||||
"base_image": d.base_image,
|
||||
"tags": d.tags,
|
||||
"status": d.status
|
||||
}
|
||||
for d in dockerfiles
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/presets/create.html",
|
||||
{"request": request}
|
||||
{
|
||||
"request": request,
|
||||
"dockerfiles": dockerfiles_list
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -129,6 +156,8 @@ async def create_preset_api(
|
||||
description: str = Form(""),
|
||||
category: str = Form("main"),
|
||||
hosts: str = Form(""),
|
||||
images: str = Form(""),
|
||||
systemd_defaults: str = Form(""),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""API endpoint для создания preset'а"""
|
||||
@@ -137,12 +166,22 @@ async def create_preset_api(
|
||||
if hosts:
|
||||
hosts_list = json.loads(hosts)
|
||||
|
||||
images_dict = {}
|
||||
if images:
|
||||
images_dict = json.loads(images)
|
||||
|
||||
systemd_defaults_dict = {}
|
||||
if systemd_defaults:
|
||||
systemd_defaults_dict = json.loads(systemd_defaults)
|
||||
|
||||
preset = await PresetService.create_preset(
|
||||
db=db,
|
||||
preset_name=preset_name,
|
||||
description=description,
|
||||
hosts=hosts_list,
|
||||
category=category
|
||||
category=category,
|
||||
images=images_dict,
|
||||
systemd_defaults=systemd_defaults_dict
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
@@ -270,6 +309,19 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
|
||||
"""WebSocket для live логов тестирования preset'а"""
|
||||
await websocket.accept()
|
||||
|
||||
# Используем класс для хранения ссылки на контейнер
|
||||
class ContainerRef:
|
||||
def __init__(self):
|
||||
self.container = None
|
||||
def set(self, container):
|
||||
self.container = container
|
||||
def get(self):
|
||||
return self.container
|
||||
|
||||
container_ref = ContainerRef()
|
||||
executor = None
|
||||
stop_requested = False
|
||||
|
||||
try:
|
||||
# Получаем preset из БД
|
||||
async for db in get_async_db():
|
||||
@@ -285,18 +337,6 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
|
||||
preset_content = preset.content
|
||||
break
|
||||
|
||||
# Получаем действие от клиента
|
||||
data = await websocket.receive_json()
|
||||
action = data.get("action", "start")
|
||||
|
||||
if action == "stop":
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": "⏹️ Остановка тестирования..."
|
||||
})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Запуск тестирования preset'а
|
||||
from app.core.molecule_executor import MoleculeExecutor
|
||||
executor = MoleculeExecutor()
|
||||
@@ -309,34 +349,99 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
|
||||
"data": f"🚀 Запуск тестирования preset'а '{preset_name}'..."
|
||||
})
|
||||
|
||||
# Создаем задачу для мониторинга сообщений от клиента (стоп)
|
||||
import asyncio
|
||||
async def monitor_stop():
|
||||
nonlocal stop_requested
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_json(), timeout=1.0)
|
||||
action = data.get("action")
|
||||
if action == "stop":
|
||||
stop_requested = True
|
||||
cont = container_ref.get()
|
||||
if cont:
|
||||
try:
|
||||
cont.stop()
|
||||
await websocket.send_json({
|
||||
"type": "info",
|
||||
"data": "⏹️ Остановка контейнера..."
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping container: {e}")
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except WebSocketDisconnect:
|
||||
stop_requested = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
monitor_task = asyncio.create_task(monitor_stop())
|
||||
|
||||
# Запускаем тест (без указания роли - тестируем все роли)
|
||||
async for line in executor.test_role(
|
||||
role_name=None,
|
||||
preset_name=preset_name,
|
||||
preset_content=preset_content,
|
||||
preset_category=category,
|
||||
stream=True
|
||||
):
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
async for line in executor.test_role(
|
||||
role_name=None,
|
||||
preset_name=preset_name,
|
||||
preset_content=preset_content,
|
||||
preset_category=category,
|
||||
stream=True,
|
||||
stop_event=lambda: stop_requested,
|
||||
container_ref=container_ref
|
||||
):
|
||||
if stop_requested:
|
||||
break
|
||||
|
||||
log_type = executor.detect_log_level(line)
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "log",
|
||||
"level": log_type,
|
||||
"data": line
|
||||
})
|
||||
log_type = executor.detect_log_level(line)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"status": "success",
|
||||
"data": "✅ Тестирование preset'а завершено"
|
||||
})
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "log",
|
||||
"level": log_type,
|
||||
"data": line
|
||||
})
|
||||
except (WebSocketDisconnect, Exception) as e:
|
||||
# Соединение закрыто - не пытаемся больше отправлять
|
||||
stop_requested = True
|
||||
logger.debug(f"WebSocket closed during log send: {e}")
|
||||
break
|
||||
|
||||
# Отправляем финальное сообщение только если соединение открыто
|
||||
try:
|
||||
if not stop_requested:
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"status": "success",
|
||||
"data": "✅ Тестирование preset'а завершено"
|
||||
})
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"status": "stopped",
|
||||
"data": "⏹️ Тестирование остановлено пользователем"
|
||||
})
|
||||
except (WebSocketDisconnect, Exception):
|
||||
# Соединение уже закрыто - это нормально
|
||||
pass
|
||||
except GeneratorExit:
|
||||
# Генератор закрыт, это нормально при закрытии WebSocket
|
||||
stop_requested = True
|
||||
finally:
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Удаляем временный файл
|
||||
if preset_name in executor._temp_preset_files:
|
||||
if executor and preset_name in executor._temp_preset_files:
|
||||
try:
|
||||
executor._temp_preset_files[preset_name].unlink()
|
||||
del executor._temp_preset_files[preset_name]
|
||||
@@ -344,14 +449,22 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
# Соединение закрыто клиентом - это нормально
|
||||
pass
|
||||
except GeneratorExit:
|
||||
# Генератор закрыт - это нормально
|
||||
pass
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"❌ Ошибка: {str(e)}\n{traceback.format_exc()}"
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": error_msg
|
||||
})
|
||||
error_msg = f"❌ Ошибка: {str(e)}"
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": error_msg
|
||||
})
|
||||
except:
|
||||
pass
|
||||
logger.error(f"Error in preset_test_websocket: {e}\n{traceback.format_exc()}")
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
|
||||
@@ -5,6 +5,7 @@ Docker клиент для управления контейнерами
|
||||
"""
|
||||
|
||||
import docker
|
||||
from docker import APIClient
|
||||
import os
|
||||
from typing import List, Dict, Optional
|
||||
from app.core.config import settings
|
||||
@@ -25,65 +26,44 @@ class DockerClient:
|
||||
"""Ленивая инициализация Docker клиента"""
|
||||
if self._client is None:
|
||||
try:
|
||||
# Получаем DOCKER_HOST из настроек или окружения
|
||||
docker_host = os.getenv("DOCKER_HOST", settings.DOCKER_HOST)
|
||||
# Временно удаляем DOCKER_HOST из окружения, если он установлен
|
||||
# Это необходимо, так как Docker SDK может неправильно парсить его
|
||||
original_docker_host = os.environ.pop("DOCKER_HOST", None)
|
||||
|
||||
logger.info(f"Initializing Docker client with DOCKER_HOST: {docker_host}")
|
||||
|
||||
# Если DOCKER_HOST начинается с unix://, извлекаем путь к socket
|
||||
if docker_host.startswith("unix://"):
|
||||
socket_path = docker_host.replace("unix://", "")
|
||||
# Убеждаемся, что путь начинается с /
|
||||
if not socket_path.startswith("/"):
|
||||
socket_path = "/" + socket_path
|
||||
|
||||
# Docker SDK для unix socket ожидает base_url в формате "unix:///path/to/socket"
|
||||
# Важно: после unix:// должно быть три слэша (unix:///)
|
||||
# Например: "unix:///var/run/docker.sock"
|
||||
base_url = f"unix://{socket_path}"
|
||||
logger.info(f"Using unix socket: base_url={base_url}, socket_path={socket_path}")
|
||||
|
||||
# НЕ используем docker.from_env() для unix socket, так как он неправильно парсит формат
|
||||
# Используем только прямой base_url
|
||||
try:
|
||||
# Пробуем docker.from_env() без DOCKER_HOST
|
||||
logger.info("Trying docker.from_env() without DOCKER_HOST")
|
||||
self._client = docker.from_env()
|
||||
self._client.ping()
|
||||
logger.info("Docker client initialized successfully with docker.from_env()")
|
||||
except Exception as e1:
|
||||
logger.warning(f"docker.from_env() failed: {e1}")
|
||||
# Если from_env не работает, пробуем прямой base_url
|
||||
try:
|
||||
# Используем прямой путь к Docker socket
|
||||
base_url = "unix:///var/run/docker.sock"
|
||||
logger.info(f"Trying direct base_url: {base_url}")
|
||||
self._client = docker.DockerClient(base_url=base_url)
|
||||
# Проверяем подключение сразу
|
||||
self._client.ping()
|
||||
logger.info(f"Successfully created Docker client with base_url={base_url}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Docker client with base_url={base_url}: {e}")
|
||||
# Пробуем альтернативный формат (без префикса unix://)
|
||||
logger.info("Docker client initialized successfully with direct base_url")
|
||||
except Exception as e2:
|
||||
logger.error(f"Direct base_url also failed: {e2}")
|
||||
# Последняя попытка - используем APIClient
|
||||
try:
|
||||
# Некоторые версии SDK могут требовать просто путь
|
||||
# Но это не работает, так как base_url должен быть полный URL
|
||||
# Поэтому пробуем стандартный формат по умолчанию
|
||||
logger.warning("Trying default socket path")
|
||||
logger.info("Trying APIClient as last resort")
|
||||
api_client = APIClient(base_url="unix:///var/run/docker.sock")
|
||||
api_client.version()
|
||||
# Если APIClient работает, создаем DockerClient
|
||||
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
|
||||
self._client.ping()
|
||||
logger.info("Successfully created Docker client with default socket")
|
||||
except Exception as e2:
|
||||
logger.error(f"All methods failed. Last error: {e2}")
|
||||
logger.info("Docker client initialized successfully with APIClient")
|
||||
except Exception as e3:
|
||||
logger.error(f"All methods failed. Last error: {e3}")
|
||||
raise
|
||||
elif docker_host.startswith("/"):
|
||||
# Прямой путь к socket - используем base_url с префиксом unix://
|
||||
base_url = f"unix://{docker_host}"
|
||||
logger.info(f"Using direct socket path: {base_url}")
|
||||
self._client = docker.DockerClient(base_url=base_url)
|
||||
else:
|
||||
# Для других форматов (tcp://, http:// и т.д.) используем from_env
|
||||
# Но сначала проверяем, не установлена ли переменная DOCKER_HOST
|
||||
if "DOCKER_HOST" in os.environ:
|
||||
# Если DOCKER_HOST установлен, но не unix://, используем from_env
|
||||
logger.info("Using docker.from_env()")
|
||||
self._client = docker.from_env()
|
||||
else:
|
||||
# Если DOCKER_HOST не установлен, используем стандартный socket
|
||||
logger.info("Using default socket: unix:///var/run/docker.sock")
|
||||
self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
|
||||
|
||||
# Проверка подключения
|
||||
self._client.ping()
|
||||
logger.info("Docker client initialized successfully")
|
||||
finally:
|
||||
# Восстанавливаем DOCKER_HOST, если он был установлен
|
||||
if original_docker_host:
|
||||
os.environ["DOCKER_HOST"] = original_docker_host
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Docker client: {e}")
|
||||
logger.error(f"DOCKER_HOST env: {os.getenv('DOCKER_HOST', 'not set')}")
|
||||
|
||||
@@ -11,24 +11,88 @@ from typing import Optional, AsyncGenerator, Dict
|
||||
from datetime import datetime
|
||||
import yaml
|
||||
import tempfile
|
||||
import logging
|
||||
from app.core.config import settings
|
||||
from app.core.docker_client import DockerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MoleculeExecutor:
|
||||
"""Выполнение Molecule тестов без использования Makefile"""
|
||||
|
||||
def __init__(self):
|
||||
self.project_root = settings.PROJECT_ROOT
|
||||
# Определяем реальный путь к проекту для монтирования в docker run
|
||||
# В docker-compose.yml проект монтируется как ../:/workspace:rw
|
||||
# Когда мы запускаем docker run из контейнера, Docker Desktop на macOS
|
||||
# не знает о пути /workspace внутри контейнера. Нужно использовать реальный путь на хосте.
|
||||
project_root = settings.PROJECT_ROOT
|
||||
|
||||
# Если PROJECT_ROOT = /workspace (внутри контейнера), но мы запускаем docker run,
|
||||
# нужно использовать реальный путь на хосте.
|
||||
# В docker-compose.yml монтируется ../:/workspace, значит на хосте это родительская директория от app/
|
||||
if str(project_root) == "/workspace":
|
||||
# Получаем путь к app/ и поднимаемся на уровень выше (это и есть реальный путь на хосте)
|
||||
current_file = Path(__file__)
|
||||
# Внутри контейнера: /app/app/core/molecule_executor.py
|
||||
# current_file.parent = /app/app/core
|
||||
# current_file.parent.parent = /app/app
|
||||
# current_file.parent.parent.parent = /app
|
||||
# Нам нужно получить путь на хосте, который соответствует /workspace
|
||||
# В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/
|
||||
# Но внутри контейнера код находится в /app/app/, а проект в /workspace
|
||||
# Поэтому нужно использовать переменную окружения или определить путь по-другому
|
||||
|
||||
# Попробуем получить путь из переменной окружения или использовать текущую рабочую директорию
|
||||
import os
|
||||
# Если есть переменная окружения с реальным путем на хосте
|
||||
host_project_root = os.getenv("HOST_PROJECT_ROOT")
|
||||
if host_project_root:
|
||||
project_root = Path(host_project_root)
|
||||
else:
|
||||
# Используем путь относительно текущего файла
|
||||
# Внутри контейнера: /app/app/core/molecule_executor.py
|
||||
# На хосте: /Users/inecs/Documents/DevOpsLab/app/core/molecule_executor.py
|
||||
# Нужно подняться на 2 уровня выше от app/ чтобы получить корень проекта
|
||||
app_dir = current_file.parent.parent.parent # /app/app или /Users/.../app
|
||||
# Если мы в /app/app, то родительская директория - это /app, но нам нужен путь на хосте
|
||||
# Поэтому используем абсолютный путь, который будет правильно разрешен Docker Desktop
|
||||
# Docker Desktop автоматически преобразует пути из контейнера в пути на хосте
|
||||
# Но для этого нужно использовать путь, который существует на хосте
|
||||
# В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/
|
||||
# На хосте: /Users/inecs/Documents/DevOpsLab
|
||||
# Внутри контейнера: /workspace
|
||||
# Когда мы запускаем docker run, нужно использовать путь на хосте
|
||||
# Но мы не знаем его напрямую, поэтому используем относительный путь
|
||||
# или определяем его через переменную окружения
|
||||
project_root = Path("/workspace") # Оставляем как есть, но для docker run нужно использовать реальный путь
|
||||
|
||||
self.project_root = project_root.resolve() if isinstance(project_root, Path) else Path(project_root).resolve()
|
||||
|
||||
# Для docker run нужно использовать реальный путь на хосте
|
||||
# Определяем его через переменную окружения или используем путь относительно app/
|
||||
self.host_project_root = os.getenv("HOST_PROJECT_ROOT", str(self.project_root))
|
||||
if self.host_project_root == "/workspace":
|
||||
# Если не задана переменная, пытаемся определить путь на хосте
|
||||
# В docker-compose.yml монтируется ../:/workspace
|
||||
# Значит на хосте это родительская директория от app/
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent.parent # app/
|
||||
# На хосте app/ находится в /Users/.../DevOpsLab/app
|
||||
# Значит корень проекта на хосте - это родительская директория от app/
|
||||
# Но внутри контейнера мы не знаем этот путь напрямую
|
||||
# Поэтому используем путь, который Docker Desktop может разрешить
|
||||
# Docker Desktop автоматически преобразует пути из volume mounts
|
||||
# Но для docker run нужно использовать путь на хосте
|
||||
# Лучше всего использовать переменную окружения HOST_PROJECT_ROOT
|
||||
self.host_project_root = str(self.project_root)
|
||||
|
||||
self.molecule_dir = self.project_root / "molecule" / "default"
|
||||
# Пресеты теперь находятся в alembic/presets
|
||||
# Находим путь к alembic относительно текущего файла
|
||||
current_file = Path(__file__)
|
||||
alembic_dir = current_file.parent.parent / "alembic"
|
||||
self.presets_dir = alembic_dir / "presets"
|
||||
# Если не найдено, пробуем старый путь (для обратной совместимости)
|
||||
if not self.presets_dir.exists():
|
||||
self.presets_dir = self.project_root / "molecule" / "presets"
|
||||
# Пресеты для Molecule должны находиться в molecule/presets
|
||||
# чтобы create.yml мог их найти по пути /workspace/molecule/presets/
|
||||
# ВАЖНО: presets_dir должен быть относительно project_root, который внутри контейнера = /workspace
|
||||
# Это гарантирует, что файлы будут доступны внутри ansible-controller контейнера
|
||||
self.presets_dir = Path("/workspace") / "molecule" / "presets"
|
||||
self.docker_client = DockerClient()
|
||||
self._temp_preset_files = {} # Кэш временных файлов preset'ов
|
||||
|
||||
@@ -158,7 +222,9 @@ class MoleculeExecutor:
|
||||
preset_name: str = "default",
|
||||
preset_content: Optional[str] = None,
|
||||
preset_category: str = "main",
|
||||
stream: bool = False
|
||||
stream: bool = False,
|
||||
stop_event: Optional[callable] = None,
|
||||
container_ref: Optional[object] = None
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Тестирование роли через Molecule
|
||||
@@ -169,96 +235,496 @@ class MoleculeExecutor:
|
||||
preset_content: Содержимое preset'а из БД (если None - загружается из файла)
|
||||
preset_category: Категория preset'а (main или k8s)
|
||||
stream: Если True, возвращает генератор строк для WebSocket
|
||||
stop_event: Функция для проверки флага остановки
|
||||
container_ref: Объект для хранения ссылки на контейнер
|
||||
|
||||
Yields:
|
||||
Строки вывода команды
|
||||
"""
|
||||
# Если preset_content передан, создаем временный файл из БД
|
||||
if preset_content:
|
||||
container = None
|
||||
try:
|
||||
# Если preset_content передан, создаем временный файл из БД
|
||||
if preset_content:
|
||||
try:
|
||||
self.create_temp_preset_file(preset_name, preset_content, preset_category)
|
||||
preset_data = self.load_preset_from_db(preset_content)
|
||||
except Exception as e:
|
||||
yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n"
|
||||
return
|
||||
else:
|
||||
# Проверка существования preset'а в файловой системе
|
||||
try:
|
||||
preset_data = self.load_preset(preset_name, preset_category)
|
||||
except FileNotFoundError as e:
|
||||
yield f"❌ Ошибка: {str(e)}\n"
|
||||
yield "💡 Убедитесь, что preset существует в БД или файловой системе\n"
|
||||
return
|
||||
|
||||
# Расшифровка vault файлов
|
||||
yield "🔓 Расшифровка vault файлов...\n"
|
||||
await self.decrypt_vault_files()
|
||||
|
||||
# Запуск ansible-controller контейнера
|
||||
yield "🔧 Запуск ansible-controller контейнера...\n"
|
||||
|
||||
# Подготовка переменных окружения
|
||||
env = {
|
||||
"ANSIBLE_FORCE_COLOR": "1",
|
||||
"MOLECULE_PRESET": preset_name,
|
||||
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
|
||||
"MOLECULE_VAULT_ENABLED": "false"
|
||||
}
|
||||
|
||||
if role_name:
|
||||
env["MOLECULE_ROLE_NAME"] = role_name
|
||||
yield f"📋 Тестируется роль: {role_name}\n"
|
||||
|
||||
yield f"📋 Используется пресет: {preset_name}\n\n"
|
||||
|
||||
# Используем Docker SDK вместо subprocess для запуска контейнера
|
||||
# Команда docker может быть недоступна внутри контейнера
|
||||
container_name = f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
try:
|
||||
self.create_temp_preset_file(preset_name, preset_content, preset_category)
|
||||
preset_data = self.load_preset_from_db(preset_content)
|
||||
# Подготавливаем volumes для монтирования
|
||||
volumes = {
|
||||
str(self.host_project_root): {"bind": "/workspace", "mode": "rw"},
|
||||
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
|
||||
}
|
||||
|
||||
# Запускаем контейнер
|
||||
container = self.docker_client.client.containers.run(
|
||||
image="inecs/ansible-lab:ansible-controller-latest",
|
||||
name=container_name,
|
||||
command=["bash", "-c", self._build_test_command(role_name, preset_name, preset_category)],
|
||||
environment=env,
|
||||
volumes=volumes,
|
||||
working_dir="/workspace",
|
||||
user="root",
|
||||
detach=True,
|
||||
remove=False, # Не удаляем автоматически, чтобы можно было получить логи
|
||||
auto_remove=False
|
||||
)
|
||||
|
||||
# Сохраняем ссылку на контейнер для возможности остановки
|
||||
if container_ref and hasattr(container_ref, 'set'):
|
||||
container_ref.set(container)
|
||||
|
||||
# Потоковый вывод логов (асинхронно)
|
||||
try:
|
||||
# Используем обычную очередь для передачи данных между потоками
|
||||
import queue
|
||||
import threading
|
||||
log_queue = queue.Queue()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def read_logs():
|
||||
"""Синхронная функция для чтения логов"""
|
||||
try:
|
||||
for line in container.logs(stream=True, follow=True, stdout=True, stderr=True):
|
||||
log_queue.put(line)
|
||||
except Exception as e:
|
||||
log_queue.put(None)
|
||||
|
||||
# Запускаем чтение логов в отдельном потоке
|
||||
log_thread = threading.Thread(target=read_logs, daemon=True)
|
||||
log_thread.start()
|
||||
|
||||
# Читаем логи из очереди асинхронно
|
||||
while True:
|
||||
# Проверяем флаг остановки
|
||||
if stop_event and stop_event():
|
||||
try:
|
||||
yield "\n⏹️ Остановка тестирования по запросу пользователя...\n"
|
||||
except GeneratorExit:
|
||||
# Генератор уже закрыт - выполняем cleanup без yield
|
||||
pass
|
||||
|
||||
# Останавливаем и удаляем контейнер ansible-controller
|
||||
try:
|
||||
if container:
|
||||
container.stop(timeout=10)
|
||||
logger.info(f"Container {container_name} stopped")
|
||||
try:
|
||||
yield "🛑 Контейнер ansible-controller остановлен\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping container: {e}")
|
||||
try:
|
||||
yield f"⚠️ Ошибка при остановке контейнера: {e}\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
|
||||
# Запускаем destroy.yml для очистки контейнеров из preset'а
|
||||
try:
|
||||
yield "\n🧹 Очистка контейнеров из preset'а...\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
|
||||
# Выполняем cleanup (синхронно, без yield)
|
||||
cleanup_success = False
|
||||
try:
|
||||
await self._cleanup_preset_containers(preset_name, preset_category)
|
||||
logger.info(f"Cleanup completed for preset {preset_name}")
|
||||
cleanup_success = True
|
||||
try:
|
||||
yield "✅ Контейнеры из preset'а удалены\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up preset containers: {e}")
|
||||
try:
|
||||
yield f"⚠️ Ошибка при очистке контейнеров preset'а: {e}\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
|
||||
# Удаляем контейнер ansible-controller
|
||||
try:
|
||||
if container:
|
||||
container.remove(force=True)
|
||||
logger.info(f"Container {container_name} removed")
|
||||
try:
|
||||
yield "🗑️ Контейнер ansible-controller удален\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing container: {e}")
|
||||
try:
|
||||
yield f"⚠️ Ошибка при удалении контейнера: {e}\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
|
||||
# Сохраняем флаг, что cleanup был выполнен
|
||||
cleanup_done = cleanup_success
|
||||
|
||||
# Выходим из цикла
|
||||
break
|
||||
|
||||
try:
|
||||
# Используем run_in_executor для чтения из обычной очереди
|
||||
line = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: log_queue.get(timeout=0.1)
|
||||
)
|
||||
if line is None:
|
||||
break
|
||||
yield line.decode('utf-8', errors='replace')
|
||||
except queue.Empty:
|
||||
# Проверяем, завершился ли контейнер
|
||||
try:
|
||||
container.reload()
|
||||
if container.status == 'exited':
|
||||
# Читаем оставшиеся логи из очереди
|
||||
while not log_queue.empty():
|
||||
try:
|
||||
line = log_queue.get_nowait()
|
||||
if line is not None:
|
||||
yield line.decode('utf-8', errors='replace')
|
||||
except queue.Empty:
|
||||
break
|
||||
# Читаем финальные логи из контейнера
|
||||
remaining_logs = container.logs(stdout=True, stderr=True)
|
||||
if remaining_logs:
|
||||
yield remaining_logs.decode('utf-8', errors='replace')
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error reloading container: {e}")
|
||||
break
|
||||
continue
|
||||
except GeneratorExit:
|
||||
# Генератор закрыт - останавливаем контейнер и очищаем ресурсы
|
||||
# НЕ используем yield здесь, так как генератор уже закрывается
|
||||
logger.info(f"GeneratorExit caught, cleaning up container {container_name if container else 'unknown'}")
|
||||
try:
|
||||
if container:
|
||||
container.stop(timeout=5)
|
||||
container.remove(force=True)
|
||||
logger.info(f"Container {container_name} stopped and removed on GeneratorExit")
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up container on GeneratorExit: {e}")
|
||||
|
||||
# Запускаем cleanup при закрытии генератора (без yield)
|
||||
try:
|
||||
await self._cleanup_preset_containers(preset_name, preset_category)
|
||||
logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup on GeneratorExit: {e}")
|
||||
|
||||
# Обязательно поднимаем исключение дальше
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading logs: {e}")
|
||||
break
|
||||
finally:
|
||||
# Ожидаем завершения контейнера (если он еще не остановлен)
|
||||
# cleanup_done может быть уже установлен при остановке по запросу
|
||||
if 'cleanup_done' not in locals():
|
||||
cleanup_done = False
|
||||
if container:
|
||||
try:
|
||||
container.reload()
|
||||
if container.status != 'exited':
|
||||
try:
|
||||
exit_code = container.wait(timeout=5)["StatusCode"]
|
||||
except:
|
||||
# Если не удалось дождаться, останавливаем принудительно
|
||||
container.stop(timeout=5)
|
||||
exit_code = -1
|
||||
else:
|
||||
exit_code = container.attrs.get("State", {}).get("ExitCode", -1)
|
||||
except Exception as e:
|
||||
logger.error(f"Error waiting for container: {e}")
|
||||
exit_code = -1
|
||||
|
||||
# Получаем финальные логи, если есть
|
||||
try:
|
||||
final_logs = container.logs(stdout=True, stderr=True, tail=100)
|
||||
if final_logs:
|
||||
yield final_logs.decode('utf-8', errors='replace')
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting final logs: {e}")
|
||||
|
||||
# Удаляем контейнер ansible-controller
|
||||
try:
|
||||
if container:
|
||||
container.stop(timeout=5)
|
||||
container.remove(force=True)
|
||||
try:
|
||||
yield f"\n🗑️ Контейнер {container_name} удален\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing container {container_name}: {e}")
|
||||
try:
|
||||
yield f"\n⚠️ Предупреждение: не удалось удалить контейнер {container_name}: {e}\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
|
||||
# Запускаем cleanup контейнеров из preset'а (всегда, даже если был вызван ранее)
|
||||
# Это гарантирует, что контейнеры будут удалены даже при ошибках
|
||||
try:
|
||||
logger.info(f"Running cleanup in finally block for preset {preset_name}")
|
||||
await self._cleanup_preset_containers(preset_name, preset_category)
|
||||
logger.info(f"Cleanup completed in finally block for preset {preset_name}")
|
||||
try:
|
||||
yield "\n🧹 Контейнеры из preset'а очищены\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup during finally: {e}", exc_info=True)
|
||||
try:
|
||||
yield f"\n⚠️ Ошибка при очистке контейнеров preset'а: {e}\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n"
|
||||
return
|
||||
else:
|
||||
# Проверка существования preset'а в файловой системе
|
||||
yield f"❌ Ошибка при запуске контейнера: {str(e)}\n"
|
||||
import traceback
|
||||
yield f"Детали: {traceback.format_exc()}\n"
|
||||
# Пытаемся удалить контейнер, если он был создан
|
||||
try:
|
||||
container = self.docker_client.client.containers.get(container_name)
|
||||
container.remove(force=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Шифрование vault файлов
|
||||
yield "\n🔒 Шифрование vault файлов...\n"
|
||||
await self.encrypt_vault_files()
|
||||
|
||||
# Проверяем код возврата контейнера (если не было остановки)
|
||||
if not (stop_event and stop_event()) and container:
|
||||
try:
|
||||
container.reload()
|
||||
exit_code = container.attrs.get("State", {}).get("ExitCode", -1)
|
||||
if exit_code == 0:
|
||||
yield "\n✅ Тестирование завершено успешно\n"
|
||||
elif exit_code == -1:
|
||||
yield "\n✅ Тестирование preset'а завершено\n"
|
||||
else:
|
||||
yield f"\n❌ Тестирование завершено с ошибкой (код: {exit_code})\n"
|
||||
except:
|
||||
yield "\n✅ Тестирование preset'а завершено\n"
|
||||
except GeneratorExit:
|
||||
# Генератор закрыт - останавливаем контейнер и очищаем ресурсы
|
||||
# НЕ используем yield здесь, так как генератор уже закрывается
|
||||
if container:
|
||||
try:
|
||||
container.stop(timeout=5)
|
||||
container.remove(force=True)
|
||||
logger.info(f"Container {container_name} stopped and removed due to GeneratorExit")
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing container on GeneratorExit: {e}")
|
||||
# Запускаем cleanup при закрытии генератора (без yield)
|
||||
try:
|
||||
preset_data = self.load_preset(preset_name, preset_category)
|
||||
except FileNotFoundError as e:
|
||||
yield f"❌ Ошибка: {str(e)}\n"
|
||||
yield "💡 Убедитесь, что preset существует в БД или файловой системе\n"
|
||||
return
|
||||
if preset_content or preset_name:
|
||||
await self._cleanup_preset_containers(preset_name, preset_category)
|
||||
logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup on GeneratorExit: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
# Обрабатываем другие исключения
|
||||
if container:
|
||||
try:
|
||||
container.stop(timeout=5)
|
||||
container.remove(force=True)
|
||||
except:
|
||||
pass
|
||||
# Запускаем cleanup при ошибке (без yield, так как генератор может быть закрыт)
|
||||
try:
|
||||
if preset_content or preset_name:
|
||||
await self._cleanup_preset_containers(preset_name, preset_category)
|
||||
except:
|
||||
pass
|
||||
# Пытаемся отправить сообщение об ошибке, если генератор еще открыт
|
||||
try:
|
||||
yield f"\n❌ Критическая ошибка: {str(e)}\n"
|
||||
except GeneratorExit:
|
||||
# Генератор закрыт - это нормально
|
||||
raise
|
||||
except:
|
||||
# Другие ошибки при yield - игнорируем
|
||||
pass
|
||||
|
||||
# Расшифровка vault файлов
|
||||
yield "🔓 Расшифровка vault файлов...\n"
|
||||
await self.decrypt_vault_files()
|
||||
async def _cleanup_preset_containers(self, preset_name: str, preset_category: str = "main"):
|
||||
"""Очистка контейнеров из preset'а через destroy.yml"""
|
||||
logger.info(f"Starting cleanup for preset {preset_name} (category: {preset_category})")
|
||||
|
||||
# Запуск ansible-controller контейнера
|
||||
yield "🔧 Запуск ansible-controller контейнера...\n"
|
||||
# Сначала пытаемся удалить контейнеры напрямую (быстрее и надежнее)
|
||||
cleanup_success = False
|
||||
try:
|
||||
# Получаем список всех контейнеров
|
||||
all_containers = self.docker_client.client.containers.list(all=True)
|
||||
removed_count = 0
|
||||
|
||||
# Подготовка переменных окружения
|
||||
env = {
|
||||
"ANSIBLE_FORCE_COLOR": "1",
|
||||
"MOLECULE_PRESET": preset_name,
|
||||
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
|
||||
"MOLECULE_VAULT_ENABLED": "false"
|
||||
}
|
||||
for cont in all_containers:
|
||||
try:
|
||||
cont.reload()
|
||||
cont_name = cont.name
|
||||
# Проверяем, что контейнер в сети labnet или имеет короткое имя (тестовые контейнеры u1, u2 и т.д.)
|
||||
networks = cont.attrs.get("NetworkSettings", {}).get("Networks", {})
|
||||
is_test_container = False
|
||||
|
||||
if role_name:
|
||||
env["MOLECULE_ROLE_NAME"] = role_name
|
||||
yield f"📋 Тестируется роль: {role_name}\n"
|
||||
if "labnet" in networks:
|
||||
is_test_container = True
|
||||
elif cont_name and len(cont_name) <= 10 and (cont_name.startswith("u") or cont_name.startswith("test-")):
|
||||
is_test_container = True
|
||||
|
||||
yield f"📋 Используется пресет: {preset_name}\n\n"
|
||||
if is_test_container:
|
||||
try:
|
||||
if cont.status != 'exited':
|
||||
cont.stop(timeout=5)
|
||||
cont.remove(force=True)
|
||||
removed_count += 1
|
||||
logger.info(f"Removed container {cont_name} during direct cleanup")
|
||||
except Exception as remove_error:
|
||||
logger.warning(f"Error removing container {cont_name}: {remove_error}")
|
||||
except Exception as cont_error:
|
||||
logger.debug(f"Error processing container: {cont_error}")
|
||||
|
||||
# Команда для выполнения в контейнере
|
||||
docker_cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"--name", f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"-v", f"{self.project_root}:/workspace",
|
||||
"-w", "/workspace",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-u", "root"
|
||||
]
|
||||
if removed_count > 0:
|
||||
logger.info(f"Direct cleanup removed {removed_count} containers")
|
||||
cleanup_success = True
|
||||
except Exception as direct_cleanup_error:
|
||||
logger.error(f"Error in direct cleanup: {direct_cleanup_error}")
|
||||
|
||||
# Добавляем переменные окружения
|
||||
for key, value in env.items():
|
||||
docker_cmd.extend(["-e", f"{key}={value}"])
|
||||
# Также пытаемся запустить destroy.yml для полной очистки
|
||||
try:
|
||||
# Запускаем destroy.yml в отдельном контейнере для очистки
|
||||
env = {
|
||||
"ANSIBLE_FORCE_COLOR": "1",
|
||||
"MOLECULE_PRESET": preset_name,
|
||||
"MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace",
|
||||
"MOLECULE_VAULT_ENABLED": "false"
|
||||
}
|
||||
|
||||
docker_cmd.extend([
|
||||
"inecs/ansible-lab:ansible-controller-latest",
|
||||
"bash", "-c", self._build_test_command(role_name)
|
||||
])
|
||||
cleanup_container_name = f"ansible-cleanup-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
# Запуск процесса
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*docker_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd=str(self.project_root)
|
||||
)
|
||||
volumes = {
|
||||
str(self.host_project_root): {"bind": "/workspace", "mode": "rw"},
|
||||
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
|
||||
}
|
||||
|
||||
# Потоковый вывод
|
||||
async for line in process.stdout:
|
||||
yield line.decode('utf-8', errors='replace')
|
||||
cleanup_container = self.docker_client.client.containers.run(
|
||||
image="inecs/ansible-lab:ansible-controller-latest",
|
||||
name=cleanup_container_name,
|
||||
command=["bash", "-c", "cd molecule/default && ansible-playbook -i localhost, destroy.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace"],
|
||||
environment=env,
|
||||
volumes=volumes,
|
||||
working_dir="/workspace",
|
||||
user="root",
|
||||
detach=True,
|
||||
remove=False,
|
||||
auto_remove=False
|
||||
)
|
||||
|
||||
await process.wait()
|
||||
# Ждем завершения cleanup
|
||||
try:
|
||||
exit_code = cleanup_container.wait(timeout=30)["StatusCode"]
|
||||
if exit_code == 0:
|
||||
logger.info(f"Destroy.yml cleanup completed successfully")
|
||||
cleanup_success = True
|
||||
else:
|
||||
logger.warning(f"Cleanup container exited with code {exit_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error waiting for cleanup container: {e}")
|
||||
finally:
|
||||
# Удаляем cleanup контейнер
|
||||
try:
|
||||
cleanup_container.stop(timeout=5)
|
||||
cleanup_container.remove(force=True)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error removing cleanup container: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error running destroy.yml cleanup: {e}")
|
||||
|
||||
# Шифрование vault файлов
|
||||
yield "\n🔒 Шифрование vault файлов...\n"
|
||||
await self.encrypt_vault_files()
|
||||
if not cleanup_success:
|
||||
logger.warning(f"Cleanup may not have completed successfully for preset {preset_name}")
|
||||
|
||||
if process.returncode == 0:
|
||||
yield "\n✅ Тестирование завершено успешно\n"
|
||||
else:
|
||||
yield f"\n❌ Тестирование завершено с ошибкой (код: {process.returncode})\n"
|
||||
|
||||
def _build_test_command(self, role_name: Optional[str] = None) -> str:
|
||||
def _build_test_command(self, role_name: Optional[str] = None, preset_name: Optional[str] = None, preset_category: str = "main") -> str:
|
||||
"""Построение команды для тестирования"""
|
||||
commands = [
|
||||
"echo -e '\\033[33m=== СОЗДАНИЕ ТЕСТОВЫХ КОНТЕЙНЕРОВ ==='",
|
||||
"echo ''",
|
||||
"mkdir -p /tmp/molecule_workspace/inventory",
|
||||
]
|
||||
|
||||
# Для k8s пресетов сначала запускаем create_k8s_cluster.py
|
||||
is_k8s_preset = False
|
||||
if preset_category == "k8s":
|
||||
is_k8s_preset = True
|
||||
elif preset_name:
|
||||
if preset_name.startswith("k8s-") or preset_name in ["kubernetes", "k8s-full"]:
|
||||
is_k8s_preset = True
|
||||
|
||||
if is_k8s_preset:
|
||||
# Определяем путь к preset файлу
|
||||
# Для k8s пресетов файл должен быть в /workspace/molecule/presets/k8s/
|
||||
if preset_category == "k8s":
|
||||
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
|
||||
elif preset_name:
|
||||
k8s_names = ["kubernetes", "k8s-full"]
|
||||
if preset_name.startswith("k8s-") or preset_name in k8s_names:
|
||||
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
|
||||
else:
|
||||
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
|
||||
else:
|
||||
preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml"
|
||||
|
||||
# Формируем команду для создания Kind кластера
|
||||
script_path = "/workspace/scripts/create_k8s_cluster.py"
|
||||
k8s_cmd_str = f"test -f {preset_file_path} && python3 {script_path} {preset_file_path} ansible-controller || echo Ошибка при создании Kind кластера"
|
||||
commands.extend([
|
||||
"echo -e '\\033[33m=== СОЗДАНИЕ KUBERNETES КЛАСТЕРА ==='",
|
||||
"echo ''",
|
||||
k8s_cmd_str,
|
||||
"echo ''",
|
||||
"echo -e '\\033[33m=== СОЗДАНИЕ DOCKER КОНТЕЙНЕРОВ ==='",
|
||||
"echo ''",
|
||||
])
|
||||
|
||||
commands.extend([
|
||||
"cd molecule/default",
|
||||
"ansible-playbook -i localhost, create.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace",
|
||||
"echo ''",
|
||||
@@ -276,7 +742,7 @@ class MoleculeExecutor:
|
||||
"echo ''",
|
||||
"echo -e '\\033[33m=== ЗАПУСК ROLES/DEPLOY.YML НА ТЕСТОВЫХ КОНТЕЙНЕРАХ ===\\033[0m'",
|
||||
"echo ''"
|
||||
]
|
||||
])
|
||||
|
||||
# Добавляем команду для deploy.yml с фильтрацией по роли
|
||||
if role_name:
|
||||
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
environment:
|
||||
- PROJECT_ROOT=/workspace
|
||||
- PROJECT_NAME=devops-lab
|
||||
- HOST_PROJECT_ROOT=${HOST_PROJECT_ROOT:-${PWD}/..}
|
||||
- API_HOST=0.0.0.0
|
||||
- API_PORT=8000
|
||||
- API_RELOAD=true
|
||||
|
||||
@@ -25,7 +25,7 @@ redis==4.6.0 # Celery 5.3.4 требует redis <5.0.0
|
||||
websockets==12.0
|
||||
|
||||
# Docker
|
||||
docker==6.1.3
|
||||
docker==7.1.0
|
||||
|
||||
# Git
|
||||
GitPython==3.1.40
|
||||
|
||||
@@ -61,65 +61,86 @@ def load_presets():
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
# Основные preset'ы
|
||||
# Функция для импорта одного пресета
|
||||
def import_preset(preset_file, category='main'):
|
||||
try:
|
||||
with open(preset_file) as f:
|
||||
content = f.read()
|
||||
preset_data = yaml.safe_load(content) or {}
|
||||
|
||||
# Извлечение описания из комментария
|
||||
description = None
|
||||
for line in content.split('\n'):
|
||||
if line.strip().startswith('#description:'):
|
||||
description = line.split('#description:')[1].strip()
|
||||
break
|
||||
|
||||
# Проверка существования в БД
|
||||
result = connection.execute(
|
||||
text("SELECT id FROM presets WHERE name = :name"),
|
||||
{"name": preset_file.stem}
|
||||
)
|
||||
if result.fetchone():
|
||||
print(f"⏭️ Пропущен (уже существует): {preset_file.stem}")
|
||||
return 'skipped'
|
||||
|
||||
# Вставка в БД
|
||||
# Преобразуем dict/list в JSON строки для PostgreSQL
|
||||
hosts_json = json.dumps(preset_data.get('hosts', []))
|
||||
images_json = json.dumps(preset_data.get('images', {}))
|
||||
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
|
||||
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
|
||||
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
|
||||
"""),
|
||||
{
|
||||
'name': preset_file.stem,
|
||||
'category': category,
|
||||
'description': description,
|
||||
'content': content,
|
||||
'docker_network': preset_data.get('docker_network'),
|
||||
'hosts': hosts_json,
|
||||
'images': images_json,
|
||||
'systemd_defaults': systemd_defaults_json,
|
||||
'kind_clusters': kind_clusters_json,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
connection.commit()
|
||||
print(f"✅ Загружен: {preset_file.stem}")
|
||||
return 'loaded'
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при загрузке {preset_file.name}: {e}")
|
||||
return 'error'
|
||||
|
||||
# Основные preset'ы из корня папки presets
|
||||
if presets_dir.exists():
|
||||
for preset_file in presets_dir.glob("*.yml"):
|
||||
if preset_file.name == "deploy.yml":
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(preset_file) as f:
|
||||
content = f.read()
|
||||
preset_data = yaml.safe_load(content) or {}
|
||||
|
||||
# Извлечение описания из комментария
|
||||
description = None
|
||||
for line in content.split('\n'):
|
||||
if line.strip().startswith('#description:'):
|
||||
description = line.split('#description:')[1].strip()
|
||||
break
|
||||
|
||||
# Проверка существования в БД
|
||||
result = connection.execute(
|
||||
text("SELECT id FROM presets WHERE name = :name"),
|
||||
{"name": preset_file.stem}
|
||||
)
|
||||
if result.fetchone():
|
||||
print(f"⏭️ Пропущен (уже существует): {preset_file.stem}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Вставка в БД
|
||||
# Преобразуем dict/list в JSON строки для PostgreSQL
|
||||
hosts_json = json.dumps(preset_data.get('hosts', []))
|
||||
images_json = json.dumps(preset_data.get('images', {}))
|
||||
systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {}))
|
||||
kind_clusters_json = json.dumps(preset_data.get('kind_clusters', []))
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at)
|
||||
VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at)
|
||||
"""),
|
||||
{
|
||||
'name': preset_file.stem,
|
||||
'category': 'main',
|
||||
'description': description,
|
||||
'content': content,
|
||||
'docker_network': preset_data.get('docker_network'),
|
||||
'hosts': hosts_json,
|
||||
'images': images_json,
|
||||
'systemd_defaults': systemd_defaults_json,
|
||||
'kind_clusters': kind_clusters_json,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
)
|
||||
connection.commit()
|
||||
print(f"✅ Загружен: {preset_file.stem}")
|
||||
result = import_preset(preset_file, category='main')
|
||||
if result == 'loaded':
|
||||
loaded_count += 1
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при загрузке {preset_file.name}: {e}")
|
||||
elif result == 'skipped':
|
||||
skipped_count += 1
|
||||
elif result == 'error':
|
||||
error_count += 1
|
||||
|
||||
# Пресеты из папки examples
|
||||
examples_dir = presets_dir / "examples"
|
||||
if examples_dir.exists():
|
||||
print(f"📁 Поиск пресетов в examples: {examples_dir}")
|
||||
for preset_file in examples_dir.glob("*.yml"):
|
||||
result = import_preset(preset_file, category='main')
|
||||
if result == 'loaded':
|
||||
loaded_count += 1
|
||||
elif result == 'skipped':
|
||||
skipped_count += 1
|
||||
elif result == 'error':
|
||||
error_count += 1
|
||||
|
||||
# K8s preset'ы
|
||||
|
||||
@@ -98,6 +98,8 @@ class PresetService:
|
||||
description: str = "",
|
||||
hosts: List[Dict] = None,
|
||||
category: str = "main",
|
||||
images: Dict = None,
|
||||
systemd_defaults: Dict = None,
|
||||
created_by: Optional[str] = None
|
||||
) -> Preset:
|
||||
"""Создание нового preset'а в БД"""
|
||||
@@ -107,7 +109,12 @@ class PresetService:
|
||||
raise ValueError(f"Preset '{preset_name}' уже существует")
|
||||
|
||||
# Генерация содержимого preset'а
|
||||
content = PresetService._generate_preset_content(description, hosts or [])
|
||||
content = PresetService._generate_preset_content_from_form(
|
||||
description=description,
|
||||
hosts=hosts or [],
|
||||
images=images or {},
|
||||
systemd_defaults=systemd_defaults or {}
|
||||
)
|
||||
|
||||
# Парсинг для извлечения данных
|
||||
data = yaml.safe_load(content)
|
||||
@@ -234,32 +241,11 @@ class PresetService:
|
||||
kind_clusters: List = None
|
||||
) -> str:
|
||||
"""Генерация содержимого preset'а из формы"""
|
||||
# Базовые образы по умолчанию
|
||||
default_images = {
|
||||
"alt9": "inecs/ansible-lab:alt9-latest",
|
||||
"alt10": "inecs/ansible-lab:alt10-latest",
|
||||
"astra": "inecs/ansible-lab:astra-linux-latest",
|
||||
"rhel": "inecs/ansible-lab:rhel-latest",
|
||||
"centos7": "inecs/ansible-lab:centos7-latest",
|
||||
"centos8": "inecs/ansible-lab:centos8-latest",
|
||||
"centos9": "inecs/ansible-lab:centos9-latest",
|
||||
"alma": "inecs/ansible-lab:alma-latest",
|
||||
"rocky": "inecs/ansible-lab:rocky-latest",
|
||||
"redos": "inecs/ansible-lab:redos-latest",
|
||||
"ubuntu20": "inecs/ansible-lab:ubuntu20-latest",
|
||||
"ubuntu22": "inecs/ansible-lab:ubuntu22-latest",
|
||||
"ubuntu24": "inecs/ansible-lab:ubuntu24-latest",
|
||||
"debian9": "inecs/ansible-lab:debian9-latest",
|
||||
"debian10": "inecs/ansible-lab:debian10-latest",
|
||||
"debian11": "inecs/ansible-lab:debian11-latest",
|
||||
"debian12": "inecs/ansible-lab:debian12-latest"
|
||||
}
|
||||
# Используем только переданные образы (без дефолтных)
|
||||
final_images = images or {}
|
||||
|
||||
# Объединяем с переданными образами
|
||||
final_images = {**default_images, **(images or {})}
|
||||
|
||||
# Systemd defaults по умолчанию
|
||||
default_systemd = {
|
||||
# Используем переданные настройки systemd или дефолтные, если не переданы
|
||||
final_systemd = systemd_defaults or {
|
||||
"privileged": True,
|
||||
"command": "/sbin/init",
|
||||
"volumes": ["/sys/fs/cgroup:/sys/fs/cgroup:rw"],
|
||||
@@ -267,9 +253,6 @@ class PresetService:
|
||||
"capabilities": ["SYS_ADMIN"]
|
||||
}
|
||||
|
||||
# Объединяем с переданными настройками
|
||||
final_systemd = {**default_systemd, **(systemd_defaults or {})}
|
||||
|
||||
# Заголовок
|
||||
content = f"""---
|
||||
#description: {description or "Пользовательский preset"}
|
||||
|
||||
@@ -91,10 +91,7 @@
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</a>
|
||||
<button
|
||||
hx-delete="/api/v1/dockerfiles/{{ dockerfile.id }}"
|
||||
hx-confirm="Удалить Dockerfile '{{ dockerfile.name }}'?"
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
onclick="deleteDockerfile({{ dockerfile.id }}, '{{ dockerfile.name }}', this)"
|
||||
class="btn btn-outline-danger"
|
||||
title="Удалить"
|
||||
>
|
||||
@@ -119,4 +116,89 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function deleteDockerfile(dockerfileId, dockerfileName, button) {
|
||||
// Показываем модальное окно подтверждения
|
||||
const confirmed = await showConfirmModal(
|
||||
`Вы уверены, что хотите удалить Dockerfile '${dockerfileName}'?`,
|
||||
'Подтверждение удаления'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Отключаем кнопку во время запроса
|
||||
button.disabled = true;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/dockerfiles/${dockerfileId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
data.message || `Dockerfile '${dockerfileName}' успешно удален`,
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна удаляем строку из таблицы
|
||||
const row = button.closest('tr');
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто удаляем строку
|
||||
const row = button.closest('tr');
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении Dockerfile';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при удалении Dockerfile', 'error');
|
||||
} else {
|
||||
alert('Ошибка при удалении Dockerfile');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении Dockerfile:', error);
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при удалении Dockerfile', 'error');
|
||||
} else {
|
||||
alert('Ошибка при удалении Dockerfile');
|
||||
}
|
||||
} finally {
|
||||
// Восстанавливаем кнопку
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,10 +31,7 @@
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button
|
||||
hx-delete="/api/v1/playbooks/{{ playbook.id }}"
|
||||
hx-confirm="Удалить playbook '{{ playbook.name }}'?"
|
||||
hx-target="closest .col-12"
|
||||
hx-swap="outerHTML"
|
||||
onclick="deletePlaybook({{ playbook.id }}, '{{ playbook.name }}', this)"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Удалить"
|
||||
>
|
||||
@@ -93,4 +90,89 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function deletePlaybook(playbookId, playbookName, button) {
|
||||
// Показываем модальное окно подтверждения
|
||||
const confirmed = await showConfirmModal(
|
||||
`Вы уверены, что хотите удалить playbook '${playbookName}'?`,
|
||||
'Подтверждение удаления'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Отключаем кнопку во время запроса
|
||||
button.disabled = true;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/playbooks/${playbookId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
data.message || `Playbook '${playbookName}' успешно удален`,
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна удаляем карточку
|
||||
const card = button.closest('.col-12');
|
||||
if (card) {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто удаляем карточку
|
||||
const card = button.closest('.col-12');
|
||||
if (card) {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении playbook';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при удалении playbook', 'error');
|
||||
} else {
|
||||
alert('Ошибка при удалении playbook');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении playbook:', error);
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при удалении playbook', 'error');
|
||||
} else {
|
||||
alert('Ошибка при удалении playbook');
|
||||
}
|
||||
} finally {
|
||||
// Восстанавливаем кнопку
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="presetCreator()">
|
||||
<div x-data="presetCreator()" x-init="init()">
|
||||
<form
|
||||
hx-post="/api/v1/presets/create"
|
||||
hx-target="#result"
|
||||
hx-swap="innerHTML"
|
||||
hx-swap="none"
|
||||
@submit.prevent="submitForm"
|
||||
class="card"
|
||||
>
|
||||
@@ -60,6 +59,89 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker образы -->
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Docker образы</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<div class="space-y-2" x-ref="imagesContainer">
|
||||
<template x-for="(image, key) in formData.images" :key="key">
|
||||
<div class="row g-2 mb-2 align-items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
:value="key"
|
||||
class="form-control"
|
||||
readonly
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.images[key]"
|
||||
class="form-control"
|
||||
placeholder="inecs/ansible-lab:ubuntu22-latest"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="removeImage(key)"
|
||||
class="btn btn-danger btn-sm w-100"
|
||||
title="Удалить"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3 align-items-end">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label small">Dockerfile</label>
|
||||
<select
|
||||
x-model="selectedDockerfile"
|
||||
@change="onDockerfileSelected"
|
||||
class="form-select"
|
||||
:disabled="!dockerfilesLoaded"
|
||||
>
|
||||
<option value="">Выберите Dockerfile...</option>
|
||||
<template x-for="dockerfile in dockerfiles" :key="dockerfile.id">
|
||||
<option :value="dockerfile.name" x-text="dockerfile.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div class="form-text" x-show="!dockerfilesLoaded">
|
||||
<i class="fas fa-spinner fa-spin me-1"></i>Загрузка Dockerfiles...
|
||||
</div>
|
||||
<div class="form-text text-danger" x-show="dockerfilesLoaded && dockerfiles.length === 0">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>Dockerfiles не найдены
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label small">Значение образа</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="newImageValue"
|
||||
class="form-control"
|
||||
placeholder="inecs/ansible-lab:ubuntu25"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="addImage"
|
||||
class="btn btn-outline-primary w-100"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Хосты</h5>
|
||||
</div>
|
||||
@@ -84,18 +166,10 @@
|
||||
x-model="host.family"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="ubuntu20">Ubuntu 20</option>
|
||||
<option value="ubuntu22">Ubuntu 22</option>
|
||||
<option value="ubuntu24">Ubuntu 24</option>
|
||||
<option value="debian11">Debian 11</option>
|
||||
<option value="debian12">Debian 12</option>
|
||||
<option value="centos7">CentOS 7</option>
|
||||
<option value="centos8">CentOS 8</option>
|
||||
<option value="centos9">CentOS 9</option>
|
||||
<option value="alma">AlmaLinux</option>
|
||||
<option value="rocky">Rocky Linux</option>
|
||||
<option value="rhel">RHEL</option>
|
||||
<option value="astra">Astra Linux</option>
|
||||
<option value="">Выберите образ...</option>
|
||||
<template x-for="(image, key) in formData.images" :key="key">
|
||||
<option :value="key" x-text="key"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
@@ -134,15 +208,89 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Systemd Defaults -->
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Systemd Defaults</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
x-model="formData.systemd_defaults.privileged"
|
||||
id="privileged"
|
||||
>
|
||||
<label class="form-check-label" for="privileged">
|
||||
Privileged
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Command</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.systemd_defaults.command"
|
||||
class="form-control"
|
||||
placeholder="/sbin/init"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Volumes (по одному на строку)</label>
|
||||
<textarea
|
||||
x-model="formData.systemd_defaults.volumes_str"
|
||||
@input="updateVolumes"
|
||||
rows="3"
|
||||
class="form-control"
|
||||
placeholder="/sys/fs/cgroup:/sys/fs/cgroup:rw"
|
||||
>/sys/fs/cgroup:/sys/fs/cgroup:rw</textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Tmpfs (через запятую)</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.systemd_defaults.tmpfs_str"
|
||||
@input="updateTmpfs"
|
||||
class="form-control"
|
||||
placeholder="/run, /run/lock"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Capabilities (через запятую)</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.systemd_defaults.capabilities_str"
|
||||
@input="updateCapabilities"
|
||||
class="form-control"
|
||||
placeholder="SYS_ADMIN"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Скрытые поля -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="hosts"
|
||||
:value="JSON.stringify(formData.hosts.map(h => ({name: h.name, family: h.family, groups: h.groups})))"
|
||||
>
|
||||
|
||||
<!-- Результат -->
|
||||
<div id="result" class="card-body border-top"></div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="images"
|
||||
:value="JSON.stringify(formData.images)"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="systemd_defaults"
|
||||
:value="JSON.stringify({
|
||||
privileged: formData.systemd_defaults.privileged,
|
||||
command: formData.systemd_defaults.command,
|
||||
volumes: formData.systemd_defaults.volumes,
|
||||
tmpfs: formData.systemd_defaults.tmpfs,
|
||||
capabilities: formData.systemd_defaults.capabilities
|
||||
})"
|
||||
>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="card-footer">
|
||||
@@ -169,15 +317,43 @@ function presetCreator() {
|
||||
category: 'main',
|
||||
hosts: [{
|
||||
name: 'u1',
|
||||
family: 'ubuntu22',
|
||||
family: '',
|
||||
groups_str: 'test, web',
|
||||
groups: ['test', 'web']
|
||||
}]
|
||||
}],
|
||||
images: {},
|
||||
systemd_defaults: {
|
||||
privileged: true,
|
||||
command: '/sbin/init',
|
||||
volumes_str: '/sys/fs/cgroup:/sys/fs/cgroup:rw',
|
||||
volumes: ['/sys/fs/cgroup:/sys/fs/cgroup:rw'],
|
||||
tmpfs_str: '/run, /run/lock',
|
||||
tmpfs: ['/run', '/run/lock'],
|
||||
capabilities_str: 'SYS_ADMIN',
|
||||
capabilities: ['SYS_ADMIN']
|
||||
}
|
||||
},
|
||||
dockerfiles: {{ dockerfiles | tojson }},
|
||||
dockerfilesLoaded: true,
|
||||
selectedDockerfile: '',
|
||||
newImageKey: '',
|
||||
newImageValue: '',
|
||||
init() {
|
||||
// Dockerfiles уже загружены из шаблона
|
||||
console.log('Загружено Dockerfiles из шаблона:', this.dockerfiles.length);
|
||||
},
|
||||
onDockerfileSelected() {
|
||||
if (this.selectedDockerfile) {
|
||||
// Устанавливаем ключ образа из выбранного Dockerfile
|
||||
this.newImageKey = this.selectedDockerfile;
|
||||
// Автоматически подставляем значение образа в формате inecs/ansible-lab:{name}
|
||||
this.newImageValue = `inecs/ansible-lab:${this.selectedDockerfile}`;
|
||||
}
|
||||
},
|
||||
addHost() {
|
||||
this.formData.hosts.push({
|
||||
name: `u${this.formData.hosts.length + 1}`,
|
||||
family: 'ubuntu22',
|
||||
family: '',
|
||||
groups_str: 'test',
|
||||
groups: ['test']
|
||||
});
|
||||
@@ -189,10 +365,99 @@ function presetCreator() {
|
||||
const host = this.formData.hosts[index];
|
||||
host.groups = host.groups_str.split(',').map(g => g.trim()).filter(g => g);
|
||||
},
|
||||
addImage() {
|
||||
if (this.newImageKey && this.newImageValue) {
|
||||
this.formData.images[this.newImageKey] = this.newImageValue;
|
||||
this.newImageKey = '';
|
||||
this.newImageValue = '';
|
||||
this.selectedDockerfile = '';
|
||||
}
|
||||
},
|
||||
removeImage(key) {
|
||||
delete this.formData.images[key];
|
||||
},
|
||||
updateVolumes() {
|
||||
this.formData.systemd_defaults.volumes =
|
||||
this.formData.systemd_defaults.volumes_str
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(v => v);
|
||||
},
|
||||
updateTmpfs() {
|
||||
this.formData.systemd_defaults.tmpfs =
|
||||
this.formData.systemd_defaults.tmpfs_str
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t);
|
||||
},
|
||||
updateCapabilities() {
|
||||
this.formData.systemd_defaults.capabilities =
|
||||
this.formData.systemd_defaults.capabilities_str
|
||||
.split(',')
|
||||
.map(c => c.trim())
|
||||
.filter(c => c);
|
||||
},
|
||||
submitForm(event) {
|
||||
// Обновляем массивы перед отправкой
|
||||
this.updateVolumes();
|
||||
this.updateTmpfs();
|
||||
this.updateCapabilities();
|
||||
// HTMX обработает отправку
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка ответа от HTMX для создания пресета
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Проверяем, что это запрос на создание пресета
|
||||
if (event.detail.path === '/api/v1/presets/create') {
|
||||
if (event.detail.xhr.status === 201 || event.detail.xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
if (response.success) {
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
response.message || `Preset '${response.preset_name}' успешно создан`,
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна перенаправляем на страницу списка пресетов
|
||||
window.location.href = '/presets';
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто перенаправляем
|
||||
window.location.href = '/presets';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка парсинга ответа:', e);
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при создании preset', 'error');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
const errorMessage = response.detail || response.message || 'Ошибка при создании preset';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при создании preset', 'error');
|
||||
} else {
|
||||
alert('Ошибка при создании preset');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,39 +4,17 @@
|
||||
{% block page_title %}Preset: {{ preset.name }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div class="btn-group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
onclick="startPresetTest()"
|
||||
title="Запустить тест preset'а"
|
||||
>
|
||||
<i class="fas fa-play me-2"></i>
|
||||
Запустить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning btn-sm"
|
||||
onclick="stopPresetTest()"
|
||||
title="Остановить тест"
|
||||
id="stop-btn"
|
||||
style="display: none;"
|
||||
>
|
||||
<i class="fas fa-stop me-2"></i>
|
||||
Остановить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info btn-sm"
|
||||
onclick="restartPresetTest()"
|
||||
title="Перезапустить тест"
|
||||
id="restart-btn"
|
||||
style="display: none;"
|
||||
>
|
||||
<i class="fas fa-redo me-2"></i>
|
||||
Перезапустить
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
onclick="openPresetTestModal()"
|
||||
title="Запустить тест preset'а"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#presetTestModal"
|
||||
>
|
||||
<i class="fas fa-play me-2"></i>
|
||||
Запустить
|
||||
</button>
|
||||
<a href="/presets/{{ preset.name }}/edit?category={{ preset.category }}" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-edit me-2"></i>
|
||||
Редактировать
|
||||
@@ -49,7 +27,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="col-12">
|
||||
<!-- Информация о preset'е -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
@@ -83,7 +61,7 @@
|
||||
<div class="mb-3">
|
||||
<strong>Хосты:</strong>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<table class="table table-sm table-bordered w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
@@ -144,24 +122,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<!-- Логи тестирования -->
|
||||
<div class="card" id="test-logs-card" style="display: none;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Логи тестирования</h5>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для тестирования preset'а -->
|
||||
<div class="modal fade" id="presetTestModal" tabindex="-1" aria-labelledby="presetTestModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="presetTestModalLabel">
|
||||
<i class="fas fa-vial me-2"></i>
|
||||
Тестирование preset'а: {{ preset.name }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть" onclick="stopPresetTest()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="log-container" id="preset-test-logs">
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fas fa-spinner fa-spin fa-2x mb-3"></i>
|
||||
<p>Подключение к серверу...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-light"
|
||||
onclick="clearTestLogs()"
|
||||
class="btn btn-warning"
|
||||
onclick="stopPresetTest()"
|
||||
id="modal-stop-btn"
|
||||
title="Остановить тест"
|
||||
>
|
||||
<i class="fas fa-stop me-2"></i>
|
||||
Остановить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info"
|
||||
onclick="restartPresetTest()"
|
||||
id="modal-restart-btn"
|
||||
title="Перезапустить тест"
|
||||
>
|
||||
<i class="fas fa-redo me-2"></i>
|
||||
Перезапустить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="clearPresetTestLogs()"
|
||||
title="Очистить логи"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
<i class="fas fa-trash me-2"></i>
|
||||
Очистить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-bs-dismiss="modal"
|
||||
onclick="stopPresetTest()"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="log-container" id="test-logs" style="height: 600px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 1rem; font-family: 'Courier New', monospace; font-size: 0.75rem;">
|
||||
<!-- Логи будут добавлены через WebSocket -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,31 +191,59 @@
|
||||
<script>
|
||||
let testWebSocket = null;
|
||||
let testRunning = false;
|
||||
let presetTestModal = null;
|
||||
|
||||
function startPresetTest() {
|
||||
// Инициализация модального окна
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
presetTestModal = new bootstrap.Modal(document.getElementById('presetTestModal'));
|
||||
|
||||
// Обработка закрытия модального окна
|
||||
document.getElementById('presetTestModal').addEventListener('hidden.bs.modal', function() {
|
||||
stopPresetTest();
|
||||
});
|
||||
});
|
||||
|
||||
function openPresetTestModal() {
|
||||
if (testRunning) {
|
||||
alert('Тест уже запущен');
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем логи
|
||||
const logsCard = document.getElementById('test-logs-card');
|
||||
const logsContainer = document.getElementById('test-logs');
|
||||
logsCard.style.display = 'block';
|
||||
// Очищаем логи
|
||||
const logsContainer = document.getElementById('preset-test-logs');
|
||||
logsContainer.innerHTML = '<div class="text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x mb-3"></i><p>Подключение к серверу...</p></div>';
|
||||
|
||||
// Показываем модальное окно
|
||||
presetTestModal.show();
|
||||
|
||||
// Запускаем тест
|
||||
setTimeout(() => {
|
||||
startPresetTest();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function startPresetTest() {
|
||||
if (testRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logsContainer = document.getElementById('preset-test-logs');
|
||||
logsContainer.innerHTML = '';
|
||||
|
||||
// Показываем кнопки управления
|
||||
document.getElementById('stop-btn').style.display = 'inline-block';
|
||||
document.getElementById('restart-btn').style.display = 'inline-block';
|
||||
document.getElementById('modal-stop-btn').disabled = false;
|
||||
document.getElementById('modal-restart-btn').disabled = false;
|
||||
|
||||
testRunning = true;
|
||||
|
||||
// Создаем WebSocket подключение
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/preset/test/{{ preset.name }}?category={{ preset.category }}`);
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/preset/test/{{ preset.name }}?category={{ preset.category }}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
testWebSocket = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
addLog('info', '🔌 Подключено к серверу');
|
||||
ws.send(JSON.stringify({
|
||||
action: 'start',
|
||||
preset_name: '{{ preset.name }}',
|
||||
@@ -205,64 +252,195 @@ function startPresetTest() {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'log' || data.type === 'info') {
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = `log-line log-${data.level || 'info'}`;
|
||||
logLine.textContent = data.data;
|
||||
logsContainer.appendChild(logLine);
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
} else if (data.type === 'error') {
|
||||
const errorLine = document.createElement('div');
|
||||
errorLine.className = 'log-line log-error';
|
||||
errorLine.textContent = data.data;
|
||||
logsContainer.appendChild(errorLine);
|
||||
} else if (data.type === 'complete') {
|
||||
const completeLine = document.createElement('div');
|
||||
completeLine.className = 'log-line log-info';
|
||||
completeLine.textContent = data.data || '✅ Тестирование завершено';
|
||||
logsContainer.appendChild(completeLine);
|
||||
testRunning = false;
|
||||
document.getElementById('stop-btn').style.display = 'none';
|
||||
document.getElementById('restart-btn').style.display = 'none';
|
||||
ws.close();
|
||||
if (data.type === 'log' || data.type === 'info') {
|
||||
addLog(data.level || 'info', data.data);
|
||||
} else if (data.type === 'error') {
|
||||
addLog('error', data.data);
|
||||
} else if (data.type === 'complete') {
|
||||
addLog('info', data.data || '✅ Тестирование завершено');
|
||||
testRunning = false;
|
||||
document.getElementById('modal-stop-btn').disabled = true;
|
||||
document.getElementById('modal-restart-btn').disabled = false;
|
||||
ws.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка парсинга сообщения:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
const errorLine = document.createElement('div');
|
||||
errorLine.className = 'log-line log-error';
|
||||
errorLine.textContent = `❌ Ошибка подключения: ${error}`;
|
||||
logsContainer.appendChild(errorLine);
|
||||
addLog('error', `❌ Ошибка подключения WebSocket`);
|
||||
testRunning = false;
|
||||
document.getElementById('modal-stop-btn').disabled = true;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
testRunning = false;
|
||||
console.log('WebSocket закрыт');
|
||||
if (testWebSocket === ws) {
|
||||
testWebSocket = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function stopPresetTest() {
|
||||
if (testWebSocket && testWebSocket.readyState === WebSocket.OPEN) {
|
||||
testWebSocket.send(JSON.stringify({ action: 'stop' }));
|
||||
addLog('info', '⏹️ Остановка тестирования...');
|
||||
}
|
||||
if (testWebSocket) {
|
||||
testWebSocket.close();
|
||||
testWebSocket = null;
|
||||
}
|
||||
testRunning = false;
|
||||
document.getElementById('stop-btn').style.display = 'none';
|
||||
document.getElementById('restart-btn').style.display = 'none';
|
||||
document.getElementById('modal-stop-btn').disabled = true;
|
||||
document.getElementById('modal-restart-btn').disabled = false;
|
||||
}
|
||||
|
||||
function restartPresetTest() {
|
||||
stopPresetTest();
|
||||
const logsContainer = document.getElementById('preset-test-logs');
|
||||
logsContainer.innerHTML = '';
|
||||
setTimeout(() => {
|
||||
startPresetTest();
|
||||
}, 1000);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function clearTestLogs() {
|
||||
document.getElementById('test-logs').innerHTML = '';
|
||||
function clearPresetTestLogs() {
|
||||
const logsContainer = document.getElementById('preset-test-logs');
|
||||
logsContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
function addLog(level, message) {
|
||||
const logsContainer = document.getElementById('preset-test-logs');
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = `log-line log-${level}`;
|
||||
|
||||
// Цвета для разных уровней логов
|
||||
const colors = {
|
||||
'error': '#f48771',
|
||||
'warning': '#d19a66',
|
||||
'info': '#61afef',
|
||||
'success': '#98c379',
|
||||
'debug': '#abb2bf'
|
||||
};
|
||||
|
||||
logLine.style.color = colors[level] || colors['debug'];
|
||||
logLine.textContent = message;
|
||||
logsContainer.appendChild(logLine);
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Стили для модального окна тестирования preset'а - как у модального окна логов сборки */
|
||||
#presetTestModal .modal-dialog {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
}
|
||||
|
||||
#presetTestModal .modal-content {
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
#presetTestModal .modal-header {
|
||||
flex-shrink: 0 !important;
|
||||
padding: 1rem !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
#presetTestModal .modal-body {
|
||||
flex: 1 1 0 !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
width: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
height: auto !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
#preset-test-logs {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow: auto !important;
|
||||
background: #1e1e1e !important;
|
||||
color: #d4d4d4 !important;
|
||||
padding: 1rem !important;
|
||||
font-family: 'Courier New', monospace !important;
|
||||
font-size: 0.875rem !important;
|
||||
white-space: pre !important;
|
||||
word-wrap: normal !important;
|
||||
overflow-wrap: normal !important;
|
||||
}
|
||||
|
||||
#presetTestModal .modal-footer {
|
||||
flex-shrink: 0 !important;
|
||||
padding: 1rem !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Дополнительные стили для гарантии полной высоты */
|
||||
#presetTestModal.show .modal-body,
|
||||
#presetTestModal.showing .modal-body {
|
||||
height: calc(100vh - 180px) !important;
|
||||
min-height: calc(100vh - 180px) !important;
|
||||
max-height: calc(100vh - 180px) !important;
|
||||
}
|
||||
|
||||
#presetTestModal.show #preset-test-logs,
|
||||
#presetTestModal.showing #preset-test-logs {
|
||||
height: calc(100vh - 180px) !important;
|
||||
min-height: calc(100vh - 180px) !important;
|
||||
max-height: calc(100vh - 180px) !important;
|
||||
}
|
||||
|
||||
/* Стили для строк логов */
|
||||
#preset-test-logs .log-line {
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#preset-test-logs .log-error {
|
||||
color: #f48771 !important;
|
||||
}
|
||||
|
||||
#preset-test-logs .log-warning {
|
||||
color: #d19a66 !important;
|
||||
}
|
||||
|
||||
#preset-test-logs .log-info {
|
||||
color: #61afef !important;
|
||||
}
|
||||
|
||||
#preset-test-logs .log-success {
|
||||
color: #98c379 !important;
|
||||
}
|
||||
|
||||
#preset-test-logs .log-debug {
|
||||
color: #abb2bf !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
<div x-data="presetEditor()">
|
||||
<form
|
||||
hx-post="/api/v1/presets/{{ preset.name }}/update?category={{ preset.category }}"
|
||||
hx-target="#result"
|
||||
hx-swap="innerHTML"
|
||||
hx-swap="none"
|
||||
@submit.prevent="submitForm"
|
||||
class="card"
|
||||
>
|
||||
@@ -374,9 +373,6 @@
|
||||
>
|
||||
{% endif %}
|
||||
|
||||
<!-- Результат -->
|
||||
<div id="result" class="card-body border-top"></div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="card-footer">
|
||||
<div class="d-flex gap-2">
|
||||
@@ -514,5 +510,58 @@ function presetEditor() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка ответа от HTMX для обновления пресета
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Проверяем, что это запрос на обновление пресета
|
||||
if (event.detail.path && event.detail.path.includes('/api/v1/presets/') && event.detail.path.includes('/update')) {
|
||||
if (event.detail.xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
if (response.success) {
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
response.message || 'Preset успешно обновлен',
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна обновляем страницу
|
||||
location.reload();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто обновляем страницу
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка парсинга ответа:', e);
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при обновлении preset', 'error');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
const errorMessage = response.detail || response.message || 'Ошибка при обновлении preset';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при обновлении preset', 'error');
|
||||
} else {
|
||||
alert('Ошибка при обновлении preset');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -115,10 +115,7 @@
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button
|
||||
hx-delete="/api/v1/presets/{{ preset.name }}?category={{ preset.category or 'main' }}"
|
||||
hx-confirm="Удалить preset '{{ preset.name }}'?"
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
onclick="deletePreset('{{ preset.name }}', '{{ preset.category or 'main' }}', this)"
|
||||
class="btn btn-outline-danger"
|
||||
title="Удалить"
|
||||
>
|
||||
@@ -225,3 +222,95 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function deletePreset(presetName, category, button) {
|
||||
// Показываем модальное окно подтверждения
|
||||
const confirmed = await showConfirmModal(
|
||||
`Вы уверены, что хотите удалить preset '${presetName}'?`,
|
||||
'Подтверждение удаления'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Отключаем кнопку во время запроса
|
||||
button.disabled = true;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/presets/${presetName}?category=${category}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
data.message || `Preset '${presetName}' успешно удален`,
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна удаляем строку из таблицы
|
||||
const row = button.closest('tr');
|
||||
if (row) {
|
||||
row.remove();
|
||||
// Обновляем счетчик, если нужно
|
||||
const totalSpan = document.querySelector('.text-muted.small strong');
|
||||
if (totalSpan) {
|
||||
const currentTotal = parseInt(totalSpan.textContent) || 0;
|
||||
totalSpan.textContent = Math.max(0, currentTotal - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто удаляем строку
|
||||
const row = button.closest('tr');
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = errorData.detail || errorData.message || 'Ошибка при удалении preset';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при удалении preset', 'error');
|
||||
} else {
|
||||
alert('Ошибка при удалении preset');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении preset:', error);
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при удалении preset', 'error');
|
||||
} else {
|
||||
alert('Ошибка при удалении preset');
|
||||
}
|
||||
} finally {
|
||||
// Восстанавливаем кнопку
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/profile/docker-settings"
|
||||
hx-target="#docker-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-swap="none"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Имя пользователя</label>
|
||||
@@ -66,8 +65,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="docker-result" class="mb-3"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить настройки Docker Hub
|
||||
@@ -89,8 +86,7 @@
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/api/v1/profile/docker-settings"
|
||||
hx-target="#harbor-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-swap="none"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">URL Harbor</label>
|
||||
@@ -139,8 +135,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="harbor-result" class="mb-3"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Сохранить настройки Harbor
|
||||
@@ -150,4 +144,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Обработка успешного сохранения настроек Docker
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.path === '/api/v1/profile/docker-settings' && event.detail.xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
if (response.success) {
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
response.message || 'Настройки успешно сохранены',
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна обновляем страницу
|
||||
location.reload();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто обновляем страницу
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка парсинга ответа:', e);
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при сохранении настроек', 'error');
|
||||
}
|
||||
}
|
||||
} else if (event.detail.path === '/api/v1/profile/docker-settings' && event.detail.xhr.status !== 200) {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
const errorMessage = response.detail || response.message || 'Ошибка при сохранении настроек';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при сохранении настроек', 'error');
|
||||
} else {
|
||||
alert('Ошибка при сохранении настроек');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -255,8 +255,7 @@
|
||||
|
||||
<form
|
||||
hx-post="/api/v1/profile"
|
||||
hx-target="#result"
|
||||
hx-swap="innerHTML"
|
||||
hx-swap="none"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Имя пользователя</label>
|
||||
@@ -292,8 +291,6 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div id="result" class="mb-3"></div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
@@ -318,20 +315,48 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Обработка успешного сохранения профиля
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.target.id === 'result' && event.detail.xhr.status === 200) {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
if (response.success) {
|
||||
// Показываем сообщение об успехе
|
||||
const resultDiv = document.getElementById('result');
|
||||
resultDiv.innerHTML = '<div class="alert alert-success alert-dismissible fade show" role="alert">' +
|
||||
'<i class="fas fa-check-circle me-2"></i>' + response.message +
|
||||
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>' +
|
||||
'</div>';
|
||||
|
||||
// Обновляем страницу через 1.5 секунды
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
if (event.detail.path === '/api/v1/profile' && event.detail.xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
if (response.success) {
|
||||
// Показываем модальное окно с успешным сообщением
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(
|
||||
response.message || 'Профиль успешно обновлен',
|
||||
'success',
|
||||
'Успешно',
|
||||
function() {
|
||||
// После закрытия модального окна обновляем страницу
|
||||
location.reload();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Если функция недоступна, просто обновляем страницу
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка парсинга ответа:', e);
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при обновлении профиля', 'error');
|
||||
}
|
||||
}
|
||||
} else if (event.detail.path === '/api/v1/profile' && event.detail.xhr.status !== 200) {
|
||||
// Ошибка - показываем в модальном окне
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
const errorMessage = response.detail || response.message || 'Ошибка при обновлении профиля';
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal(errorMessage, 'error');
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showMessageModal) {
|
||||
window.showMessageModal('Ошибка при обновлении профиля', 'error');
|
||||
} else {
|
||||
alert('Ошибка при обновлении профиля');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -47,8 +47,10 @@ kind_clusters:
|
||||
ingress_host_https_port: 8445
|
||||
|
||||
images:
|
||||
debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy"
|
||||
rhel: "quay.io/centos/centos:stream9-systemd"
|
||||
debian: "inecs/ansible-lab:ubuntu22-latest"
|
||||
ubuntu: "inecs/ansible-lab:ubuntu22-latest"
|
||||
rhel: "inecs/ansible-lab:rhel-latest"
|
||||
centos: "inecs/ansible-lab:centos9-latest"
|
||||
|
||||
systemd_defaults:
|
||||
privileged: true
|
||||
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user