# Goherence Замена Ansible на Go: без Python-зависимостей, с guardrails против превращения плейбуков в императивный код. ## Статус Реализованы все пункты плана из `goherence-architecture.md`, плюс параллелизм и конфигурация, добавленные по ходу реального использования: - Парсинг плейбуков, инвентаря (с вложенными группами и group_vars/host_vars), ролей. - Мердж переменных по 8-уровневой цепочке приоритета. - SSH-транспорт (ключ/пароль, exec, запись файлов, RunWithInput для JSON-обмена, `~` в пути к ключу разворачивается автоматически). - **Builtin-модули**: `shell`, `command`, `copy`, `template` (text/template + sprig), `apt`, `rpm_package`, `file`, `systemd`, **`package`** (сам выбирает `apt`/`rpm_package` по `os_family`, либо автоопределяет провайдера на хосте, если факты не собирались). - `loop` — таск выполняется по одному разу на каждый элемент списка. - Guardrails-линтер (`internal/lint`): чистота `when`, запрет "тихого" shell, запрет self-referencing `set_fact`, запрет динамических `include_tasks`, предупреждение о сложности роли. - External-модули по JSON stdin/stdout контракту (`internal/module/external`), с доставкой на хост под `$HOME/.goherence/modules` (без требования root) и кэшем по версии. - Конвертер Jinja2 → text/template+sprig (`cmd/j2conv`). - **Сбор минимальных фактов** (`internal/facts`): `os_family`, `distribution`, `system` — включается/выключается через `gather_facts` в конфиге или `--gather-facts` в CLI. - **Хэндлеры** (`notify`/`handlers/main.yml`) — выполняются один раз в конце play, только если хотя бы один уведомивший таск реально изменил состояние. - **Граф зависимостей тасков** (`before`/`after`, `internal/executor/graph.go`): стабильная топологическая сортировка — без объявленных зависимостей порядок в точности как в файле, с ними переставляются только реально связанные таски. Аналог `require`/`before` в Puppet. Юнит-тесты — `graph_test.go`. - **Файл конфигурации** (`internal/config`, пример — `goherence.yml`): `forks`, `serial`, `max_fails`, `become`, `gather_facts`, `timeout`, ssh-дефолты, `modules_dir`. Приоритет: built-in default → `goherence.yml` → явный флаг CLI. - **Rolling-обновление с порогом ошибок** (`serial`/`max_fails`): хосты обрабатываются батчами по `serial` (1 — классическое "один хост за раз"), и если упавших хостов набирается `max_fails`, оставшиеся батчи не запускаются — то, что уже обновилось, не откатывается. - **Параллелизм по хостам** (`internal/executor`, goroutine на хост, ограничено `forks` через буферизованный семафор). Подтверждено реальным замером: 3 хоста × 2-секундный таск — 8 сек при `forks=1`, 3 сек при `forks=3`. Вывод сериализован мьютексом, `go test -race` и `go build -race` чисты. ## Известные упрощения первой версии - `TemplateModule.Vars` пересчитывается один раз на весь набор тасков play/роли, а не per-task. - `command` реализован идентично `shell`. - `--check` даёт содержательный diff только для `copy`/`template`. - Ретеншн-политики для старых версий доставленных модулей нет. - `j2conv` — набор regex-правил, не полноценный парсер Jinja2. - Хэндлеры используют переменные play/host/facts, но не role.Defaults/Vars той роли, где объявлены — общий пул хэндлеров на весь play. - `become` — только глобальный переключатель на весь прогон, без per-task или per-play override. - При параллельном запуске (`forks > 1`) первая ошибка на любом хосте останавливает запуск новых хостов, но уже идущие доводятся до конца; ошибки с разных хостов не агрегируются в единый multi-error — возвращается только первая. ## Баги, найденные и исправленные при реальном интеграционном тестировании Всё ниже — не гипотетические соображения, а то, что реально всплыло при прогоне против настоящего `sshd` (локальный сервер, `ssh-keygen`, тестовый пользователь без root): 1. **Порядок аргументов CLI** — флаги должны идти до пути к плейбуку (ограничение пакета `flag`, не решение архитектуры). 2. **Адрес подключения игнорировался** — SSH шло по имени хоста из инвентаря вместо `goherence_host` из его переменных. 3. **`ssh.Conn.WriteFile` возвращал сырой `EOF`** вместо содержательной ошибки при падении удалённой команды. 4. **`internal/module/external`: путь для модулей требовал root** (`/opt/goherence/modules`) — теперь резолвится через `$HOME` подключившегося пользователя. 5. **`when`-сравнения не резолвили переменные** — `os_family == "Debian"` был всегда `false`, потому что сравнивалось имя переменной как строковый литерал, а не её значение. Есть юнит-тест на 13 разных `when`-выражений (`internal/executor/condition_test.go`). 6. **`~` в пути к SSH-ключу не разворачивался** — `os.ReadFile("~/...")` в Go не понимает `~` сам по себе. ## Переменные подключения Адрес и пользователь для SSH могут переопределяться per-host/per-group переменными в инвентаре (аналог `ansible_host`/`ansible_user`, но без чужого нейминга): ```yaml all: vars: goherence_user: deploy # дефолтный пользователь для всех хостов children: webservers: hosts: web1: goherence_host: 10.0.0.1 # реальный адрес, если он не совпадает с именем хоста ``` Приоритет обычный инвентарный: `host_vars` > группа хоста > `all`. Флаг CLI `-u`/`--key`/`--port` действует, если ни то, ни другое не задано. ## Конфигурация `goherence.yml` в текущей директории подхватывается автоматически (или явно через `-c path/to/config.yml`). Все поля опциональны: ```yaml forks: 5 # сколько хостов обрабатывать одновременно become: false # глобально sudo для всех команд gather_facts: true # собирать os_family и т.п. timeout: 10s # таймаут SSH-подключения ssh_user: deploy ssh_key_path: ~/.ssh/id_ed25519 ssh_port: 22 modules_dir: modules.d ``` Приоритет: built-in default → `goherence.yml` → явный флаг CLI (`--forks`, `--become`, `--gather-facts`, `-u`, `--key`, `--port`, `--modules-dir`). ## Запуск примера Флаги — первыми, путь к плейбуку — последним аргументом (это ограничение самого пакета `flag` в стандартной библиотеке Go, не наша прихоть): ```bash # основной плейбук ./goherence -i examples/inventory.yml -u deploy --check examples/playbook.yml # с конфигом, параллелизмом и без сбора фактов ./goherence -c goherence.yml -i examples/inventory.yml --forks 10 \ --gather-facts=false examples/playbook.yml # с внешним модулем hello (см. modules.d/hello/, уже собран для linux/amd64+arm64) ./goherence -i examples/inventory.yml -u deploy --modules-dir modules.d --check \ examples/playbook.yml # массовая конвертация существующих .j2 в .tmpl go run ./cmd/j2conv path/to/existing/templates/ ``` ## Кросс-дистрибутивный сценарий (Ubuntu + RHEL в одном инвентаре) Роль `examples/roles/webserver` — рабочий пример именно такого сценария: установить nginx через модуль `package` (сам выбирает `apt` или `rpm_package`/`dnf` по `os_family`, а если `gather_facts: false` — автоопределяет прямо на хосте по наличию пакетного менеджера, см. `internal/module/builtin/package.go`), сразу выключить и снять с автозагрузки, разложить конфиг из шаблона с per-host данными (`examples/host_vars/web1.yml`, `web2.yml` — разные `server_name` на каждом хосте), включить и запустить. Изменение конфига триггерит хэндлер `Restart nginx` (`examples/roles/webserver/handlers/main.yml`), который прогоняется один раз в конце play. Добавь оба тестовых хоста (Ubuntu и RHEL) в `examples/inventory.yml` под `webservers`, и один и тот же плейбук отработает на обоих без единого `when` для выбора пакетного менеджера — это и есть весь смысл модуля `package`. ## Сборка ```bash go mod tidy # подтянет sprig, x/crypto, yaml.v3 go build -o goherence ./cmd/goherence go build -o j2conv ./cmd/j2conv go test ./... ``` ## Структура См. `goherence-architecture.md` — карта проекта; этот README фиксирует, что из плана уже реализовано и какие упрощения приняты сознательно.