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