Система управления конфигурациями, сочетающая в себе плюсы Ansible и Puppet.
Find a file
2026-09-11 07:21:06 +00:00
examples Alpha 2026-09-11 10:17:25 +03:00
internal Alpha 2026-09-11 10:17:25 +03:00
modules.d/hello Alpha 2026-09-11 10:17:25 +03:00
.gitignore Alpha 2026-09-11 10:17:25 +03:00
go.mod Alpha 2026-09-11 10:17:25 +03:00
go.sum Alpha 2026-09-11 10:17:25 +03:00
goherence-architecture.md Alpha 2026-09-11 10:17:25 +03:00
goherence.yml Alpha 2026-09-11 10:17:25 +03:00
LICENSE Create: LICENSE 2026-09-11 07:21:06 +00:00
README.md Alpha 2026-09-11 10:17:25 +03:00

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, но без чужого нейминга):

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 фиксирует, что из плана уже реализовано и какие упрощения приняты сознательно.