| examples | ||
| internal | ||
| modules.d/hello | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| goherence-architecture.md | ||
| goherence.yml | ||
| LICENSE | ||
| README.md | ||
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-referencingset_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):
- Порядок аргументов CLI — флаги должны идти до пути к плейбуку
(ограничение пакета
flag, не решение архитектуры). - Адрес подключения игнорировался — SSH шло по имени хоста
из инвентаря вместо
goherence_hostиз его переменных. ssh.Conn.WriteFileвозвращал сыройEOFвместо содержательной ошибки при падении удалённой команды.internal/module/external: путь для модулей требовал root (/opt/goherence/modules) — теперь резолвится через$HOMEподключившегося пользователя.when-сравнения не резолвили переменные —os_family == "Debian"был всегдаfalse, потому что сравнивалось имя переменной как строковый литерал, а не её значение. Есть юнит-тест на 13 разныхwhen-выражений (internal/executor/condition_test.go).~в пути к SSH-ключу не разворачивался —os.ReadFile("~/...")в Go не понимает~сам по себе.
Переменные подключения
Адрес и пользователь для SSH могут переопределяться per-host/per-group
переменными в инвентаре (аналог ansible_host/ansible_user, но без
чужого нейминга):
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). Все поля опциональны:
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, не наша прихоть):
# основной плейбук
./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.
Сборка
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 фиксирует,
что из плана уже реализовано и какие упрощения приняты сознательно.