diff --git a/app/Makefile b/app/Makefile index 81ff570..0759d0f 100644 --- a/app/Makefile +++ b/app/Makefile @@ -22,6 +22,8 @@ help: @echo " make shell - Открыть shell в контейнере web" @echo " make clean - Очистить контейнеры и volumes" @echo " make rebuild - Пересобрать и перезапустить" + @echo " make migrate - Применить миграции БД" + @echo " make load-presets - Импортировать пресеты из файловой системы" build: $(COMPOSE) build @@ -58,3 +60,9 @@ clean: status: $(COMPOSE) ps + +migrate: + $(COMPOSE) exec web bash -c "cd /app/app && alembic upgrade head" + +load-presets: + $(COMPOSE) exec web bash -c "cd /app/app && python scripts/load_presets.py" diff --git a/app/alembic/presets/k8s/k8s-istio-full.yml b/app/alembic/presets/k8s/k8s-istio-full.yml index f310a27..2d5b914 100644 --- a/app/alembic/presets/k8s/k8s-istio-full.yml +++ b/app/alembic/presets/k8s/k8s-istio-full.yml @@ -29,8 +29,10 @@ kind_clusters: ingress_host_https_port: 8443 images: - debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy" - rhel: "quay.io/centos/centos:stream9-systemd" + debian: "inecs/ansible-lab:ubuntu22-latest" + ubuntu: "inecs/ansible-lab:ubuntu22-latest" + rhel: "inecs/ansible-lab:rhel-latest" + centos: "inecs/ansible-lab:centos9-latest" systemd_defaults: privileged: true diff --git a/app/alembic/presets/k8s/k8s-multi.yml b/app/alembic/presets/k8s/k8s-multi.yml index 5a3d098..05689ff 100644 --- a/app/alembic/presets/k8s/k8s-multi.yml +++ b/app/alembic/presets/k8s/k8s-multi.yml @@ -47,8 +47,10 @@ kind_clusters: ingress_host_https_port: 8445 images: - debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy" - rhel: "quay.io/centos/centos:stream9-systemd" + debian: "inecs/ansible-lab:ubuntu22-latest" + ubuntu: "inecs/ansible-lab:ubuntu22-latest" + rhel: "inecs/ansible-lab:rhel-latest" + centos: "inecs/ansible-lab:centos9-latest" systemd_defaults: privileged: true diff --git a/app/alembic/presets/k8s/k8s-single.yml b/app/alembic/presets/k8s/k8s-single.yml index 68f921c..bace1c5 100644 --- a/app/alembic/presets/k8s/k8s-single.yml +++ b/app/alembic/presets/k8s/k8s-single.yml @@ -24,8 +24,10 @@ kind_clusters: ingress_host_https_port: 8443 images: - debian: "ghcr.io/ansible-community/molecule-ubuntu-systemd:jammy" - rhel: "quay.io/centos/centos:stream9-systemd" + debian: "inecs/ansible-lab:ubuntu22-latest" + ubuntu: "inecs/ansible-lab:ubuntu22-latest" + rhel: "inecs/ansible-lab:rhel-latest" + centos: "inecs/ansible-lab:centos9-latest" systemd_defaults: privileged: true diff --git a/app/alembic/versions/004_migrate_presets_and_dockerfiles.py b/app/alembic/versions/004_migrate_presets_and_dockerfiles.py index 6ca6d26..4095ee5 100644 --- a/app/alembic/versions/004_migrate_presets_and_dockerfiles.py +++ b/app/alembic/versions/004_migrate_presets_and_dockerfiles.py @@ -39,59 +39,70 @@ def upgrade(): presets_dir = old_presets_dir k8s_presets_dir = presets_dir / "k8s" - # Основные preset'ы + # Функция для импорта preset'а + def import_preset(preset_file, category='main'): + try: + with open(preset_file) as f: + content = f.read() + preset_data = yaml.safe_load(content) or {} + + # Извлечение описания из комментария + description = None + for line in content.split('\n'): + if line.strip().startswith('#description:'): + description = line.split('#description:')[1].strip() + break + + # Проверка существования в БД + result = connection.execute( + sa.text("SELECT id FROM presets WHERE name = :name"), + {"name": preset_file.stem} + ) + if result.fetchone(): + return False + + # Преобразуем dict/list в JSON строки для PostgreSQL + hosts_json = json.dumps(preset_data.get('hosts', [])) + images_json = json.dumps(preset_data.get('images', {})) + systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {})) + kind_clusters_json = json.dumps(preset_data.get('kind_clusters', [])) + + connection.execute( + sa.text(""" + INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at) + VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at) + """), + { + 'name': preset_file.stem, + 'category': category, + 'description': description, + 'content': content, + 'docker_network': preset_data.get('docker_network'), + 'hosts': hosts_json, + 'images': images_json, + 'systemd_defaults': systemd_defaults_json, + 'kind_clusters': kind_clusters_json, + 'created_at': datetime.utcnow(), + 'updated_at': datetime.utcnow() + } + ) + return True + except Exception as e: + print(f"Ошибка при импорте preset {preset_file.name}: {e}") + return False + + # Основные preset'ы из корня папки presets if presets_dir.exists(): for preset_file in presets_dir.glob("*.yml"): if preset_file.name == "deploy.yml": continue - - try: - with open(preset_file) as f: - content = f.read() - preset_data = yaml.safe_load(content) or {} - - # Извлечение описания из комментария - description = None - for line in content.split('\n'): - if line.strip().startswith('#description:'): - description = line.split('#description:')[1].strip() - break - - # Проверка существования в БД - result = connection.execute( - sa.text("SELECT id FROM presets WHERE name = :name"), - {"name": preset_file.stem} - ) - if result.fetchone(): - continue - - # Преобразуем dict/list в JSON строки для PostgreSQL - hosts_json = json.dumps(preset_data.get('hosts', [])) - images_json = json.dumps(preset_data.get('images', {})) - systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {})) - kind_clusters_json = json.dumps(preset_data.get('kind_clusters', [])) - - connection.execute( - sa.text(""" - INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at) - VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at) - """), - { - 'name': preset_file.stem, - 'category': 'main', - 'description': description, - 'content': content, - 'docker_network': preset_data.get('docker_network'), - 'hosts': hosts_json, - 'images': images_json, - 'systemd_defaults': systemd_defaults_json, - 'kind_clusters': kind_clusters_json, - 'created_at': datetime.utcnow(), - 'updated_at': datetime.utcnow() - } - ) - except Exception as e: - print(f"Ошибка при импорте preset {preset_file.name}: {e}") + import_preset(preset_file, category='main') + + # Пресеты из папки examples + examples_dir = presets_dir / "examples" + if examples_dir.exists(): + for preset_file in examples_dir.glob("*.yml"): + import_preset(preset_file, category='main') # K8s preset'ы if k8s_presets_dir.exists(): diff --git a/app/api/v1/endpoints/dockerfiles_api.py b/app/api/v1/endpoints/dockerfiles_api.py index 6a6411c..297d103 100644 --- a/app/api/v1/endpoints/dockerfiles_api.py +++ b/app/api/v1/endpoints/dockerfiles_api.py @@ -313,11 +313,24 @@ async def delete_dockerfile( db: AsyncSession = Depends(get_async_db) ): """Удаление Dockerfile""" + # Получаем имя Dockerfile до удаления + dockerfile = await DockerfileService.get_dockerfile(db, dockerfile_id) + if not dockerfile: + raise HTTPException(status_code=404, detail="Dockerfile не найден") + + dockerfile_name = dockerfile.name + + # Удаляем Dockerfile deleted = await DockerfileService.delete_dockerfile(db, dockerfile_id) if not deleted: raise HTTPException(status_code=404, detail="Dockerfile не найден") - return {"message": "Dockerfile удален успешно"} + return JSONResponse(content={ + "success": True, + "dockerfile_id": dockerfile_id, + "dockerfile_name": dockerfile_name, + "message": f"Dockerfile '{dockerfile_name}' успешно удален" + }) @router.get("/api/v1/dockerfiles") diff --git a/app/api/v1/endpoints/playbooks.py b/app/api/v1/endpoints/playbooks.py index c36d43c..a6d9ca8 100644 --- a/app/api/v1/endpoints/playbooks.py +++ b/app/api/v1/endpoints/playbooks.py @@ -206,11 +206,24 @@ async def delete_playbook( db: AsyncSession = Depends(get_async_db) ): """Удаление playbook""" + # Получаем имя playbook до удаления + playbook = await PlaybookService.get_playbook(db, playbook_id) + if not playbook: + raise HTTPException(status_code=404, detail="Playbook не найден") + + playbook_name = playbook.name + + # Удаляем playbook deleted = await PlaybookService.delete_playbook(db, playbook_id) if not deleted: raise HTTPException(status_code=404, detail="Playbook не найден") - return {"message": "Playbook удален успешно"} + return JSONResponse(content={ + "success": True, + "playbook_id": playbook_id, + "playbook_name": playbook_name, + "message": f"Playbook '{playbook_name}' успешно удален" + }) @router.get("/api/v1/playbooks") diff --git a/app/api/v1/endpoints/presets.py b/app/api/v1/endpoints/presets.py index cbfe84d..21aa23a 100644 --- a/app/api/v1/endpoints/presets.py +++ b/app/api/v1/endpoints/presets.py @@ -11,10 +11,14 @@ from pathlib import Path from typing import List, Dict, Optional import yaml import json +import logging from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.services.preset_service import PresetService from app.db.session import get_async_db +from app.auth.deps import get_current_user + +logger = logging.getLogger(__name__) router = APIRouter() templates_path = Path(__file__).parent.parent.parent.parent / "templates" @@ -75,11 +79,34 @@ async def get_presets_api( @router.get("/presets/create", response_class=HTMLResponse) -async def create_preset_page(request: Request): +async def create_preset_page( + request: Request, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_async_db) +): """Страница создания preset'а""" + from app.services.dockerfile_service import DockerfileService + + # Загружаем список Dockerfiles из БД + dockerfiles = await DockerfileService.list_dockerfiles(db, status="active") + dockerfiles_list = [ + { + "id": d.id, + "name": d.name, + "description": d.description, + "base_image": d.base_image, + "tags": d.tags, + "status": d.status + } + for d in dockerfiles + ] + return templates.TemplateResponse( "pages/presets/create.html", - {"request": request} + { + "request": request, + "dockerfiles": dockerfiles_list + } ) @@ -129,6 +156,8 @@ async def create_preset_api( description: str = Form(""), category: str = Form("main"), hosts: str = Form(""), + images: str = Form(""), + systemd_defaults: str = Form(""), db: AsyncSession = Depends(get_async_db) ): """API endpoint для создания preset'а""" @@ -137,12 +166,22 @@ async def create_preset_api( if hosts: hosts_list = json.loads(hosts) + images_dict = {} + if images: + images_dict = json.loads(images) + + systemd_defaults_dict = {} + if systemd_defaults: + systemd_defaults_dict = json.loads(systemd_defaults) + preset = await PresetService.create_preset( db=db, preset_name=preset_name, description=description, hosts=hosts_list, - category=category + category=category, + images=images_dict, + systemd_defaults=systemd_defaults_dict ) return JSONResponse(content={ @@ -270,6 +309,19 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category """WebSocket для live логов тестирования preset'а""" await websocket.accept() + # Используем класс для хранения ссылки на контейнер + class ContainerRef: + def __init__(self): + self.container = None + def set(self, container): + self.container = container + def get(self): + return self.container + + container_ref = ContainerRef() + executor = None + stop_requested = False + try: # Получаем preset из БД async for db in get_async_db(): @@ -285,18 +337,6 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category preset_content = preset.content break - # Получаем действие от клиента - data = await websocket.receive_json() - action = data.get("action", "start") - - if action == "stop": - await websocket.send_json({ - "type": "info", - "data": "⏹️ Остановка тестирования..." - }) - await websocket.close() - return - # Запуск тестирования preset'а from app.core.molecule_executor import MoleculeExecutor executor = MoleculeExecutor() @@ -309,34 +349,99 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category "data": f"🚀 Запуск тестирования preset'а '{preset_name}'..." }) - # Запускаем тест (без указания роли - тестируем все роли) - async for line in executor.test_role( - role_name=None, - preset_name=preset_name, - preset_content=preset_content, - preset_category=category, - stream=True - ): - line = line.rstrip() - if not line: - continue - - log_type = executor.detect_log_level(line) - - await websocket.send_json({ - "type": "log", - "level": log_type, - "data": line - }) + # Создаем задачу для мониторинга сообщений от клиента (стоп) + import asyncio + async def monitor_stop(): + nonlocal stop_requested + try: + while True: + try: + data = await asyncio.wait_for(websocket.receive_json(), timeout=1.0) + action = data.get("action") + if action == "stop": + stop_requested = True + cont = container_ref.get() + if cont: + try: + cont.stop() + await websocket.send_json({ + "type": "info", + "data": "⏹️ Остановка контейнера..." + }) + except Exception as e: + logger.error(f"Error stopping container: {e}") + break + except asyncio.TimeoutError: + continue + except WebSocketDisconnect: + stop_requested = True + break + except Exception: + pass - await websocket.send_json({ - "type": "complete", - "status": "success", - "data": "✅ Тестирование preset'а завершено" - }) + monitor_task = asyncio.create_task(monitor_stop()) + + # Запускаем тест (без указания роли - тестируем все роли) + 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: executor._temp_preset_files[preset_name].unlink() del executor._temp_preset_files[preset_name] @@ -344,14 +449,22 @@ async def preset_test_websocket(websocket: WebSocket, preset_name: str, category pass except WebSocketDisconnect: + # Соединение закрыто клиентом - это нормально + pass + except GeneratorExit: + # Генератор закрыт - это нормально pass except Exception as e: import traceback - error_msg = f"❌ Ошибка: {str(e)}\n{traceback.format_exc()}" - await websocket.send_json({ - "type": "error", - "data": error_msg - }) + error_msg = f"❌ Ошибка: {str(e)}" + try: + await websocket.send_json({ + "type": "error", + "data": error_msg + }) + except: + pass + logger.error(f"Error in preset_test_websocket: {e}\n{traceback.format_exc()}") finally: try: await websocket.close() diff --git a/app/core/docker_client.py b/app/core/docker_client.py index 149b2f0..76b6871 100644 --- a/app/core/docker_client.py +++ b/app/core/docker_client.py @@ -5,6 +5,7 @@ Docker клиент для управления контейнерами """ import docker +from docker import APIClient import os from typing import List, Dict, Optional from app.core.config import settings @@ -25,65 +26,44 @@ class DockerClient: """Ленивая инициализация Docker клиента""" if self._client is None: try: - # Получаем DOCKER_HOST из настроек или окружения - docker_host = os.getenv("DOCKER_HOST", settings.DOCKER_HOST) + # Временно удаляем DOCKER_HOST из окружения, если он установлен + # Это необходимо, так как Docker SDK может неправильно парсить его + original_docker_host = os.environ.pop("DOCKER_HOST", None) - logger.info(f"Initializing Docker client with DOCKER_HOST: {docker_host}") - - # Если DOCKER_HOST начинается с unix://, извлекаем путь к socket - if docker_host.startswith("unix://"): - socket_path = docker_host.replace("unix://", "") - # Убеждаемся, что путь начинается с / - if not socket_path.startswith("/"): - socket_path = "/" + socket_path - - # Docker SDK для unix socket ожидает base_url в формате "unix:///path/to/socket" - # Важно: после unix:// должно быть три слэша (unix:///) - # Например: "unix:///var/run/docker.sock" - base_url = f"unix://{socket_path}" - logger.info(f"Using unix socket: base_url={base_url}, socket_path={socket_path}") - - # НЕ используем docker.from_env() для unix socket, так как он неправильно парсит формат - # Используем только прямой base_url + try: + # Пробуем docker.from_env() без DOCKER_HOST + logger.info("Trying docker.from_env() without DOCKER_HOST") + self._client = docker.from_env() + self._client.ping() + logger.info("Docker client initialized successfully with docker.from_env()") + except Exception as e1: + logger.warning(f"docker.from_env() failed: {e1}") + # Если from_env не работает, пробуем прямой base_url try: + # Используем прямой путь к Docker socket + base_url = "unix:///var/run/docker.sock" + logger.info(f"Trying direct base_url: {base_url}") self._client = docker.DockerClient(base_url=base_url) - # Проверяем подключение сразу self._client.ping() - logger.info(f"Successfully created Docker client with base_url={base_url}") - except Exception as e: - logger.error(f"Failed to create Docker client with base_url={base_url}: {e}") - # Пробуем альтернативный формат (без префикса unix://) + logger.info("Docker client initialized successfully with direct base_url") + except Exception as e2: + logger.error(f"Direct base_url also failed: {e2}") + # Последняя попытка - используем APIClient try: - # Некоторые версии SDK могут требовать просто путь - # Но это не работает, так как base_url должен быть полный URL - # Поэтому пробуем стандартный формат по умолчанию - logger.warning("Trying default socket path") + logger.info("Trying APIClient as last resort") + api_client = APIClient(base_url="unix:///var/run/docker.sock") + api_client.version() + # Если APIClient работает, создаем DockerClient self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock") self._client.ping() - logger.info("Successfully created Docker client with default socket") - except Exception as e2: - logger.error(f"All methods failed. Last error: {e2}") + logger.info("Docker client initialized successfully with APIClient") + except Exception as e3: + logger.error(f"All methods failed. Last error: {e3}") raise - elif docker_host.startswith("/"): - # Прямой путь к socket - используем base_url с префиксом unix:// - base_url = f"unix://{docker_host}" - logger.info(f"Using direct socket path: {base_url}") - self._client = docker.DockerClient(base_url=base_url) - else: - # Для других форматов (tcp://, http:// и т.д.) используем from_env - # Но сначала проверяем, не установлена ли переменная DOCKER_HOST - if "DOCKER_HOST" in os.environ: - # Если DOCKER_HOST установлен, но не unix://, используем from_env - logger.info("Using docker.from_env()") - self._client = docker.from_env() - else: - # Если DOCKER_HOST не установлен, используем стандартный socket - logger.info("Using default socket: unix:///var/run/docker.sock") - self._client = docker.DockerClient(base_url="unix:///var/run/docker.sock") - - # Проверка подключения - self._client.ping() - logger.info("Docker client initialized successfully") + finally: + # Восстанавливаем DOCKER_HOST, если он был установлен + if original_docker_host: + os.environ["DOCKER_HOST"] = original_docker_host except Exception as e: logger.error(f"Failed to initialize Docker client: {e}") logger.error(f"DOCKER_HOST env: {os.getenv('DOCKER_HOST', 'not set')}") diff --git a/app/core/molecule_executor.py b/app/core/molecule_executor.py index bafc6ef..9de13e4 100644 --- a/app/core/molecule_executor.py +++ b/app/core/molecule_executor.py @@ -11,24 +11,88 @@ from typing import Optional, AsyncGenerator, Dict from datetime import datetime import yaml import tempfile +import logging from app.core.config import settings from app.core.docker_client import DockerClient +logger = logging.getLogger(__name__) + class MoleculeExecutor: """Выполнение Molecule тестов без использования Makefile""" def __init__(self): - self.project_root = settings.PROJECT_ROOT + # Определяем реальный путь к проекту для монтирования в docker run + # В docker-compose.yml проект монтируется как ../:/workspace:rw + # Когда мы запускаем docker run из контейнера, Docker Desktop на macOS + # не знает о пути /workspace внутри контейнера. Нужно использовать реальный путь на хосте. + project_root = settings.PROJECT_ROOT + + # Если PROJECT_ROOT = /workspace (внутри контейнера), но мы запускаем docker run, + # нужно использовать реальный путь на хосте. + # В docker-compose.yml монтируется ../:/workspace, значит на хосте это родительская директория от app/ + if str(project_root) == "/workspace": + # Получаем путь к app/ и поднимаемся на уровень выше (это и есть реальный путь на хосте) + current_file = Path(__file__) + # Внутри контейнера: /app/app/core/molecule_executor.py + # current_file.parent = /app/app/core + # current_file.parent.parent = /app/app + # current_file.parent.parent.parent = /app + # Нам нужно получить путь на хосте, который соответствует /workspace + # В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/ + # Но внутри контейнера код находится в /app/app/, а проект в /workspace + # Поэтому нужно использовать переменную окружения или определить путь по-другому + + # Попробуем получить путь из переменной окружения или использовать текущую рабочую директорию + import os + # Если есть переменная окружения с реальным путем на хосте + host_project_root = os.getenv("HOST_PROJECT_ROOT") + if host_project_root: + project_root = Path(host_project_root) + else: + # Используем путь относительно текущего файла + # Внутри контейнера: /app/app/core/molecule_executor.py + # На хосте: /Users/inecs/Documents/DevOpsLab/app/core/molecule_executor.py + # Нужно подняться на 2 уровня выше от app/ чтобы получить корень проекта + app_dir = current_file.parent.parent.parent # /app/app или /Users/.../app + # Если мы в /app/app, то родительская директория - это /app, но нам нужен путь на хосте + # Поэтому используем абсолютный путь, который будет правильно разрешен Docker Desktop + # Docker Desktop автоматически преобразует пути из контейнера в пути на хосте + # Но для этого нужно использовать путь, который существует на хосте + # В docker-compose.yml: ../:/workspace, значит на хосте это родительская директория от app/ + # На хосте: /Users/inecs/Documents/DevOpsLab + # Внутри контейнера: /workspace + # Когда мы запускаем docker run, нужно использовать путь на хосте + # Но мы не знаем его напрямую, поэтому используем относительный путь + # или определяем его через переменную окружения + project_root = Path("/workspace") # Оставляем как есть, но для docker run нужно использовать реальный путь + + self.project_root = project_root.resolve() if isinstance(project_root, Path) else Path(project_root).resolve() + + # Для docker run нужно использовать реальный путь на хосте + # Определяем его через переменную окружения или используем путь относительно app/ + self.host_project_root = os.getenv("HOST_PROJECT_ROOT", str(self.project_root)) + if self.host_project_root == "/workspace": + # Если не задана переменная, пытаемся определить путь на хосте + # В docker-compose.yml монтируется ../:/workspace + # Значит на хосте это родительская директория от app/ + current_file = Path(__file__) + app_dir = current_file.parent.parent.parent # app/ + # На хосте app/ находится в /Users/.../DevOpsLab/app + # Значит корень проекта на хосте - это родительская директория от app/ + # Но внутри контейнера мы не знаем этот путь напрямую + # Поэтому используем путь, который Docker Desktop может разрешить + # Docker Desktop автоматически преобразует пути из volume mounts + # Но для docker run нужно использовать путь на хосте + # Лучше всего использовать переменную окружения HOST_PROJECT_ROOT + self.host_project_root = str(self.project_root) + self.molecule_dir = self.project_root / "molecule" / "default" - # Пресеты теперь находятся в alembic/presets - # Находим путь к alembic относительно текущего файла - current_file = Path(__file__) - alembic_dir = current_file.parent.parent / "alembic" - self.presets_dir = alembic_dir / "presets" - # Если не найдено, пробуем старый путь (для обратной совместимости) - if not self.presets_dir.exists(): - self.presets_dir = self.project_root / "molecule" / "presets" + # Пресеты для Molecule должны находиться в molecule/presets + # чтобы create.yml мог их найти по пути /workspace/molecule/presets/ + # ВАЖНО: presets_dir должен быть относительно project_root, который внутри контейнера = /workspace + # Это гарантирует, что файлы будут доступны внутри ansible-controller контейнера + self.presets_dir = Path("/workspace") / "molecule" / "presets" self.docker_client = DockerClient() self._temp_preset_files = {} # Кэш временных файлов preset'ов @@ -158,7 +222,9 @@ class MoleculeExecutor: preset_name: str = "default", preset_content: Optional[str] = None, preset_category: str = "main", - stream: bool = False + stream: bool = False, + stop_event: Optional[callable] = None, + container_ref: Optional[object] = None ) -> AsyncGenerator[str, None]: """ Тестирование роли через Molecule @@ -169,96 +235,496 @@ class MoleculeExecutor: preset_content: Содержимое preset'а из БД (если None - загружается из файла) preset_category: Категория preset'а (main или k8s) stream: Если True, возвращает генератор строк для WebSocket + stop_event: Функция для проверки флага остановки + container_ref: Объект для хранения ссылки на контейнер Yields: Строки вывода команды """ - # Если preset_content передан, создаем временный файл из БД - if preset_content: + container = None + try: + # Если preset_content передан, создаем временный файл из БД + if preset_content: + try: + self.create_temp_preset_file(preset_name, preset_content, preset_category) + preset_data = self.load_preset_from_db(preset_content) + except Exception as e: + yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n" + return + else: + # Проверка существования preset'а в файловой системе + try: + preset_data = self.load_preset(preset_name, preset_category) + except FileNotFoundError as e: + yield f"❌ Ошибка: {str(e)}\n" + yield "💡 Убедитесь, что preset существует в БД или файловой системе\n" + return + + # Расшифровка vault файлов + yield "🔓 Расшифровка vault файлов...\n" + await self.decrypt_vault_files() + + # Запуск ansible-controller контейнера + yield "🔧 Запуск ansible-controller контейнера...\n" + + # Подготовка переменных окружения + env = { + "ANSIBLE_FORCE_COLOR": "1", + "MOLECULE_PRESET": preset_name, + "MOLECULE_EPHEMERAL_DIRECTORY": "/tmp/molecule_workspace", + "MOLECULE_VAULT_ENABLED": "false" + } + + if role_name: + env["MOLECULE_ROLE_NAME"] = role_name + yield f"📋 Тестируется роль: {role_name}\n" + + yield f"📋 Используется пресет: {preset_name}\n\n" + + # Используем Docker SDK вместо subprocess для запуска контейнера + # Команда docker может быть недоступна внутри контейнера + container_name = f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}" + try: - self.create_temp_preset_file(preset_name, preset_content, preset_category) - preset_data = self.load_preset_from_db(preset_content) + # Подготавливаем volumes для монтирования + volumes = { + str(self.host_project_root): {"bind": "/workspace", "mode": "rw"}, + "/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"} + } + + # Запускаем контейнер + container = self.docker_client.client.containers.run( + image="inecs/ansible-lab:ansible-controller-latest", + name=container_name, + command=["bash", "-c", self._build_test_command(role_name, preset_name, preset_category)], + environment=env, + volumes=volumes, + working_dir="/workspace", + user="root", + detach=True, + remove=False, # Не удаляем автоматически, чтобы можно было получить логи + auto_remove=False + ) + + # Сохраняем ссылку на контейнер для возможности остановки + if container_ref and hasattr(container_ref, 'set'): + container_ref.set(container) + + # Потоковый вывод логов (асинхронно) + try: + # Используем обычную очередь для передачи данных между потоками + import queue + import threading + log_queue = queue.Queue() + loop = asyncio.get_event_loop() + + def read_logs(): + """Синхронная функция для чтения логов""" + try: + for line in container.logs(stream=True, follow=True, stdout=True, stderr=True): + log_queue.put(line) + except Exception as e: + log_queue.put(None) + + # Запускаем чтение логов в отдельном потоке + log_thread = threading.Thread(target=read_logs, daemon=True) + log_thread.start() + + # Читаем логи из очереди асинхронно + while True: + # Проверяем флаг остановки + if stop_event and stop_event(): + try: + yield "\n⏹️ Остановка тестирования по запросу пользователя...\n" + except GeneratorExit: + # Генератор уже закрыт - выполняем cleanup без yield + pass + + # Останавливаем и удаляем контейнер ansible-controller + try: + if container: + container.stop(timeout=10) + logger.info(f"Container {container_name} stopped") + try: + yield "🛑 Контейнер ansible-controller остановлен\n" + except GeneratorExit: + pass + except Exception as e: + logger.error(f"Error stopping container: {e}") + try: + yield f"⚠️ Ошибка при остановке контейнера: {e}\n" + except GeneratorExit: + pass + + # Запускаем destroy.yml для очистки контейнеров из preset'а + try: + yield "\n🧹 Очистка контейнеров из preset'а...\n" + except GeneratorExit: + pass + + # Выполняем cleanup (синхронно, без yield) + cleanup_success = False + try: + await self._cleanup_preset_containers(preset_name, preset_category) + logger.info(f"Cleanup completed for preset {preset_name}") + cleanup_success = True + try: + yield "✅ Контейнеры из preset'а удалены\n" + except GeneratorExit: + pass + except Exception as e: + logger.error(f"Error cleaning up preset containers: {e}") + try: + yield f"⚠️ Ошибка при очистке контейнеров preset'а: {e}\n" + except GeneratorExit: + pass + + # Удаляем контейнер ansible-controller + try: + if container: + container.remove(force=True) + logger.info(f"Container {container_name} removed") + try: + yield "🗑️ Контейнер ansible-controller удален\n" + except GeneratorExit: + pass + except Exception as e: + logger.error(f"Error removing container: {e}") + try: + yield f"⚠️ Ошибка при удалении контейнера: {e}\n" + except GeneratorExit: + pass + + # Сохраняем флаг, что cleanup был выполнен + cleanup_done = cleanup_success + + # Выходим из цикла + break + + try: + # Используем run_in_executor для чтения из обычной очереди + line = await asyncio.get_event_loop().run_in_executor( + None, + lambda: log_queue.get(timeout=0.1) + ) + if line is None: + break + yield line.decode('utf-8', errors='replace') + except queue.Empty: + # Проверяем, завершился ли контейнер + try: + container.reload() + if container.status == 'exited': + # Читаем оставшиеся логи из очереди + while not log_queue.empty(): + try: + line = log_queue.get_nowait() + if line is not None: + yield line.decode('utf-8', errors='replace') + except queue.Empty: + break + # Читаем финальные логи из контейнера + remaining_logs = container.logs(stdout=True, stderr=True) + if remaining_logs: + yield remaining_logs.decode('utf-8', errors='replace') + break + except Exception as e: + logger.error(f"Error reloading container: {e}") + break + continue + except GeneratorExit: + # Генератор закрыт - останавливаем контейнер и очищаем ресурсы + # НЕ используем yield здесь, так как генератор уже закрывается + logger.info(f"GeneratorExit caught, cleaning up container {container_name if container else 'unknown'}") + try: + if container: + container.stop(timeout=5) + container.remove(force=True) + logger.info(f"Container {container_name} stopped and removed on GeneratorExit") + except Exception as e: + logger.error(f"Error cleaning up container on GeneratorExit: {e}") + + # Запускаем cleanup при закрытии генератора (без yield) + try: + await self._cleanup_preset_containers(preset_name, preset_category) + logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit") + except Exception as e: + logger.error(f"Error in cleanup on GeneratorExit: {e}") + + # Обязательно поднимаем исключение дальше + raise + except Exception as e: + logger.error(f"Error reading logs: {e}") + break + finally: + # Ожидаем завершения контейнера (если он еще не остановлен) + # cleanup_done может быть уже установлен при остановке по запросу + if 'cleanup_done' not in locals(): + cleanup_done = False + if container: + try: + container.reload() + if container.status != 'exited': + try: + exit_code = container.wait(timeout=5)["StatusCode"] + except: + # Если не удалось дождаться, останавливаем принудительно + container.stop(timeout=5) + exit_code = -1 + else: + exit_code = container.attrs.get("State", {}).get("ExitCode", -1) + except Exception as e: + logger.error(f"Error waiting for container: {e}") + exit_code = -1 + + # Получаем финальные логи, если есть + try: + final_logs = container.logs(stdout=True, stderr=True, tail=100) + if final_logs: + yield final_logs.decode('utf-8', errors='replace') + except Exception as e: + logger.error(f"Error getting final logs: {e}") + + # Удаляем контейнер ansible-controller + try: + if container: + container.stop(timeout=5) + container.remove(force=True) + try: + yield f"\n🗑️ Контейнер {container_name} удален\n" + except GeneratorExit: + pass + except Exception as e: + logger.error(f"Error removing container {container_name}: {e}") + try: + yield f"\n⚠️ Предупреждение: не удалось удалить контейнер {container_name}: {e}\n" + except GeneratorExit: + pass + + # Запускаем cleanup контейнеров из preset'а (всегда, даже если был вызван ранее) + # Это гарантирует, что контейнеры будут удалены даже при ошибках + try: + logger.info(f"Running cleanup in finally block for preset {preset_name}") + await self._cleanup_preset_containers(preset_name, preset_category) + logger.info(f"Cleanup completed in finally block for preset {preset_name}") + try: + yield "\n🧹 Контейнеры из preset'а очищены\n" + except GeneratorExit: + pass + except Exception as e: + logger.error(f"Error in cleanup during finally: {e}", exc_info=True) + try: + yield f"\n⚠️ Ошибка при очистке контейнеров preset'а: {e}\n" + except GeneratorExit: + pass except Exception as e: - yield f"❌ Ошибка создания временного файла preset'а: {str(e)}\n" - return - else: - # Проверка существования preset'а в файловой системе + yield f"❌ Ошибка при запуске контейнера: {str(e)}\n" + import traceback + yield f"Детали: {traceback.format_exc()}\n" + # Пытаемся удалить контейнер, если он был создан + try: + container = self.docker_client.client.containers.get(container_name) + container.remove(force=True) + except: + pass + + # Шифрование vault файлов + yield "\n🔒 Шифрование vault файлов...\n" + await self.encrypt_vault_files() + + # Проверяем код возврата контейнера (если не было остановки) + if not (stop_event and stop_event()) and container: + try: + container.reload() + exit_code = container.attrs.get("State", {}).get("ExitCode", -1) + if exit_code == 0: + yield "\n✅ Тестирование завершено успешно\n" + elif exit_code == -1: + yield "\n✅ Тестирование preset'а завершено\n" + else: + yield f"\n❌ Тестирование завершено с ошибкой (код: {exit_code})\n" + except: + yield "\n✅ Тестирование preset'а завершено\n" + except GeneratorExit: + # Генератор закрыт - останавливаем контейнер и очищаем ресурсы + # НЕ используем yield здесь, так как генератор уже закрывается + if container: + try: + container.stop(timeout=5) + container.remove(force=True) + logger.info(f"Container {container_name} stopped and removed due to GeneratorExit") + except Exception as e: + logger.error(f"Error removing container on GeneratorExit: {e}") + # Запускаем cleanup при закрытии генератора (без yield) try: - preset_data = self.load_preset(preset_name, preset_category) - except FileNotFoundError as e: - yield f"❌ Ошибка: {str(e)}\n" - yield "💡 Убедитесь, что preset существует в БД или файловой системе\n" - return - - # Расшифровка 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_cmd = [ - "docker", "run", "--rm", - "--name", f"ansible-controller-{datetime.now().strftime('%Y%m%d%H%M%S')}", - "-v", f"{self.project_root}:/workspace", - "-w", "/workspace", - "-v", "/var/run/docker.sock:/var/run/docker.sock", - "-u", "root" - ] - - # Добавляем переменные окружения - 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" + if preset_content or preset_name: + await self._cleanup_preset_containers(preset_name, preset_category) + logger.info(f"Cleanup completed for preset {preset_name} on GeneratorExit") + except Exception as e: + logger.error(f"Error in cleanup on GeneratorExit: {e}") + raise + except Exception as e: + # Обрабатываем другие исключения + if container: + try: + container.stop(timeout=5) + container.remove(force=True) + except: + pass + # Запускаем cleanup при ошибке (без yield, так как генератор может быть закрыт) + try: + if preset_content or preset_name: + await self._cleanup_preset_containers(preset_name, preset_category) + except: + pass + # Пытаемся отправить сообщение об ошибке, если генератор еще открыт + try: + yield f"\n❌ Критическая ошибка: {str(e)}\n" + except GeneratorExit: + # Генератор закрыт - это нормально + raise + except: + # Другие ошибки при yield - игнорируем + pass - 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 = [ "echo -e '\\033[33m=== СОЗДАНИЕ ТЕСТОВЫХ КОНТЕЙНЕРОВ ==='", "echo ''", "mkdir -p /tmp/molecule_workspace/inventory", + ] + + # Для k8s пресетов сначала запускаем create_k8s_cluster.py + is_k8s_preset = False + if preset_category == "k8s": + is_k8s_preset = True + elif preset_name: + if preset_name.startswith("k8s-") or preset_name in ["kubernetes", "k8s-full"]: + is_k8s_preset = True + + if is_k8s_preset: + # Определяем путь к preset файлу + # Для k8s пресетов файл должен быть в /workspace/molecule/presets/k8s/ + if preset_category == "k8s": + preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" + elif preset_name: + k8s_names = ["kubernetes", "k8s-full"] + if preset_name.startswith("k8s-") or preset_name in k8s_names: + preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" + else: + preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" + else: + preset_file_path = f"/workspace/molecule/presets/k8s/{preset_name}.yml" + + # Формируем команду для создания Kind кластера + script_path = "/workspace/scripts/create_k8s_cluster.py" + k8s_cmd_str = f"test -f {preset_file_path} && python3 {script_path} {preset_file_path} ansible-controller || echo Ошибка при создании Kind кластера" + commands.extend([ + "echo -e '\\033[33m=== СОЗДАНИЕ KUBERNETES КЛАСТЕРА ==='", + "echo ''", + k8s_cmd_str, + "echo ''", + "echo -e '\\033[33m=== СОЗДАНИЕ DOCKER КОНТЕЙНЕРОВ ==='", + "echo ''", + ]) + + commands.extend([ "cd molecule/default", "ansible-playbook -i localhost, create.yml --connection=local -e molecule_ephemeral_directory=/tmp/molecule_workspace", "echo ''", @@ -276,7 +742,7 @@ class MoleculeExecutor: "echo ''", "echo -e '\\033[33m=== ЗАПУСК ROLES/DEPLOY.YML НА ТЕСТОВЫХ КОНТЕЙНЕРАХ ===\\033[0m'", "echo ''" - ] + ]) # Добавляем команду для deploy.yml с фильтрацией по роли if role_name: diff --git a/app/docker-compose.yml b/app/docker-compose.yml index 4f6bbed..2ee9a91 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -45,6 +45,7 @@ services: environment: - PROJECT_ROOT=/workspace - PROJECT_NAME=devops-lab + - HOST_PROJECT_ROOT=${HOST_PROJECT_ROOT:-${PWD}/..} - API_HOST=0.0.0.0 - API_PORT=8000 - API_RELOAD=true diff --git a/app/requirements.txt b/app/requirements.txt index 7906e6b..44a59c4 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -25,7 +25,7 @@ redis==4.6.0 # Celery 5.3.4 требует redis <5.0.0 websockets==12.0 # Docker -docker==6.1.3 +docker==7.1.0 # Git GitPython==3.1.40 diff --git a/app/scripts/load_presets.py b/app/scripts/load_presets.py index deefadf..17e8926 100644 --- a/app/scripts/load_presets.py +++ b/app/scripts/load_presets.py @@ -61,65 +61,86 @@ def load_presets(): skipped_count = 0 error_count = 0 - # Основные preset'ы + # Функция для импорта одного пресета + def import_preset(preset_file, category='main'): + try: + with open(preset_file) as f: + content = f.read() + preset_data = yaml.safe_load(content) or {} + + # Извлечение описания из комментария + description = None + for line in content.split('\n'): + if line.strip().startswith('#description:'): + description = line.split('#description:')[1].strip() + break + + # Проверка существования в БД + result = connection.execute( + text("SELECT id FROM presets WHERE name = :name"), + {"name": preset_file.stem} + ) + if result.fetchone(): + print(f"⏭️ Пропущен (уже существует): {preset_file.stem}") + return 'skipped' + + # Вставка в БД + # Преобразуем dict/list в JSON строки для PostgreSQL + hosts_json = json.dumps(preset_data.get('hosts', [])) + images_json = json.dumps(preset_data.get('images', {})) + systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {})) + kind_clusters_json = json.dumps(preset_data.get('kind_clusters', [])) + + connection.execute( + text(""" + INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at) + VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at) + """), + { + 'name': preset_file.stem, + 'category': category, + 'description': description, + 'content': content, + 'docker_network': preset_data.get('docker_network'), + 'hosts': hosts_json, + 'images': images_json, + 'systemd_defaults': systemd_defaults_json, + 'kind_clusters': kind_clusters_json, + 'created_at': datetime.utcnow(), + 'updated_at': datetime.utcnow() + } + ) + connection.commit() + print(f"✅ Загружен: {preset_file.stem}") + return 'loaded' + except Exception as e: + print(f"❌ Ошибка при загрузке {preset_file.name}: {e}") + return 'error' + + # Основные preset'ы из корня папки presets if presets_dir.exists(): for preset_file in presets_dir.glob("*.yml"): if preset_file.name == "deploy.yml": continue - - try: - with open(preset_file) as f: - content = f.read() - preset_data = yaml.safe_load(content) or {} - - # Извлечение описания из комментария - description = None - for line in content.split('\n'): - if line.strip().startswith('#description:'): - description = line.split('#description:')[1].strip() - break - - # Проверка существования в БД - result = connection.execute( - text("SELECT id FROM presets WHERE name = :name"), - {"name": preset_file.stem} - ) - if result.fetchone(): - print(f"⏭️ Пропущен (уже существует): {preset_file.stem}") - skipped_count += 1 - continue - - # Вставка в БД - # Преобразуем dict/list в JSON строки для PostgreSQL - hosts_json = json.dumps(preset_data.get('hosts', [])) - images_json = json.dumps(preset_data.get('images', {})) - systemd_defaults_json = json.dumps(preset_data.get('systemd_defaults', {})) - kind_clusters_json = json.dumps(preset_data.get('kind_clusters', [])) - - connection.execute( - text(""" - INSERT INTO presets (name, category, description, content, docker_network, hosts, images, systemd_defaults, kind_clusters, created_at, updated_at) - VALUES (:name, :category, :description, :content, :docker_network, CAST(:hosts AS jsonb), CAST(:images AS jsonb), CAST(:systemd_defaults AS jsonb), CAST(:kind_clusters AS jsonb), :created_at, :updated_at) - """), - { - 'name': preset_file.stem, - 'category': 'main', - 'description': description, - 'content': content, - 'docker_network': preset_data.get('docker_network'), - 'hosts': hosts_json, - 'images': images_json, - 'systemd_defaults': systemd_defaults_json, - 'kind_clusters': kind_clusters_json, - 'created_at': datetime.utcnow(), - 'updated_at': datetime.utcnow() - } - ) - connection.commit() - print(f"✅ Загружен: {preset_file.stem}") + result = import_preset(preset_file, category='main') + if result == 'loaded': loaded_count += 1 - except Exception as e: - print(f"❌ Ошибка при загрузке {preset_file.name}: {e}") + elif result == 'skipped': + skipped_count += 1 + elif result == 'error': + error_count += 1 + + # Пресеты из папки examples + examples_dir = presets_dir / "examples" + if examples_dir.exists(): + print(f"📁 Поиск пресетов в examples: {examples_dir}") + for preset_file in examples_dir.glob("*.yml"): + result = import_preset(preset_file, category='main') + if result == 'loaded': + loaded_count += 1 + elif result == 'skipped': + skipped_count += 1 + elif result == 'error': error_count += 1 # K8s preset'ы diff --git a/app/services/preset_service.py b/app/services/preset_service.py index 3eb83ad..1202603 100644 --- a/app/services/preset_service.py +++ b/app/services/preset_service.py @@ -98,6 +98,8 @@ class PresetService: description: str = "", hosts: List[Dict] = None, category: str = "main", + images: Dict = None, + systemd_defaults: Dict = None, created_by: Optional[str] = None ) -> Preset: """Создание нового preset'а в БД""" @@ -107,7 +109,12 @@ class PresetService: raise ValueError(f"Preset '{preset_name}' уже существует") # Генерация содержимого preset'а - content = PresetService._generate_preset_content(description, hosts or []) + content = PresetService._generate_preset_content_from_form( + description=description, + hosts=hosts or [], + images=images or {}, + systemd_defaults=systemd_defaults or {} + ) # Парсинг для извлечения данных data = yaml.safe_load(content) @@ -234,32 +241,11 @@ class PresetService: kind_clusters: List = None ) -> str: """Генерация содержимого preset'а из формы""" - # Базовые образы по умолчанию - default_images = { - "alt9": "inecs/ansible-lab:alt9-latest", - "alt10": "inecs/ansible-lab:alt10-latest", - "astra": "inecs/ansible-lab:astra-linux-latest", - "rhel": "inecs/ansible-lab:rhel-latest", - "centos7": "inecs/ansible-lab:centos7-latest", - "centos8": "inecs/ansible-lab:centos8-latest", - "centos9": "inecs/ansible-lab:centos9-latest", - "alma": "inecs/ansible-lab:alma-latest", - "rocky": "inecs/ansible-lab:rocky-latest", - "redos": "inecs/ansible-lab:redos-latest", - "ubuntu20": "inecs/ansible-lab:ubuntu20-latest", - "ubuntu22": "inecs/ansible-lab:ubuntu22-latest", - "ubuntu24": "inecs/ansible-lab:ubuntu24-latest", - "debian9": "inecs/ansible-lab:debian9-latest", - "debian10": "inecs/ansible-lab:debian10-latest", - "debian11": "inecs/ansible-lab:debian11-latest", - "debian12": "inecs/ansible-lab:debian12-latest" - } + # Используем только переданные образы (без дефолтных) + final_images = images or {} - # Объединяем с переданными образами - final_images = {**default_images, **(images or {})} - - # Systemd defaults по умолчанию - default_systemd = { + # Используем переданные настройки systemd или дефолтные, если не переданы + final_systemd = systemd_defaults or { "privileged": True, "command": "/sbin/init", "volumes": ["/sys/fs/cgroup:/sys/fs/cgroup:rw"], @@ -267,9 +253,6 @@ class PresetService: "capabilities": ["SYS_ADMIN"] } - # Объединяем с переданными настройками - final_systemd = {**default_systemd, **(systemd_defaults or {})} - # Заголовок content = f"""--- #description: {description or "Пользовательский preset"} diff --git a/app/templates/pages/dockerfiles/list.html b/app/templates/pages/dockerfiles/list.html index 1928f15..1cbad37 100644 --- a/app/templates/pages/dockerfiles/list.html +++ b/app/templates/pages/dockerfiles/list.html @@ -91,10 +91,7 @@ + + + +
Хосты
@@ -84,18 +166,10 @@ x-model="host.family" class="form-select" > - - - - - - - - - - - - + +
@@ -134,15 +208,89 @@
+ +
+
Systemd Defaults
+
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ - - -
+ +