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