goherence/README.md
2026-09-11 10:17:25 +03:00

181 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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