# Детали работы веб-интерфейса: тестирование и inventory **Автор:** Сергей Антропов **Сайт:** https://devops.org.ru ## 🎯 Обзор Этот документ описывает, как пользователь будет работать с тестированием ролей, выбором Docker образов, настройкой inventory и Molecule через веб-интерфейс. --- ## 1. 🐳 Выбор Docker образов и количества контейнеров ### 1.1. Страница создания/редактирования preset'а **URL:** `/presets/create` или `/presets/{preset_name}/edit` **Интерфейс:** ``` ┌─────────────────────────────────────────────────────────┐ │ Создание Preset'а: "my-custom-test" │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📋 Доступные образы: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ ☑ Ubuntu 22.04 [inecs/ansible-lab:ubuntu22] │ │ │ │ ☐ Ubuntu 20.04 [inecs/ansible-lab:ubuntu20] │ │ │ │ ☑ Debian 12 [inecs/ansible-lab:debian12] │ │ │ │ ☐ Debian 11 [inecs/ansible-lab:debian11] │ │ │ │ ☑ CentOS 9 [inecs/ansible-lab:centos9] │ │ │ │ ☐ CentOS 8 [inecs/ansible-lab:centos8] │ │ │ │ ☐ RHEL 8 [inecs/ansible-lab:rhel] │ │ │ │ ☐ AlmaLinux 8 [inecs/ansible-lab:alma] │ │ │ │ ☐ Rocky Linux 8 [inecs/ansible-lab:rocky] │ │ │ │ ☐ ALT Linux 9 [inecs/ansible-lab:alt9] │ │ │ │ ☐ Astra Linux [inecs/ansible-lab:astra] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ➕ Добавить кастомный образ: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Registry: [docker.io] │ │ │ │ Image: [my-custom/image:tag] │ │ │ │ [Добавить] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 🖥️ Настройка хостов: │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Хост 1 │ │ │ │ ├─ Имя: [web1] │ │ │ │ ├─ Образ: [Ubuntu 22.04 ▼] │ │ │ │ ├─ Группы: [web, frontend] (можно добавить) │ │ │ │ └─ [🗑️ Удалить] │ │ │ ├─────────────────────────────────────────────────┤ │ │ │ Хост 2 │ │ │ │ ├─ Имя: [web2] │ │ │ │ ├─ Образ: [Debian 12 ▼] │ │ │ │ ├─ Группы: [web, backend] │ │ │ │ └─ [🗑️ Удалить] │ │ │ ├─────────────────────────────────────────────────┤ │ │ │ Хост 3 │ │ │ │ ├─ Имя: [db1] │ │ │ │ ├─ Образ: [CentOS 9 ▼] │ │ │ │ ├─ Группы: [database, backend] │ │ │ │ └─ [🗑️ Удалить] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [➕ Добавить хост] │ │ │ │ 📊 Предпросмотр: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Всего хостов: 3 │ │ │ │ Ubuntu 22.04: 1 │ │ │ │ Debian 12: 1 │ │ │ │ CentOS 9: 1 │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [💾 Сохранить preset] [👁️ Предпросмотр YAML] │ └─────────────────────────────────────────────────────────┘ ``` ### 1.2. Быстрое создание preset'а при тестировании **URL:** `/roles/{role_name}/test` **Интерфейс:** ``` ┌─────────────────────────────────────────────────────────┐ │ Тестирование роли: nginx │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📋 Выбор preset'а: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ ○ Использовать существующий preset │ │ │ │ [default ▼] [minimal] [all-images] │ │ │ │ │ │ │ │ ● Создать временный preset для этого теста │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ Быстрый выбор: │ │ │ │ │ │ ☑ Ubuntu 22.04 [Количество: 2] │ │ │ │ │ │ ☑ Debian 12 [Количество: 2] │ │ │ │ │ │ ☐ CentOS 9 [Количество: 0] │ │ │ │ │ │ ☐ RHEL 8 [Количество: 0] │ │ │ │ │ │ │ │ │ │ │ │ Итого: 4 хоста │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Или детальная настройка: │ │ │ │ [Открыть редактор preset'а] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ⚙️ Параметры теста: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ ☑ Параллельное выполнение [Количество: 2] │ │ │ │ ☑ Проверка синтаксиса (lint) │ │ │ │ ☑ Проверка идемпотентности │ │ │ │ ☐ Verbose режим │ │ │ │ ☐ Dry-run (только проверка) │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [🚀 Запустить тест] │ └─────────────────────────────────────────────────────────┘ ``` ### 1.3. Визуальный редактор хостов (Drag & Drop) **Интерфейс с возможностью перетаскивания:** ``` ┌─────────────────────────────────────────────────────────┐ │ Редактор хостов │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📦 Доступные образы (перетащите для добавления): │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │Ubuntu│ │Debian│ │CentOS│ │RHEL │ │ │ │ 22.04│ │ 12 │ │ 9 │ │ 8 │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ │ 🖥️ Хосты (перетащите для изменения порядка): │ │ ┌─────────────────────────────────────────────────┐ │ │ │ ═══ web1 (Ubuntu 22.04) [web, frontend] [✏️][🗑️]│ │ │ ├─────────────────────────────────────────────────┤ │ │ │ ═══ web2 (Debian 12) [web, backend] [✏️][🗑️] │ │ │ ├─────────────────────────────────────────────────┤ │ │ │ ═══ db1 (CentOS 9) [database] [✏️][🗑️] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [➕ Добавить хост] [🔄 Сбросить] [💾 Сохранить] │ └─────────────────────────────────────────────────────────┘ ``` **HTMX реализация:** ```html
Ubuntu 22.04
``` --- ## 2. 📝 Заполнение Inventory ### 2.1. Визуальный редактор Inventory **URL:** `/inventory/edit` или `/presets/{preset_name}/inventory` **Интерфейс:** ``` ┌─────────────────────────────────────────────────────────┐ │ Редактор Inventory │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📋 Группы хостов: │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ [web_servers] │ │ │ │ ├─ web1 │ │ │ │ │ ├─ ansible_host: [172.17.0.2] │ │ │ │ │ ├─ ansible_user: [ansible] │ │ │ │ │ ├─ ansible_ssh_private_key_file: [~/.ssh/id_rsa]│ │ │ │ └─ [➕ Добавить переменную] │ │ │ │ │ │ │ │ │ └─ web2 │ │ │ │ ├─ ansible_host: [172.17.0.3] │ │ │ │ └─ ... │ │ │ │ │ │ │ │ [➕ Добавить хост в группу] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ [database_servers] │ │ │ │ └─ db1 │ │ │ │ └─ ... │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ [all:vars] (глобальные переменные) │ │ │ │ ├─ ansible_user: [ansible] │ │ │ │ ├─ ansible_ssh_private_key_file: [~/.ssh/id_rsa]│ │ │ ├─ ansible_python_interpreter: [/usr/bin/python3]│ │ │ └─ [➕ Добавить переменную] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 📄 Предпросмотр INI формата: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ [web_servers] │ │ │ │ web1 ansible_host=172.17.0.2 │ │ │ │ web2 ansible_host=172.17.0.3 │ │ │ │ │ │ │ │ [database_servers] │ │ │ │ db1 ansible_host=172.17.0.4 │ │ │ │ │ │ │ │ [all:vars] │ │ │ │ ansible_user=ansible │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [💾 Сохранить] [📥 Экспорт в файл] [📤 Импорт] │ └─────────────────────────────────────────────────────────┘ ``` ### 2.2. Автоматическая генерация Inventory из preset'а **При создании preset'а автоматически генерируется inventory:** ```python # app/services/inventory_service.py class InventoryService: def generate_from_preset(self, preset: dict) -> str: """Генерация inventory из preset'а""" inventory = [] # Группировка хостов по группам groups = {} for host in preset['hosts']: for group in host.get('groups', []): if group not in groups: groups[group] = [] groups[group].append(host) # Формирование INI for group_name, hosts in groups.items(): inventory.append(f"[{group_name}]") for host in hosts: # Получение IP из Docker контейнера ip = self.get_container_ip(host['name']) inventory.append(f"{host['name']} ansible_host={ip}") inventory.append("") # Глобальные переменные inventory.append("[all:vars]") inventory.append("ansible_user=ansible") inventory.append("ansible_ssh_private_key_file=~/.ssh/id_rsa") return "\n".join(inventory) ``` ### 2.3. Редактирование Inventory для реальных серверов **URL:** `/inventory/production/edit` **Интерфейс для продакшн inventory:** ``` ┌─────────────────────────────────────────────────────────┐ │ Production Inventory │ ├─────────────────────────────────────────────────────────┤ │ │ │ ⚠️ Это inventory для реальных серверов! │ │ │ │ 📋 Группы: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ [web_servers] │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ │ Хост: web1.example.com │ │ │ │ │ │ IP: [192.168.1.10] │ │ │ │ │ │ Пользователь: [deploy] │ │ │ │ │ │ SSH ключ: [~/.ssh/deploy_key] │ │ │ │ │ │ Порт: [22] │ │ │ │ │ │ Python: [/usr/bin/python3] │ │ │ │ │ │ [🔐 Тест подключения] │ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ [➕ Добавить хост] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 🔐 Безопасность: │ │ ☑ Использовать Vault для секретов │ │ ☑ Шифровать SSH ключи │ │ │ │ [💾 Сохранить] [🔒 Зашифровать в Vault] │ └─────────────────────────────────────────────────────────┘ ``` --- ## 3. 🧪 Тестирование в Molecule ### 3.1. Страница тестирования роли **URL:** `/roles/{role_name}/test` **Полный интерфейс:** ``` ┌─────────────────────────────────────────────────────────┐ │ Тестирование роли: nginx │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📋 Шаг 1: Выбор окружения │ │ ┌─────────────────────────────────────────────────┐ │ │ │ ○ Использовать preset │ │ │ │ [default ▼] [minimal] [all-images] │ │ │ │ │ │ │ │ ● Создать кастомное окружение │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ Образы и хосты: │ │ │ │ │ │ [См. раздел 1.1] │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 📝 Шаг 2: Настройка переменных роли │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Переменные из defaults/main.yml: │ │ │ │ │ │ │ │ nginx_version: [1.25.0] │ │ │ │ nginx_worker_processes: [auto] │ │ │ │ nginx_worker_connections: [1024] │ │ │ │ nginx_enabled: ☑ │ │ │ │ nginx_sites: │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ │ [➕ Добавить сайт] │ │ │ │ │ │ - name: example.com │ │ │ │ │ │ root: /var/www/html │ │ │ │ │ └─────────────────────────────────────┘ │ │ │ │ │ │ │ │ [📥 Загрузить из файла] │ │ │ │ [💾 Сохранить как шаблон] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ⚙️ Шаг 3: Параметры Molecule │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Molecule сценарий: [default] │ │ │ │ │ │ │ │ Этапы тестирования: │ │ │ │ ☑ create (создание контейнеров) │ │ │ │ ☑ prepare (подготовка) │ │ │ │ ☑ converge (применение роли) │ │ │ │ ☑ idempotence (проверка идемпотентности) │ │ │ │ ☑ verify (проверка через testinfra) │ │ │ │ ☑ destroy (удаление контейнеров) │ │ │ │ │ │ │ │ Дополнительные опции: │ │ │ │ ☑ --destroy=never (не удалять после теста) │ │ │ │ ☐ --scenario-name=custom (кастомный сценарий) │ │ │ │ ☐ --driver-name=docker (драйвер) │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 🔍 Шаг 4: Настройка проверок (verify) │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Использовать testinfra: ☑ │ │ │ │ │ │ │ │ Тесты для проверки: │ │ │ │ ☑ nginx установлен │ │ │ │ ☑ nginx запущен │ │ │ │ ☑ nginx слушает на порту 80 │ │ │ │ ☑ конфигурация валидна │ │ │ │ │ │ │ │ [➕ Добавить кастомный тест] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [🚀 Запустить тест] [💾 Сохранить конфигурацию] │ └─────────────────────────────────────────────────────────┘ ``` ### 3.2. Live мониторинг теста **После запуска теста открывается страница с live логами:** ``` ┌─────────────────────────────────────────────────────────┐ │ Тест роли: nginx | Статус: 🟢 Running │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📊 Прогресс: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ ████████████░░░░░░░░ 60% │ │ │ │ Этап: converge (применение роли) │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 📋 Этапы: │ │ ✅ create - Создание контейнеров (5s) │ │ ✅ prepare - Подготовка (2s) │ │ 🟢 converge - Применение роли (в процессе...) │ │ ⏳ idempotence - Ожидание... │ │ ⏳ verify - Ожидание... │ │ ⏳ destroy - Ожидание... │ │ │ │ 📝 Логи (live): │ │ ┌─────────────────────────────────────────────────┐ │ │ │ [2024-01-15 10:30:15] TASK [nginx : Install] │ │ │ │ [2024-01-15 10:30:16] changed: [web1] │ │ │ │ [2024-01-15 10:30:17] TASK [nginx : Configure] │ │ │ │ [2024-01-15 10:30:18] changed: [web1] │ │ │ │ ... │ │ │ │ │ │ │ │ [Автоскролл: ☑] [Очистить] [📥 Скачать] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 🖥️ Статус контейнеров: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ web1 (Ubuntu 22.04) 🟢 Running │ │ │ │ web2 (Debian 12) 🟢 Running │ │ │ │ db1 (CentOS 9) 🟢 Running │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [⏸️ Пауза] [⏹️ Остановить] [🔄 Перезапустить] │ └─────────────────────────────────────────────────────────┘ ``` **WebSocket реализация:** ```python # app/api/v1/endpoints/websocket.py @router.websocket("/ws/test/{test_id}/logs") async def test_logs_websocket(websocket: WebSocket, test_id: str): await websocket.accept() # Запуск molecule test с потоковым выводом process = await asyncio.create_subprocess_exec( "molecule", "test", "-s", "default", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=f"roles/{test_id}" ) # Отправка логов в реальном времени async for line in process.stdout: await websocket.send_json({ "type": "log", "stage": detect_stage(line), # create, converge, etc. "data": line.decode(), "timestamp": datetime.now().isoformat() }) # Результат await websocket.send_json({ "type": "complete", "status": "success" if process.returncode == 0 else "failed" }) ``` ### 3.3. Редактирование molecule.yml через интерфейс **URL:** `/roles/{role_name}/molecule/edit` **Интерфейс:** ``` ┌─────────────────────────────────────────────────────────┐ │ Редактор molecule.yml для роли: nginx │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📋 Вкладки: │ │ [Driver] [Platforms] [Provisioner] [Verifier] │ │ │ │ 🚗 Driver (Docker): │ │ ┌─────────────────────────────────────────────────┐ │ │ │ driver: │ │ │ │ name: [docker ▼] │ │ │ │ options: │ │ │ │ privileged: ☑ │ │ │ │ volumes: │ │ │ │ - /sys/fs/cgroup:/sys/fs/cgroup:ro │ │ │ │ capabilities: │ │ │ │ - SYS_ADMIN │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 🖥️ Platforms (контейнеры): │ │ ┌─────────────────────────────────────────────────┐ │ │ │ [➕ Добавить платформу] │ │ │ │ │ │ │ │ Платформа 1: │ │ │ │ ├─ name: [web1] │ │ │ │ ├─ image: [inecs/ansible-lab:ubuntu22 ▼] │ │ │ │ ├─ pre_build_image: ☑ │ │ │ │ └─ [🗑️ Удалить] │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ 📝 Предпросмотр YAML: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ driver: │ │ │ │ name: docker │ │ │ │ platforms: │ │ │ │ - name: web1 │ │ │ │ image: inecs/ansible-lab:ubuntu22 │ │ │ │ pre_build_image: true │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [💾 Сохранить] [✅ Валидация] [🔄 Сбросить] │ └─────────────────────────────────────────────────────────┘ ``` --- ## 4. 🔄 Интеграция с существующими preset'ами ### 4.1. Использование preset'ов из `molecule/presets/` **Автоматическое обнаружение preset'ов:** ```python # app/services/preset_service.py class PresetService: def list_presets(self) -> List[dict]: """Список всех preset'ов из molecule/presets/""" presets = [] preset_dir = Path("molecule/presets") for preset_file in preset_dir.glob("*.yml"): with open(preset_file) as f: preset_data = yaml.safe_load(f) presets.append({ "name": preset_file.stem, "description": preset_data.get("description", ""), "hosts_count": len(preset_data.get("hosts", [])), "images": self.extract_images(preset_data), "file": str(preset_file) }) return presets def load_preset(self, name: str) -> dict: """Загрузка preset'а""" preset_file = Path(f"molecule/presets/{name}.yml") with open(preset_file) as f: return yaml.safe_load(f) ``` ### 4.2. Редактирование существующих preset'ов **Интерфейс редактирования:** ``` ┌─────────────────────────────────────────────────────────┐ │ Редактирование preset'а: default │ ├─────────────────────────────────────────────────────────┤ │ │ │ 📋 Информация: │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Описание: [Стандартный preset для тестирования] │ │ │ │ Файл: molecule/presets/default.yml │ │ │ │ Хостов: 2 │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ [См. интерфейс из раздела 1.1] │ │ │ │ ⚠️ Внимание: Изменения сохраняются в файл! │ │ │ │ [💾 Сохранить] [📋 Создать копию] [🗑️ Удалить] │ └─────────────────────────────────────────────────────────┘ ``` --- ## 5. 🎯 Примеры использования ### 5.1. Быстрый тест на одном образе **Сценарий:** Пользователь хочет быстро протестировать роль на Ubuntu 22.04 1. Переходит на `/roles/nginx/test` 2. Выбирает "Создать временный preset" 3. Ставит галочку только на "Ubuntu 22.04", количество: 1 4. Нажимает "Запустить тест" 5. Смотрит live логи ### 5.2. Тест на нескольких ОС **Сценарий:** Пользователь хочет протестировать на Ubuntu, Debian и CentOS 1. Переходит на `/roles/nginx/test` 2. Выбирает "Создать временный preset" 3. Ставит галочки: - Ubuntu 22.04 (2 хоста) - Debian 12 (2 хоста) - CentOS 9 (1 хост) 4. Настраивает переменные роли 5. Запускает тест 6. Смотрит результаты по каждому хосту ### 5.3. Создание и сохранение кастомного preset'а **Сценарий:** Пользователь создает preset для тестирования веб-серверов 1. Переходит на `/presets/create` 2. Называет preset: "web-servers-test" 3. Добавляет хосты: - web1 (Ubuntu 22.04) в группы [web, frontend] - web2 (Debian 12) в группы [web, backend] - lb1 (CentOS 9) в группы [web, loadbalancer] 4. Сохраняет preset 5. Использует его для тестирования: `/roles/nginx/test?preset=web-servers-test` ### 5.4. Редактирование inventory для продакшн **Сценарий:** Пользователь настраивает inventory для реальных серверов 1. Переходит на `/inventory/production/edit` 2. Добавляет группу [web_servers] 3. Добавляет хосты с реальными IP 4. Настраивает SSH ключи 5. Тестирует подключение к каждому хосту 6. Сохраняет в Vault для безопасности --- ## 6. 🔧 Технические детали реализации ### 6.1. Генерация molecule.yml из preset'а ```python # app/services/molecule_service.py class MoleculeService: def generate_molecule_yml(self, preset: dict, role_name: str) -> str: """Генерация molecule.yml из preset'а""" molecule_config = { "driver": { "name": "docker" }, "platforms": [], "provisioner": { "name": "ansible", "inventory": { "hosts": self.generate_inventory_hosts(preset) } }, "verifier": { "name": "ansible" } } # Преобразование хостов из preset'а в platforms for host in preset['hosts']: molecule_config['platforms'].append({ "name": host['name'], "image": preset['images'][host['family']], "pre_build_image": True, **preset.get('systemd_defaults', {}) }) return yaml.dump(molecule_config) ``` ### 6.2. Выполнение molecule команд ```python # app/core/molecule_executor.py class MoleculeExecutor: async def test( self, role_name: str, scenario: str = "default", stream: bool = False ): """Запуск molecule test""" cmd = [ "molecule", "test", "-s", scenario, "--destroy", "never" # Не удалять для просмотра ] if stream: # Для WebSocket process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=f"roles/{role_name}" ) return process else: result = subprocess.run( cmd, capture_output=True, text=True, cwd=f"roles/{role_name}" ) return { "success": result.returncode == 0, "stdout": result.stdout, "stderr": result.stderr } ``` --- ## 📝 Резюме Все действия по настройке тестирования выполняются через веб-интерфейс: 1. **Выбор образов и хостов:** Визуальный редактор с drag & drop 2. **Настройка inventory:** Автоматическая генерация + ручное редактирование 3. **Molecule тестирование:** Полный контроль через интерфейс с live логами 4. **Preset'ы:** Создание, редактирование, использование через UI 5. **Переменные ролей:** Формы для заполнения с валидацией Всё интуитивно, визуально и без необходимости знать команды make или структуру файлов! --- **Автор:** Сергей Антропов **Сайт:** https://devops.org.ru