This commit is contained in:
Magnus Root 2026-09-11 10:17:25 +03:00
commit 918fc531f2
54 changed files with 4475 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# binary
goherence
j2conv

181
README.md Normal file
View file

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

View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Кросс-компилирует пример внешнего модуля hello под linux/amd64 и
# linux/arm64 и кладёт результат в modules.d/hello/ рядом с manifest.json.
# Тот же паттерн (GOOS/GOARCH + go build) применим к любому другому
# внешнему модулю — имя бинарника обязано быть <name>-<goos>-<goarch>,
# это соглашение, по которому internal/module/external его находит.
set -euo pipefail
MODULE_NAME="hello"
SRC_DIR="$(dirname "$0")"
OUT_DIR="$(dirname "$0")/../../modules.d/${MODULE_NAME}"
mkdir -p "${OUT_DIR}"
for target in "linux/amd64" "linux/arm64"; do
goos="${target%/*}"
goarch="${target#*/}"
out="${OUT_DIR}/${MODULE_NAME}-${goos}-${goarch}"
echo "сборка ${out}..."
GOOS="${goos}" GOARCH="${goarch}" go build -o "${out}" "${SRC_DIR}/main.go"
done
echo "готово: ${OUT_DIR}"

View file

@ -0,0 +1,51 @@
// Пример внешнего модуля для goherence — демонстрирует контракт
// stdin/stdout JSON, тот же, что использует и сам goherence для builtin.
// Собери его через build.sh и положи результат в modules.d/hello/
// рядом с manifest.json — goherence подхватит его как модуль `hello`.
package main
import (
"encoding/json"
"fmt"
"os"
)
// Input и Result дублируют контракт из internal/module/module.go —
// внешний модуль не импортирует goherence как зависимость (он может
// быть написан вообще не на Go), поэтому просто повторяет форму JSON.
type Input struct {
SchemaVersion int `json:"schema_version"`
Args map[string]interface{} `json:"args"`
Host string `json:"host"`
Become bool `json:"become"`
CheckMode bool `json:"check_mode"`
}
type Result struct {
SchemaVersion int `json:"schema_version"`
Changed bool `json:"changed"`
Failed bool `json:"failed"`
Msg string `json:"msg"`
Diff interface{} `json:"diff,omitempty"`
}
func main() {
var in Input
if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil {
fmt.Fprintln(os.Stderr, "hello: невалидный input JSON:", err)
os.Exit(1)
}
name, _ := in.Args["name"].(string)
if name == "" {
name = "world"
}
result := Result{
SchemaVersion: 1,
Changed: false, // модуль ничего не меняет на хосте — просто демонстрационный
Msg: fmt.Sprintf("hello, %s (host=%s, check_mode=%v)", name, in.Host, in.CheckMode),
}
json.NewEncoder(os.Stdout).Encode(result)
}

View file

@ -0,0 +1 @@
clear_cache: true

View file

@ -0,0 +1 @@
server_name: web1.internal.example.com

View file

@ -0,0 +1 @@
server_name: web2.internal.example.com

17
examples/inventory.yml Normal file
View file

@ -0,0 +1,17 @@
all:
vars:
goherence_user: deploy
children:
webservers:
hosts:
web1:
goherence_host: 10.0.0.1
web2:
goherence_host: 10.0.0.2
vars:
http_port: 80
prod:
children:
webservers: {}
vars:
env: production

6
examples/playbook.yml Normal file
View file

@ -0,0 +1,6 @@
- name: Настройка веб-серверов
hosts: webservers
vars:
nginx_worker_connections: 1024
roles:
- webserver

View file

@ -0,0 +1,5 @@
nginx_worker_connections: 512
clear_cache: false
required_packages:
- curl
- htop

View file

@ -0,0 +1,4 @@
- name: Restart nginx
systemd:
name: nginx
state: restarted

View file

@ -0,0 +1,39 @@
- name: Установить nginx (сам выберет apt/dnf по дистрибутиву)
package:
name: nginx
state: present
- name: Убедиться, что nginx выключен сразу после установки
systemd:
name: nginx
state: stopped
enabled: false
- name: Уложить конфиг nginx из шаблона (per-host/group данные)
template:
src: examples/roles/webserver/templates/nginx.conf.tmpl
dest: /etc/nginx/nginx.conf
mode: "0644"
notify:
- Restart nginx
- name: Установить пакеты через loop
package:
name: "{{ .item }}"
state: present
loop: "{{ .required_packages }}"
- name: Разово почистить кэш при явном флаге (пример allow_shell)
shell: "rm -rf /var/cache/nginx/*"
allow_shell: true
when: clear_cache == true
- name: Включить и запустить nginx
systemd:
name: nginx
state: started
enabled: true
- name: Пример вызова внешнего модуля (см. modules.d/hello)
hello:
name: "{{ .goherence_user }}"

View file

@ -0,0 +1,12 @@
worker_processes auto;
events {
worker_connections {{ .nginx_worker_connections | default 1024 }};
}
http {
server {
listen {{ .http_port | default 80 }};
server_name {{ .server_name | default "_" }};
}
}

9
go.mod Normal file
View file

@ -0,0 +1,9 @@
module github.com/vladimir/goherence
go 1.22
require (
github.com/Masterminds/sprig/v3 v3.3.0
golang.org/x/crypto v0.31.0
gopkg.in/yaml.v3 v3.0.1
)

48
go.sum Normal file
View file

@ -0,0 +1,48 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

616
goherence-architecture.md Normal file
View file

@ -0,0 +1,616 @@
# Goherence — черновая архитектура
## Структура проекта
```
project/
├── cmd/
│ └── goherence/
│ └── main.go # CLI-обвязка (флаги, вывод прогресса)
├── internal/
│ ├── parser/ # YAML → структуры плейбука
│ │ ├── playbook.go # Playbook, Play, Task
│ │ ├── inventory.go # Inventory, Host, Group
│ │ └── role.go # загрузка ролей по директорийной конвенции
│ │
│ ├── executor/ # движок исполнения
│ │ ├── executor.go # оркестрация: хосты × таски
│ │ ├── vars.go # мердж переменных (defaults < vars < extra-vars < host-vars)
│ │ ├── condition.go # обработка `when`
│ │ └── loop.go # аналог `loop`/`with_items`
│ │
│ ├── template/
│ │ └── render.go # text/template + sprig.FuncMap()
│ │
│ ├── ssh/
│ │ ├── client.go # golang.org/x/crypto/ssh обвязка
│ │ ├── sftp.go # копирование файлов/модулей на хост
│ │ └── become.go # sudo/become
│ │
│ ├── module/
│ │ ├── module.go # интерфейс Module + Registry
│ │ ├── builtin/ # встроенные модули (нативный Go, без subprocess)
│ │ │ ├── shell.go
│ │ │ ├── copy.go
│ │ │ ├── template.go
│ │ │ ├── apt.go
│ │ │ └── rpm.go
│ │ └── external/
│ │ ├── loader.go # чтение manifest.json, поиск бинарников
│ │ ├── deploy.go # доставка модуля на хост + кэш по версии/хешу
│ │ └── protocol.go # (де)сериализация JSON stdin/stdout
│ │
│ └── manifest/
│ └── manifest.go # схема manifest.json внешних модулей
├── modules.d/ # внешние модули (по одному на директорию)
│ └── mymodule/
│ ├── manifest.json
│ ├── mymodule-linux-amd64
│ └── mymodule-linux-arm64
└── examples/
├── inventory.yml
├── playbook.yml
└── roles/
└── webserver/
├── tasks/main.yml
├── templates/nginx.conf.tmpl
├── defaults/main.yml
└── handlers/main.yml
```
## Ключевые интерфейсы
### Module (внутренний контракт, единый для builtin и external)
```go
package module
type Input struct {
SchemaVersion int `json:"schema_version"`
Args map[string]interface{} `json:"args"`
Host string `json:"host"`
Become bool `json:"become"`
CheckMode bool `json:"check_mode"`
}
type Result struct {
SchemaVersion int `json:"schema_version"`
Changed bool `json:"changed"`
Failed bool `json:"failed"`
Msg string `json:"msg"`
Diff interface{} `json:"diff,omitempty"`
}
// Module — общий интерфейс, которому соответствуют
// и builtin-функции, и обёртка над внешним subprocess-модулем.
type Module interface {
Name() string
Run(ctx context.Context, in Input, conn ssh.Conn) (Result, error)
}
// Registry — реестр, из которого executor достаёт модуль по имени.
type Registry struct {
builtin map[string]Module
external map[string]*ExternalModule // лениво резолвится через manifest.json
}
func (r *Registry) Resolve(name string) (Module, error)
```
### ExternalModule (subprocess по JSON-контракту)
```go
package module
type ExternalManifest struct {
Name string `json:"name"`
Version string `json:"version"`
SchemaVersion int `json:"schema_version"`
Architectures []string `json:"architectures"` // "linux/amd64", "linux/arm64"
Checksums map[string]string `json:"checksums"` // arch → sha256
}
type ExternalModule struct {
Manifest ExternalManifest
BinPath map[string]string // arch → путь к локальному бинарнику на control node
}
// Run — доставляет модуль на хост (если его там ещё нет/версия устарела),
// затем исполняет через stdin/stdout JSON.
func (m *ExternalModule) Run(ctx context.Context, in module.Input, conn ssh.Conn) (module.Result, error) {
if err := ensureDeployed(ctx, conn, m); err != nil {
return module.Result{}, err
}
remotePath := remoteModulePath(m)
return execRemoteJSON(ctx, conn, remotePath, in)
}
```
### Доставка модуля на хост с кэшированием
```go
package external
// ensureDeployed проверяет, есть ли актуальная версия модуля на хосте
// (по версии+checksum из manifest), и копирует бинарник только если нужно.
func ensureDeployed(ctx context.Context, conn ssh.Conn, m *ExternalModule) error {
arch, err := detectRemoteArch(ctx, conn) // uname -m / uname -s
if err != nil {
return err
}
remoteMarker := fmt.Sprintf("/opt/goherence/modules/%s-%s.version", m.Manifest.Name, arch)
installed, _ := readRemoteFile(ctx, conn, remoteMarker)
if string(installed) == m.Manifest.Version {
return nil // уже актуальна
}
localBin, ok := m.BinPath[arch]
if !ok {
return fmt.Errorf("no build for arch %s", arch)
}
remoteBin := remoteModulePath(m)
if err := sftpUpload(ctx, conn, localBin, remoteBin); err != nil {
return err
}
if err := remoteChmodExec(ctx, conn, remoteBin); err != nil {
return err
}
return writeRemoteFile(ctx, conn, remoteMarker, []byte(m.Manifest.Version))
}
```
## manifest.json внешнего модуля (пример)
```json
{
"name": "mymodule",
"version": "1.2.0",
"schema_version": 1,
"architectures": ["linux/amd64", "linux/arm64"],
"checksums": {
"linux/amd64": "sha256:abc123...",
"linux/arm64": "sha256:def456..."
}
}
```
## Шаблонизация (sprig)
```go
package template
import (
"text/template"
"github.com/Masterminds/sprig/v3"
)
func Render(name, src string, vars map[string]interface{}) (string, error) {
tmpl, err := template.New(name).Funcs(sprig.FuncMap()).Parse(src)
if err != nil {
return "", err
}
var buf strings.Builder
if err := tmpl.Execute(&buf, vars); err != nil {
return "", err
}
return buf.String(), nil
}
```
Кастомные фильтры без аналога в sprig (`ansible.utils.ipaddr` и подобные) добавляются
тем же `Funcs(...)` — отдельный `FuncMap` в `internal/template/custom.go`.
## Инвентарь и приоритет переменных
### Формат инвентаря (YAML, с вложенными группами)
```yaml
all:
vars:
goherence_user: deploy
children:
webservers:
hosts:
web1:
goherence_host: 10.0.0.1
web2:
goherence_host: 10.0.0.2
vars:
http_port: 80
prod:
children:
webservers: {}
vars:
env: production
```
Группы могут входить друг в друга (`prod` включает `webservers`) — это и есть
«вложенность», которую нужно резолвить при подсчёте переменных для конкретного хоста.
### Источники переменных и порядок приоритета (от низкого к высокому)
Повторяем реальный порядок Ansible, но без экзотики (facts/vars_prompt и т.п. не нужны для MVP):
```
1. role defaults/main.yml (самый низкий приоритет)
2. inventory: group_vars/all
3. inventory: group_vars/<group> (родительские группы раньше дочерних)
4. inventory: host_vars/<host>
5. play vars (в самом плейбуке: `vars:`)
6. role vars/main.yml
7. set_fact / registered vars (во время выполнения)
8. extra-vars (-e из CLI) (самый высокий, всегда побеждает)
```
Если хост состоит в нескольких группах на одном уровне вложенности — порядок
между ними определяется алфавитным сравнением имён групп (так делает и сам
Ansible), чтобы результат был детерминирован и не зависел от порядка в YAML.
### Резолв группы (topological order по дереву `children`)
```go
package inventory
// GroupChain возвращает группы хоста в порядке от самых общих (all)
// к самым специфичным — именно в этом порядке накатываются group_vars.
func (inv *Inventory) GroupChain(host string) []string {
groups := inv.groupsOf(host) // все группы, включая через children
sort.Slice(groups, func(i, j int) bool {
di, dj := inv.depth(groups[i]), inv.depth(groups[j])
if di != dj {
return di < dj // родители раньше потомков
}
return groups[i] < groups[j] // на одном уровне алфавит
})
return groups
}
func (inv *Inventory) depth(group string) int {
// "all" = 0, каждый уровень children = +1
}
```
### Мердж переменных
```go
package vars
// Resolve строит финальный набор переменных для (хост, роль, play)
// последовательно накатывая слои в порядке приоритета.
func Resolve(host string, inv *inventory.Inventory, play *parser.Play,
role *parser.Role, extraVars map[string]interface{}) map[string]interface{} {
result := map[string]interface{}{}
merge(result, role.Defaults)
merge(result, inv.GroupVars("all"))
for _, g := range inv.GroupChain(host) {
merge(result, inv.GroupVars(g))
}
merge(result, inv.HostVars(host))
merge(result, play.Vars)
merge(result, role.Vars)
merge(result, extraVars)
return result
}
// merge — плоская перезапись по ключу (dst[k] = src[k]).
// Для словарей внутри переменных (аналог Ansible hash_behaviour=merge)
// понадобится рекурсивный merge — но по умолчанию в самом Ansible
// это replace, а не deep-merge, так что для совместимости лучше
// тоже начать с replace и добавить deep-merge отдельным явным флагом.
func merge(dst, src map[string]interface{}) {
for k, v := range src {
dst[k] = v
}
}
```
### Загрузка group_vars/host_vars с диска
Ansible ищет `group_vars/<name>.yml` или `group_vars/<name>/*.yml` (директория —
для разбиения на несколько файлов) рядом с инвентарём. Стоит повторить оба варианта:
```go
func (inv *Inventory) GroupVars(group string) map[string]interface{} {
// 1. group_vars/<group>.yml — один файл
// 2. group_vars/<group>/*.yml — директория, файлы грузятся по алфавиту
// и мерджатся между собой в этом порядке
}
```
## Guardrails против императивного использования (`internal/lint/`)
В духе Go — никакой отдельной "опциональной" линтер-утилиты, никакого `--skip-lint`
по умолчанию. Проверки встроены в сам `executor` и срабатывают **на этапе парсинга**,
до подключения к хостам. Философия: то, что нельзя выразить декларативно —
не должно молча компилироваться, а должно упасть с понятной ошибкой.
### Структура пакета
```
internal/lint/
├── lint.go # Linter, Rule, Violation — общий контракт
├── rules_when.go # ограничение грамматики `when`
├── rules_shell.go # детект shell/command вместо декларативных модулей
├── rules_setfact.go # детект self-referencing set_fact (эмуляция состояния)
├── rules_include.go # запрет динамических include_tasks
├── rules_complexity.go # цикломатическая сложность роли/плея
└── config.go # пороги и allow-листы (без внешнего конфиг-файла — только Go-структура)
```
### Общий контракт
```go
package lint
type Severity int
const (
Error Severity = iota // блокирует запуск, требует --force
Warning // печатается, но не блокирует
)
type Violation struct {
Rule string
Severity Severity
Location string // "playbook.yml:role webserver:task 3"
Message string
}
// Rule — единственный интерфейс, которому подчиняются все проверки.
// Никакой плагинной системы для правил: правила — часть бинарника,
// расширяются через PR, а не через рантайм-загрузку (в отличие от
// внешних модулей, которые расширяемы намеренно).
type Rule interface {
Name() string
Check(pb *parser.Playbook) []Violation
}
// Linter — фиксированный, неконфигурируемый снаружи список правил.
type Linter struct {
rules []Rule
}
func New() *Linter {
return &Linter{rules: []Rule{
&WhenGrammarRule{},
&ShellUsageRule{},
&SetFactStateRule{},
&DynamicIncludeRule{},
&ComplexityRule{MaxNesting: 3, MaxBranches: 8},
}}
}
func (l *Linter) Run(pb *parser.Playbook) []Violation {
var out []Violation
for _, r := range l.rules {
out = append(out, r.Check(pb)...)
}
return out
}
```
### Правило 1 — грамматика `when` (allow-list, не Jinja2/произвольные выражения)
```go
package lint
// WhenGrammarRule разрешает в `when` только:
// сравнения: ==, !=, <, >, <=, >=
// логику: and, or, not
// обращение к переменным/фактам (без вызова функций с побочным эффектом)
// Всё остальное (вызовы sprig-функций вроде `now`, `randAlphaNum`,
// произвольная арифметика с накоплением) — Error.
type WhenGrammarRule struct{}
func (r *WhenGrammarRule) Name() string { return "when-grammar" }
func (r *WhenGrammarRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.When == "" {
return
}
expr, err := parseCondition(t.When) // свой мини-парсер, не text/template
if err != nil || !expr.IsPure() {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: "when-выражение должно быть чистым предикатом " +
"(сравнения/and/or/not над переменными), без функций с побочным эффектом",
})
}
})
return out
}
```
### Правило 2 — использование `shell`/`command` там, где есть декларативный модуль
```go
package lint
// известные паттерны: "apt-get install" → модуль apt, "systemctl" → модуль systemd,
// "mkdir"/"cp"/"chmod" → модули file/copy. Список расширяется по мере роста builtin-модулей.
var shellToModuleHints = map[string]string{
`apt-get install`: "apt",
`apt install`: "apt",
`yum install`: "package",
`systemctl`: "systemd",
`mkdir -p`: "file (state=directory)",
`chmod`: "file (mode=...)",
`chown`: "file (owner=/group=)",
}
type ShellUsageRule struct{}
func (r *ShellUsageRule) Name() string { return "shell-usage" }
func (r *ShellUsageRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.Module != "shell" && t.Module != "command" {
return
}
if !t.AllowShell { // явный флаг на таске — единственный способ обойти правило
for pattern, alt := range shellToModuleHints {
if strings.Contains(t.Args["cmd"].(string), pattern) {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: fmt.Sprintf(
"используй декларативный модуль %q вместо shell, "+
"либо явно пометь таск allow_shell: true", alt),
})
}
}
}
})
return out
}
```
Ключевая деталь: `allow_shell: true` — это **явная, видимая в code review пометка**,
а не глобальный флаг запуска. Она остаётся в самом плейбуке навсегда, так что
причина обхода правила всегда на виду у следующего, кто читает файл.
### Правило 3 — `set_fact`, ссылающийся сам на себя (эмуляция состояния/счётчиков)
```go
package lint
// SetFactStateRule ловит паттерн `set_fact: x="{{ x + 1 }}"`
// попытку завести изменяемое состояние поверх декларативной модели.
type SetFactStateRule struct{}
func (r *SetFactStateRule) Name() string { return "set-fact-self-reference" }
func (r *SetFactStateRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.Module != "set_fact" {
return
}
for varName, expr := range t.Args {
if referencesVar(expr.(string), varName) {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: fmt.Sprintf(
"set_fact: %s ссылается сам на себя — похоже на попытку "+
"завести изменяемое состояние между тасками, что "+
"нарушает идемпотентность плейбука", varName),
})
}
}
})
return out
}
```
### Правило 4 — запрет динамических `include_tasks`/`import_role`
```go
package lint
// DynamicIncludeRule требует, чтобы путь include был статической строкой,
// известной на этапе парсинга — без Jinja2-переменных внутри пути.
// Это убирает GOTO-с-параметрами как способ "программировать" плейбук.
type DynamicIncludeRule struct{}
func (r *DynamicIncludeRule) Name() string { return "static-include-only" }
func (r *DynamicIncludeRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.Module != "include_tasks" && t.Module != "import_role" {
return
}
path, _ := t.Args["path"].(string)
if strings.Contains(path, "{{") {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: "путь include должен быть статическим — " +
"динамический include_tasks запрещён",
})
}
})
return out
}
```
### Правило 5 — цикломатическая сложность роли/плея
```go
package lint
// ComplexityRule ограничивает глубину вложенности block/when
// и число условных ветвлений в одной роли — превышение обычно
// значит, что роль пытается быть программой, а не описанием состояния.
type ComplexityRule struct {
MaxNesting int
MaxBranches int
}
func (r *ComplexityRule) Name() string { return "complexity" }
func (r *ComplexityRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
for _, role := range pb.Roles {
nesting := maxBlockNesting(role)
branches := countConditionalTasks(role)
if nesting > r.MaxNesting {
out = append(out, Violation{
Rule: r.Name(), Severity: Warning,
Location: "role " + role.Name,
Message: fmt.Sprintf("вложенность block/when = %d (порог %d) — "+
"разбей роль на несколько ролей поменьше", nesting, r.MaxNesting),
})
}
if branches > r.MaxBranches {
out = append(out, Violation{
Rule: r.Name(), Severity: Warning,
Location: "role " + role.Name,
Message: fmt.Sprintf("условных тасков = %d (порог %d) — "+
"похоже на процедурный код внутри плейбука", branches, r.MaxBranches),
})
}
}
return out
}
```
### Интеграция в executor
```go
// перед подключением к хостам — блокирующий шаг, не опциональный
violations := lint.New().Run(playbook)
hasError := false
for _, v := range violations {
printViolation(v)
if v.Severity == lint.Error {
hasError = true
}
}
if hasError && !flags.Force {
return fmt.Errorf("плейбук нарушает guardrails декларативности; " +
"используй --force для запуска несмотря на ошибки (не рекомендуется)")
}
```
`--force` существует (иногда guardrail даёт false positive), но каждое его
применение — это осознанное, видимое в логах/CI решение, а не тихий обход.
## Порядок реализации (примерные вехи)
1. **Skeleton**: parser (playbook + inventory) → executor без модулей → SSH-клиент → `shell`-модуль. Цель: `goherence playbook.yml -i inventory.yml` реально что-то выполняет на хосте.
2. **Builtin-модули**: copy, template (sprig), apt, rpm, systemd, file, user — 8-10 самых частых.
3. **Инвентарь и переменные**: вложенные группы (`children`), group_vars/host_vars с диска, полная цепочка приоритета из раздела выше, `when`-условия, `loop` по спискам/словарям.
4. **Roles**: загрузка директорийной структуры, defaults/vars/handlers, `notify`.
5. **External module protocol**: JSON stdin/stdout контракт, Registry с резолвом builtin → external, кросс-компиляция модулей под целевые архитектуры, версионирование протокола (`schema_version`).
6. **Deploy & cache внешних модулей**: manifest.json, детект архитектуры, SFTP-доставка модулей на хосты по требованию, кэш по версии.
7. **Guardrails (`internal/lint/`)**: 5 правил выше, обязательный блокирующий проход перед подключением к хостам, `--force` как единственный явный обход.
8. **Конвертер j2 → text/template+sprig**: скрипт для массовой миграции существующих шаблонов + прогон тестов на них.
Пункты 14 — это уже рабочий MVP для внутреннего использования.
Пункты 56 нужны только когда появится первый реальный сторонний модуль.

37
goherence.yml Normal file
View file

@ -0,0 +1,37 @@
# Пример конфигурации goherence (аналог ansible.cfg по назначению, но
# свой YAML-формат). Полностью опционален: любое из этих значений можно
# не указывать (тогда действует built-in дефолт, см. internal/config.Default())
# или переопределить флагом CLI — порядок приоритета:
# built-in default → этот файл → явный флаг CLI.
# сколько хостов обрабатывать одновременно внутри одного батча
forks: 5
# размер батча rolling-обновления (0 — все хосты одним батчом,
# 1 — классическое "один хост за раз")
serial: 0
# после скольких упавших хостов прервать оставшиеся батчи
# (0 — без ограничения, прогонять все батчи независимо от числа ошибок)
max_fails: 0
# глобально поднимать привилегии (sudo -n) для всех команд на всех хостах;
# per-task/per-play override в первой версии не реализован — это
# переключатель на весь прогон
become: false
# собирать ли os_family/distribution/system перед тасками; false
# экономит одну SSH-сессию на хост, если эти факты плейбуку не нужны
gather_facts: true
# таймаут на установление SSH-соединения к одному хосту
timeout: 10s
# дефолты для SSH-подключения — используются, если не заданы флагом CLI
# и не переопределены per-host переменными goherence_host/goherence_user
ssh_user: deploy
ssh_key_path: ~/.ssh/id_ed25519
ssh_port: 22
# путь к modules.d/ с внешними модулями по умолчанию
modules_dir: modules.d

160
internal/config/config.go Normal file
View file

@ -0,0 +1,160 @@
// Package config читает файл настроек goherence (по умолчанию
// goherence.yml в текущей директории) — аналог ansible.cfg, но с
// явными Go-типами вместо INI-секций. CLI-флаги имеют приоритет над
// файлом: Load возвращает Config с дефолтами, а cmd/goherence поверх
// него применяет флаги, которые пользователь указал явно.
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config — то, что чаще всего настраивают в ansible.cfg: параллелизм,
// become, факты, таймауты, дефолтные SSH-параметры.
type Config struct {
// Forks — сколько хостов внутри одного батча обрабатывать
// одновременно (аналог ansible.cfg forks). 1 — строго последовательно.
Forks int `yaml:"forks"`
// Serial — размер батча rolling-обновления: сначала обновляется
// Serial хостов, потом (если MaxFails не превышен) следующие Serial,
// и так далее. 1 — классическое "один хост за раз". 0/не задано —
// один батч на всех сразу (Forks по-прежнему ограничивает
// параллелизм внутри этого единственного батча).
Serial int `yaml:"serial"`
// MaxFails — после скольких упавших хостов прервать оставшиеся
// батчи rolling-обновления. 0 — без ограничения (прогонять все
// батчи независимо от числа ошибок, как было по умолчанию раньше).
MaxFails int `yaml:"max_fails"`
// Become — глобально поднимать привилегии (sudo) для всех команд.
// Гранулярнее, per-task become в первой версии не реализовано —
// это включатель на весь прогон, как ansible.cfg become=true
// без per-play override.
Become bool `yaml:"become"`
// GatherFacts — собирать ли internal/facts перед тасками. false
// экономит одну SSH-сессию на хост и полезно, если os_family
// и подобные факты плейбуку не нужны.
GatherFacts bool `yaml:"gather_facts"`
// Timeout — таймаut на установление SSH-соединения к одному хосту.
Timeout time.Duration `yaml:"timeout"`
// SSHUser / SSHKeyPath / SSHPort — дефолты для подключения,
// используются, если соответствующий CLI-флаг не передан явно.
SSHUser string `yaml:"ssh_user"`
SSHKeyPath string `yaml:"ssh_key_path"`
SSHPort int `yaml:"ssh_port"`
// ModulesDir — путь к modules.d/ по умолчанию.
ModulesDir string `yaml:"modules_dir"`
}
// Default — значения, которые действуют, если ни в файле, ни в CLI
// ничего не указано. Forks=5 — тот же дефолт, что у самого Ansible.
func Default() Config {
return Config{
Forks: 5,
Serial: 0,
MaxFails: 0,
Become: false,
GatherFacts: true,
Timeout: 10 * time.Second,
SSHUser: "root",
SSHPort: 22,
}
}
// yamlConfig — то же самое, что Config, но Timeout — строка ("10s"),
// потому что time.Duration не умеет сам себя парсить из YAML-скаляра
// без кастомного UnmarshalYAML; отдельный тип проще, чем городить его
// прямо на Config.
type yamlConfig struct {
Forks *int `yaml:"forks"`
Serial *int `yaml:"serial"`
MaxFails *int `yaml:"max_fails"`
Become *bool `yaml:"become"`
GatherFacts *bool `yaml:"gather_facts"`
Timeout string `yaml:"timeout"`
SSHUser string `yaml:"ssh_user"`
SSHKeyPath string `yaml:"ssh_key_path"`
SSHPort *int `yaml:"ssh_port"`
ModulesDir string `yaml:"modules_dir"`
}
// Load читает YAML-файл конфигурации и накладывает его поверх Default().
// Отсутствие файла — не ошибка (Load(path) с несуществующим path
// возвращает Default() как есть) — файл конфигурации в goherence
// полностью опционален, все параметры дублируются флагами CLI.
func Load(path string) (Config, error) {
cfg := Default()
if path == "" {
return cfg, nil
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return cfg, nil
}
if err != nil {
return cfg, fmt.Errorf("читаю конфиг %s: %w", path, err)
}
var y yamlConfig
if err := yaml.Unmarshal(data, &y); err != nil {
return cfg, fmt.Errorf("парсинг конфига %s: %w", path, err)
}
if y.Forks != nil {
cfg.Forks = *y.Forks
}
if y.Serial != nil {
cfg.Serial = *y.Serial
}
if y.MaxFails != nil {
cfg.MaxFails = *y.MaxFails
}
if y.Become != nil {
cfg.Become = *y.Become
}
if y.GatherFacts != nil {
cfg.GatherFacts = *y.GatherFacts
}
if y.Timeout != "" {
d, err := time.ParseDuration(y.Timeout)
if err != nil {
return cfg, fmt.Errorf("конфиг %s: timeout: %w", path, err)
}
cfg.Timeout = d
}
if y.SSHUser != "" {
cfg.SSHUser = y.SSHUser
}
if y.SSHKeyPath != "" {
cfg.SSHKeyPath = y.SSHKeyPath
}
if y.SSHPort != nil {
cfg.SSHPort = *y.SSHPort
}
if y.ModulesDir != "" {
cfg.ModulesDir = y.ModulesDir
}
if cfg.Forks < 1 {
return cfg, fmt.Errorf("конфиг %s: forks должен быть >= 1", path)
}
if cfg.Serial < 0 {
return cfg, fmt.Errorf("конфиг %s: serial не может быть отрицательным", path)
}
if cfg.MaxFails < 0 {
return cfg, fmt.Errorf("конфиг %s: max_fails не может быть отрицательным", path)
}
return cfg, nil
}

View file

@ -0,0 +1,245 @@
package executor
import (
"fmt"
"strconv"
"strings"
)
// evalWhen вычисляет when-выражение над hostVars. Грамматика намеренно
// минимальна и совпадает с тем, что разрешает lint.WhenGrammarRule:
// сравнения (== != < > <= >=), and/or/not, переменные и литералы.
// Никаких вызовов функций — если выражение прошло линтер, сюда оно
// попадает уже гарантированно "чистым".
//
// Переменные резолвятся через hostVars сразу в момент разбора атома
// (parseAtom), а не отложенно — hostVars известен целиком заранее, так
// что откладывать резолюцию нет причины, а более ранняя версия из-за
// этого имела баг: compare() сравнивал имя переменной как литерал,
// а не её значение.
func evalWhen(expr string, hostVars map[string]interface{}) bool {
p := &condParser{tokens: tokenize(expr), vars: hostVars}
v, err := p.parseOr()
if err != nil {
// невалидное when — считаем условие ложным и не роняем весь плейбук:
// синтаксическая проверка уже была на этапе lint, здесь это
// подстраховка на случай --force поверх ошибки линтера.
return false
}
return toBool(v)
}
type condParser struct {
tokens []string
pos int
vars map[string]interface{}
}
func (p *condParser) peek() string {
if p.pos >= len(p.tokens) {
return ""
}
return p.tokens[p.pos]
}
func (p *condParser) next() string {
t := p.peek()
p.pos++
return t
}
func (p *condParser) parseOr() (interface{}, error) {
left, err := p.parseAnd()
if err != nil {
return nil, err
}
for p.peek() == "or" {
p.next()
right, err := p.parseAnd()
if err != nil {
return nil, err
}
left = toBool(left) || toBool(right)
}
return left, nil
}
func (p *condParser) parseAnd() (interface{}, error) {
left, err := p.parseNot()
if err != nil {
return nil, err
}
for p.peek() == "and" {
p.next()
right, err := p.parseNot()
if err != nil {
return nil, err
}
left = toBool(left) && toBool(right)
}
return left, nil
}
func (p *condParser) parseNot() (interface{}, error) {
if p.peek() == "not" {
p.next()
v, err := p.parseNot()
if err != nil {
return nil, err
}
return !toBool(v), nil
}
return p.parseComparison()
}
func (p *condParser) parseComparison() (interface{}, error) {
left, err := p.parseAtom()
if err != nil {
return nil, err
}
op := p.peek()
switch op {
case "==", "!=", "<", ">", "<=", ">=":
p.next()
right, err := p.parseAtom()
if err != nil {
return nil, err
}
return compare(op, left, right), nil
}
return left, nil
}
// parseAtom резолвит переменные сразу через p.vars — идентификатор,
// не являющийся числом/строкой/true/false, ищется в hostVars и
// возвращается уже как реальное значение (или nil, если переменной нет).
func (p *condParser) parseAtom() (interface{}, error) {
tok := p.next()
switch {
case tok == "(":
v, err := p.parseOr()
if err != nil {
return nil, err
}
if p.next() != ")" {
return nil, fmt.Errorf("ожидалась закрывающая скобка")
}
return v, nil
case tok == "":
return nil, fmt.Errorf("неожиданный конец выражения")
case strings.HasPrefix(tok, `"`) || strings.HasPrefix(tok, "'"):
return strings.Trim(tok, `"'`), nil
case isNumber(tok):
f, _ := strconv.ParseFloat(tok, 64)
return f, nil
case tok == "true":
return true, nil
case tok == "false":
return false, nil
default:
return p.vars[tok], nil // отсутствующая переменная — nil, не ошибка
}
}
func compare(op string, left, right interface{}) bool {
// числовое сравнение, если оба значения — float64
lf, lok := left.(float64)
rf, rok := right.(float64)
if lok && rok {
switch op {
case "==":
return lf == rf
case "!=":
return lf != rf
case "<":
return lf < rf
case ">":
return lf > rf
case "<=":
return lf <= rf
case ">=":
return lf >= rf
}
}
// булево сравнение, если оба значения — bool (частый случай:
// `when: clear_cache == true`, где true уже распознан как bool атом)
lb, lbok := left.(bool)
rb, rbok := right.(bool)
if lbok && rbok {
switch op {
case "==":
return lb == rb
case "!=":
return lb != rb
}
}
ls := fmt.Sprintf("%v", left)
rs := fmt.Sprintf("%v", right)
switch op {
case "==":
return ls == rs
case "!=":
return ls != rs
default:
return ls < rs // строковое < / > — редкий случай, оставлен для полноты
}
}
// toBool приводит уже резолвленное значение (не varRef — резолюция
// происходит раньше, в parseAtom) к bool.
func toBool(v interface{}) bool {
switch vv := v.(type) {
case bool:
return vv
case string:
return vv != ""
case float64:
return vv != 0
default:
return v != nil
}
}
func isNumber(s string) bool {
_, err := strconv.ParseFloat(s, 64)
return err == nil
}
// tokenize — примитивный токенизатор: числа/строки/идентификаторы через
// пробелы, операторы сравнения и скобки — отдельными токенами.
func tokenize(expr string) []string {
var tokens []string
i := 0
for i < len(expr) {
c := expr[i]
switch {
case c == ' ' || c == '\t':
i++
case c == '(' || c == ')':
tokens = append(tokens, string(c))
i++
case strings.HasPrefix(expr[i:], "=="), strings.HasPrefix(expr[i:], "!="),
strings.HasPrefix(expr[i:], "<="), strings.HasPrefix(expr[i:], ">="):
tokens = append(tokens, expr[i:i+2])
i += 2
case c == '<' || c == '>':
tokens = append(tokens, string(c))
i++
case c == '"' || c == '\'':
j := i + 1
for j < len(expr) && expr[j] != c {
j++
}
tokens = append(tokens, expr[i:j+1])
i = j + 1
default:
j := i
for j < len(expr) && expr[j] != ' ' && expr[j] != '(' && expr[j] != ')' {
j++
}
tokens = append(tokens, expr[i:j])
i = j
}
}
return tokens
}

View file

@ -0,0 +1,35 @@
package executor
import "testing"
func TestEvalWhen(t *testing.T) {
vars := map[string]interface{}{
"os_family": "Debian",
"clear_cache": true,
"http_port": float64(80),
}
cases := []struct {
expr string
want bool
}{
{`os_family == "Debian"`, true},
{`os_family == "RedHat"`, false},
{`os_family != "RedHat"`, true},
{`clear_cache == true`, true},
{`clear_cache == false`, false},
{`http_port == 80`, true},
{`http_port > 79`, true},
{`http_port < 79`, false},
{`os_family == "Debian" and clear_cache == true`, true},
{`os_family == "RedHat" or clear_cache == true`, true},
{`not clear_cache == false`, true},
{`(os_family == "Debian") and (http_port == 80)`, true},
{`unknown_var == "x"`, false},
}
for _, c := range cases {
got := evalWhen(c.expr, vars)
if got != c.want {
t.Errorf("evalWhen(%q) = %v, хотел %v", c.expr, got, c.want)
}
}
}

View file

@ -0,0 +1,472 @@
// Package executor связывает все остальные пакеты воедино: парсер даёт
// структуру плейбука, lint проверяет её на guardrails, vars считает
// переменные для каждого хоста, module.Registry резолвит модуль по имени,
// ssh.Conn исполняет его на хосте. Сам executor не содержит модульной
// логики — только оркестрацию.
package executor
import (
"context"
"fmt"
"strings"
"sync"
"github.com/vladimir/goherence/internal/facts"
"github.com/vladimir/goherence/internal/lint"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/module/builtin"
"github.com/vladimir/goherence/internal/module/external"
"github.com/vladimir/goherence/internal/parser"
"github.com/vladimir/goherence/internal/ssh"
tmpl "github.com/vladimir/goherence/internal/template"
"github.com/vladimir/goherence/internal/vars"
)
// Options — то, что задаётся снаружи (флагами CLI/конфигом) при запуске плейбука.
type Options struct {
Limit string // ограничить выполнение одним хостом/группой
ExtraVars map[string]interface{}
CheckMode bool
Force bool // пропустить блокирующие ошибки линтера
ModulesDir string // путь к modules.d/ с внешними модулями; пусто — внешних модулей нет
Forks int // сколько хостов внутри одного батча обрабатывать одновременно; 0/1 — последовательно
Serial int // размер батча rolling-обновления; 0 — один батч на всех хостов сразу
MaxFails int // после скольких упавших хостов прервать оставшиеся батчи; 0 — без ограничения
Become bool // глобально поднимать привилегии (sudo) для всех команд на всех хостах
GatherFacts bool // собирать ли internal/facts перед тасками
OnTaskStart func(host string, t *parser.Task)
OnTaskEnd func(host string, t *parser.Task, res module.Result)
}
// SSHConfigFor — функция, которую вызывающий код должен предоставить,
// чтобы получить параметры подключения для конкретного хоста (ключ,
// пользователь и т.д.). Executor намеренно не знает, откуда эти данные
// берутся (инвентарь / vault / переменные окружения) — это ответственность
// вызывающего кода (см. cmd/goherence).
type SSHConfigFor func(host string) ssh.Config
// Run выполняет плейбук pb против инвентаря inv. Хосты разбиваются на
// батчи по opts.Serial (rolling-обновление — "один хост за раз" это
// Serial: 1); внутри батча до opts.Forks хостов обрабатываются
// параллельно. После каждого батча проверяется opts.MaxFails: если
// упавших хостов уже достаточно, оставшиеся батчи не запускаются —
// то, что уже успело обновиться, не откатывается (это не транзакция).
func Run(ctx context.Context, pb *parser.Playbook, inv *parser.Inventory, sshCfg SSHConfigFor, opts Options) error {
violations := lint.New().Run(pb)
for _, v := range violations {
fmt.Printf("[%s] %s: %s: %s\n", v.Severity, v.Rule, v.Location, v.Message)
}
if lint.HasError(violations) && !opts.Force {
return fmt.Errorf("плейбук нарушает guardrails декларативности; " +
"используй --force для запуска несмотря на ошибки (не рекомендуется)")
}
// printMu сериализует вывод OnTaskStart/OnTaskEnd между горутинами —
// без этого строки от разных хостов перемежались бы посимвольно.
printMu := &sync.Mutex{}
origStart, origEnd := opts.OnTaskStart, opts.OnTaskEnd
if origStart != nil {
opts.OnTaskStart = func(host string, t *parser.Task) {
printMu.Lock()
defer printMu.Unlock()
origStart(host, t)
}
}
if origEnd != nil {
opts.OnTaskEnd = func(host string, t *parser.Task, res module.Result) {
printMu.Lock()
defer printMu.Unlock()
origEnd(host, t, res)
}
}
for _, play := range pb.Plays {
hosts := resolveHosts(inv, play.Hosts, opts.Limit)
if err := runPlaySerially(ctx, pb, inv, &play, hosts, sshCfg, opts); err != nil {
return err
}
}
return nil
}
// runPlaySerially делит hosts на батчи по opts.Serial и прогоняет их
// последовательно один за другим, проверяя opts.MaxFails между батчами.
func runPlaySerially(
ctx context.Context,
pb *parser.Playbook,
inv *parser.Inventory,
play *parser.Play,
hosts []string,
sshCfg SSHConfigFor,
opts Options,
) error {
batchSize := opts.Serial
if batchSize < 1 {
batchSize = len(hosts) // без serial — один батч на всех, как раньше
}
var failed []error
for start := 0; start < len(hosts); start += batchSize {
end := start + batchSize
if end > len(hosts) {
end = len(hosts)
}
batch := hosts[start:end]
failed = append(failed, runBatch(ctx, pb, inv, play, batch, sshCfg, opts)...)
if opts.MaxFails > 0 && len(failed) >= opts.MaxFails {
return fmt.Errorf(
"остановлено после %d ошибок (лимит max_fails=%d), оставшиеся хосты не тронуты: %s",
len(failed), opts.MaxFails, joinErrors(failed))
}
}
if len(failed) > 0 {
return fmt.Errorf("ошибки на %d из %d хостов: %s", len(failed), len(hosts), joinErrors(failed))
}
return nil
}
// runBatch запускает play на hosts, максимум opts.Forks одновременно,
// и возвращает ВСЕ ошибки батча (а не первую попавшуюся) — иначе после
// каждого батча нечего было бы сравнивать с MaxFails: одна необработанная
// ошибка ничем не отличалась бы от трёх.
func runBatch(
ctx context.Context,
pb *parser.Playbook,
inv *parser.Inventory,
play *parser.Play,
hosts []string,
sshCfg SSHConfigFor,
opts Options,
) []error {
forks := opts.Forks
if forks < 1 {
forks = 1
}
if forks == 1 {
var errs []error
for _, host := range hosts {
if err := runPlayOnHost(ctx, pb, inv, play, host, sshCfg, opts); err != nil {
errs = append(errs, fmt.Errorf("хост %s: %w", host, err))
}
}
return errs
}
sem := make(chan struct{}, forks)
errCh := make(chan error, len(hosts))
var wg sync.WaitGroup
for _, host := range hosts {
host := host
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
if err := runPlayOnHost(ctx, pb, inv, play, host, sshCfg, opts); err != nil {
errCh <- fmt.Errorf("хост %s: %w", host, err)
}
}()
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errs
}
func joinErrors(errs []error) string {
parts := make([]string, len(errs))
for i, e := range errs {
parts[i] = e.Error()
}
return strings.Join(parts, "; ")
}
// resolveHosts разворачивает play.Hosts (имя группы или "all") в список
// конкретных хостов, применяя --limit, если он задан.
func resolveHosts(inv *parser.Inventory, hostsSpec, limit string) []string {
group, ok := inv.Groups[hostsSpec]
if !ok {
return nil
}
var out []string
for name := range group.Hosts {
if limit == "" || limit == name || limit == hostsSpec {
out = append(out, name)
}
}
return out
}
func runPlayOnHost(
ctx context.Context,
pb *parser.Playbook,
inv *parser.Inventory,
play *parser.Play,
host string,
sshCfg SSHConfigFor,
opts Options,
) error {
conn, err := ssh.Dial(sshCfg(host))
if err != nil {
return fmt.Errorf("подключение по ssh: %w", err)
}
defer conn.Close()
conn.Become = opts.Become // глобальный переключатель на весь прогон, см. internal/config
// факты собираются один раз на хост в начале play — ровно как
// implicit gather_facts в Ansible перед первым таском. Отключается
// через opts.GatherFacts=false (config: gather_facts: false), если
// плейбуку факты не нужны — это экономит одну SSH-сессию на хост.
hostFacts := map[string]interface{}{}
if opts.GatherFacts {
hostFacts, err = facts.Gather(conn)
if err != nil {
return fmt.Errorf("сбор фактов: %w", err)
}
}
// notified — хэндлеры, на которые сработал notify хотя бы одного
// изменившего состояние таска, в порядке первого срабатывания.
// Собирается по всему play (play-level tasks + все роли) и
// выполняется один раз в конце — ровно так же, как в Ansible.
var notified []string
seen := map[string]bool{}
notify := func(names []string) {
for _, n := range names {
if !seen[n] {
seen[n] = true
notified = append(notified, n)
}
}
}
// сначала таски самого play, затем таски каждой подключённой роли —
// в первой версии roles всегда выполняются после play-level tasks
n, err := runTasks(ctx, play.Tasks, nil, inv, play, host, conn, hostFacts, opts)
if err != nil {
return err
}
notify(n)
handlers := map[string]parser.Task{}
for _, roleName := range play.Roles {
role := pb.Roles[roleName]
if role == nil {
return fmt.Errorf("роль %q не загружена", roleName)
}
for _, h := range role.Handlers {
handlers[h.Name] = h
}
n, err := runTasks(ctx, role.Tasks, role, inv, play, host, conn, hostFacts, opts)
if err != nil {
return fmt.Errorf("роль %s: %w", roleName, err)
}
notify(n)
}
return runHandlers(ctx, notified, handlers, inv, play, host, conn, hostFacts, opts)
}
// runHandlers выполняет по одному разу каждый хэндлер из notified —
// аналог того, как Ansible прогоняет notify-хэндлеры в конце play.
// Хэндлер резолвится с переменными play/host/facts, но без role.Defaults/Vars
// конкретной роли (в первой версии хэндлеры общие на весь play, без
// привязки к переменным той роли, где они были объявлены) — сознательное
// упрощение, отмеченное в README.
func runHandlers(
ctx context.Context,
notified []string,
handlers map[string]parser.Task,
inv *parser.Inventory,
play *parser.Play,
host string,
conn *ssh.Conn,
hostFacts map[string]interface{},
opts Options,
) error {
if len(notified) == 0 {
return nil
}
var toRun []parser.Task
for _, name := range notified {
h, ok := handlers[name]
if !ok {
return fmt.Errorf("notify указывает на неизвестный хэндлер %q", name)
}
toRun = append(toRun, h)
}
_, err := runTasks(ctx, toRun, nil, inv, play, host, conn, hostFacts, opts)
return err
}
// runTasks выполняет список тасков на одном хосте и возвращает имена
// хэндлеров, на которые сработал notify (для тасков, реально изменивших
// состояние — Result.Changed). Вызывающий код (runPlayOnHost) собирает
// notify со всех групп тасков play и роли и прогоняет хэндлеры один раз в конце.
func runTasks(
ctx context.Context,
tasks []parser.Task,
role *parser.Role,
inv *parser.Inventory,
play *parser.Play,
host string,
conn *ssh.Conn,
hostFacts map[string]interface{},
opts Options,
) ([]string, error) {
hostVars := vars.Resolve(host, inv, play, role, hostFacts, opts.ExtraVars)
// граф зависимостей (after/before) — см. graph.go; без объявленных
// зависимостей порядок не меняется вообще.
orderedTasks, err := orderTasks(tasks)
if err != nil {
return nil, err
}
tasks = orderedTasks
reg := module.NewRegistry()
builtin.RegisterAll(reg, hostVars) // TemplateModule/PackageModule получают vars именно здесь,
// на пересчёт для каждого набора тасков — см. ограничение в register.go
if opts.ModulesDir != "" {
extModules, err := external.LoadDir(opts.ModulesDir)
if err != nil {
return nil, fmt.Errorf("загрузка внешних модулей из %s: %w", opts.ModulesDir, err)
}
for _, m := range extModules {
reg.RegisterExternal(m)
}
}
var notified []string
for i := range tasks {
t := &tasks[i]
if t.When != "" && !evalWhen(t.When, hostVars) {
continue
}
mod, err := reg.Resolve(t.Module)
if err != nil {
return nil, err
}
items, err := loopItems(t.Loop, hostVars)
if err != nil {
return nil, fmt.Errorf("таск %q: loop: %w", t.Name, err)
}
for _, item := range items {
args, err := renderTaskArgs(t.Args, hostVars, item)
if err != nil {
return nil, fmt.Errorf("таск %q: рендер аргументов: %w", t.Name, err)
}
if opts.OnTaskStart != nil {
opts.OnTaskStart(host, t)
}
res, err := mod.Run(ctx, module.Input{
SchemaVersion: module.SchemaVersion,
Args: args,
Host: host,
CheckMode: opts.CheckMode,
}, conn)
if err != nil {
return nil, fmt.Errorf("таск %q: %w", t.Name, err)
}
if opts.OnTaskEnd != nil {
opts.OnTaskEnd(host, t, res)
}
if res.Failed {
return nil, fmt.Errorf("таск %q завершился с ошибкой: %s", t.Name, res.Msg)
}
if res.Changed && len(t.Notify) > 0 {
notified = append(notified, t.Notify...)
}
}
}
return notified, nil
}
// loopItems разворачивает t.Loop в список элементов, по одному на итерацию.
// nil-loop — это ровно один "элемент" (пустой), то есть таск выполняется
// один раз как обычно; это позволяет не разветвлять код на "с loop"/"без loop".
func loopItems(loopSpec interface{}, hostVars map[string]interface{}) ([]interface{}, error) {
if loopSpec == nil {
return []interface{}{nil}, nil
}
switch v := loopSpec.(type) {
case []interface{}:
return v, nil
case string:
// "{{ .some_list }}" — ссылка на переменную-список в hostVars
varName := extractVarName(v)
if varName == "" {
return nil, fmt.Errorf("loop-выражение %q не распознано (первая версия понимает только {{ .var }})", v)
}
val, ok := hostVars[varName]
if !ok {
return nil, fmt.Errorf("переменная %q для loop не найдена", varName)
}
list, ok := val.([]interface{})
if !ok {
return nil, fmt.Errorf("переменная %q не является списком", varName)
}
return list, nil
default:
return nil, fmt.Errorf("неподдерживаемый тип loop: %T", loopSpec)
}
}
// extractVarName вытаскивает имя переменной из "{{ .name }}" — только
// этот один паттерн, без произвольных выражений (loop — не место для логики).
func extractVarName(expr string) string {
s := strings.TrimSpace(expr)
s = strings.TrimPrefix(s, "{{")
s = strings.TrimSuffix(s, "}}")
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, ".")
return strings.TrimSpace(s)
}
// renderTaskArgs рендерит каждое строковое значение в args через
// internal/template (text/template+sprig), добавляя текущий item из
// loop под именем `.item` — ровно так же, как Ansible подставляет `item`
// внутри тасков с loop. Нестроковые значения (числа, bool, вложенные map)
// передаются как есть.
func renderTaskArgs(args map[string]interface{}, hostVars map[string]interface{}, item interface{}) (map[string]interface{}, error) {
scopedVars := make(map[string]interface{}, len(hostVars)+1)
for k, v := range hostVars {
scopedVars[k] = v
}
if item != nil {
scopedVars["item"] = item
}
out := make(map[string]interface{}, len(args))
for k, v := range args {
s, ok := v.(string)
if !ok || !strings.Contains(s, "{{") {
out[k] = v
continue
}
rendered, err := tmpl.Render("arg:"+k, s, scopedVars)
if err != nil {
return nil, err
}
out[k] = rendered
}
return out, nil
}

112
internal/executor/graph.go Normal file
View file

@ -0,0 +1,112 @@
package executor
import (
"fmt"
"strings"
"github.com/vladimir/goherence/internal/parser"
)
// orderTasks сортирует tasks по явным зависимостям (after/before), как
// require/before в Puppet, вместо жёсткого порядка "как написано в файле".
// Это СТАБИЛЬНАЯ топологическая сортировка: если ни один таск не объявляет
// after/before, результат побайтово совпадает с исходным порядком — граф
// зависимостей влияет только на то, что реально зависимостями объявлено,
// и не может незаметно переставить местами таски без причины.
//
// Алгоритм Кана с выбором наименьшего доступного индекса на каждом шаге
// (а не FIFO/DFS) — это и даёт стабильность: без явных рёбер все узлы
// доступны сразу, и они просто разбираются по порядку.
//
// Важная деталь тай-брейка: если из двух тасков без прямой связи друг
// с другом один вынужден сдвинуться из-за чужой зависимости, второй
// остаётся на исходном относительном месте — а не "уступает дорогу"
// первому. Иными словами, при конфликте сохраняется как можно больше
// исходных пар "было раньше" — но если это математически невозможно
// для ВСЕХ пар сразу (что бывает при before/after), какая именно пара
// "жертвуется" — вопрос тай-брейка, а не более раннего решения "правильно/неправильно".
func orderTasks(tasks []parser.Task) ([]parser.Task, error) {
n := len(tasks)
index := map[string]int{}
for i, t := range tasks {
if t.Name != "" {
if _, dup := index[t.Name]; dup {
return nil, fmt.Errorf("два таска с одинаковым именем %q — "+
"after/before не может однозначно на них сослаться", t.Name)
}
index[t.Name] = i
}
}
adj := make([][]int, n) // adj[i] — какие таски должны идти после i
indeg := make([]int, n)
addEdge := func(from, to int) {
if from == to {
return
}
adj[from] = append(adj[from], to)
indeg[to]++
}
for i, t := range tasks {
for _, depName := range t.After {
j, ok := index[depName]
if !ok {
return nil, fmt.Errorf("таск %q: after ссылается на неизвестный таск %q", t.Name, depName)
}
addEdge(j, i) // depName должен выполниться до t
}
for _, targetName := range t.Before {
j, ok := index[targetName]
if !ok {
return nil, fmt.Errorf("таск %q: before ссылается на неизвестный таск %q", t.Name, targetName)
}
addEdge(i, j) // t должен выполниться до targetName
}
}
visited := make([]bool, n)
var order []int
for len(order) < n {
picked := -1
for i := 0; i < n; i++ {
if !visited[i] && indeg[i] == 0 {
picked = i
break // наименьший индекс среди доступных — гарантирует стабильность
}
}
if picked == -1 {
return nil, fmt.Errorf("цикл в зависимостях тасков (after/before): %s", cycleHint(tasks, visited))
}
visited[picked] = true
order = append(order, picked)
for _, next := range adj[picked] {
indeg[next]--
}
}
out := make([]parser.Task, n)
for pos, idx := range order {
out[pos] = tasks[idx]
}
return out, nil
}
// cycleHint перечисляет имена тасков, которые не удалось разместить —
// то есть участников цикла (или зависящих от него), чтобы сообщение об
// ошибке указывало, где искать проблему, а не просто "где-то цикл".
func cycleHint(tasks []parser.Task, visited []bool) string {
var names []string
for i, v := range visited {
if !v {
name := tasks[i].Name
if name == "" {
name = fmt.Sprintf("#%d", i)
}
names = append(names, name)
}
}
return strings.Join(names, ", ")
}

View file

@ -0,0 +1,108 @@
package executor
import (
"testing"
"github.com/vladimir/goherence/internal/parser"
)
func names(tasks []parser.Task) []string {
out := make([]string, len(tasks))
for i, t := range tasks {
out[i] = t.Name
}
return out
}
func TestOrderTasksNoDepsPreservesOrder(t *testing.T) {
tasks := []parser.Task{{Name: "a"}, {Name: "b"}, {Name: "c"}}
got, err := orderTasks(tasks)
if err != nil {
t.Fatal(err)
}
want := []string{"a", "b", "c"}
if got := names(got); !equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestOrderTasksAfterReorders(t *testing.T) {
// "b" объявлен раньше "a" в файле, но зависит от него через after —
// должен переместиться после "a".
tasks := []parser.Task{
{Name: "b", After: []string{"a"}},
{Name: "a"},
{Name: "c"},
}
got, err := orderTasks(tasks)
if err != nil {
t.Fatal(err)
}
want := []string{"a", "b", "c"}
if got := names(got); !equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestOrderTasksBefore(t *testing.T) {
tasks := []parser.Task{
{Name: "a"},
{Name: "b"},
{Name: "c", Before: []string{"a"}},
}
got, err := orderTasks(tasks)
if err != nil {
t.Fatal(err)
}
// Единственное жёсткое требование — c должен идти раньше a.
// b ничем не связан ни с a, ни с c, так что его позиция относительно
// них — вопрос тай-брейка алгоритма (см. комментарий в graph.go),
// а не то, что тест должен закреплять как "единственно верное".
order := names(got)
posA, posC := indexOf(order, "a"), indexOf(order, "c")
if posC >= posA {
t.Errorf("c должен идти раньше a, получили порядок %v", order)
}
}
func indexOf(s []string, v string) int {
for i, x := range s {
if x == v {
return i
}
}
return -1
}
func TestOrderTasksCycleDetected(t *testing.T) {
tasks := []parser.Task{
{Name: "a", After: []string{"b"}},
{Name: "b", After: []string{"a"}},
}
_, err := orderTasks(tasks)
if err == nil {
t.Fatal("ожидалась ошибка цикла, получили nil")
}
}
func TestOrderTasksUnknownDependency(t *testing.T) {
tasks := []parser.Task{
{Name: "a", After: []string{"nonexistent"}},
}
_, err := orderTasks(tasks)
if err == nil {
t.Fatal("ожидалась ошибка неизвестной зависимости, получили nil")
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

75
internal/facts/facts.go Normal file
View file

@ -0,0 +1,75 @@
// Package facts собирает минимальный набор фактов о хосте — ровно
// столько, сколько нужно, чтобы плейбук мог сам выбрать нужную ветку
// для разных дистрибутивов (apt vs rpm_package), без ручного указания
// ОС в инвентаре. Это не полноценный gather_facts — осознанно маленький
// набор, расширяемый по мере реальной необходимости.
package facts
import (
"strings"
"github.com/vladimir/goherence/internal/ssh"
)
// Gather подключается к уже открытой SSH-сессии и вычисляет:
// - os_family: "Debian" | "RedHat" | "Unknown"
// - distribution: значение ID= из /etc/os-release (ubuntu, debian, rhel, almalinux...)
// - system: вывод `uname -s` (обычно "Linux")
func Gather(conn *ssh.Conn) (map[string]interface{}, error) {
res, err := conn.Run("cat /etc/os-release 2>/dev/null; echo '---'; uname -s")
if err != nil {
return nil, err
}
parts := strings.SplitN(res.Stdout, "---", 2)
osRelease := parts[0]
system := "Unknown"
if len(parts) > 1 {
system = strings.TrimSpace(parts[1])
}
distro := parseOSReleaseID(osRelease)
return map[string]interface{}{
"os_family": familyFor(distro),
"distribution": distro,
"system": system,
}, nil
}
// parseOSReleaseID вытаскивает значение ID= из /etc/os-release
// (например ID=ubuntu, ID="almalinux", ID=rhel).
func parseOSReleaseID(osRelease string) string {
for _, line := range strings.Split(osRelease, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "ID=") {
v := strings.TrimPrefix(line, "ID=")
return strings.Trim(v, `"'`)
}
}
return "unknown"
}
// debianFamily / redHatFamily — известные ID из /etc/os-release,
// относящиеся к каждому семейству. Список расширяется по мере
// встречи новых дистрибутивов, а не пытается быть исчерпывающим сразу.
var (
debianFamily = map[string]bool{
"debian": true, "ubuntu": true, "raspbian": true, "linuxmint": true,
}
redHatFamily = map[string]bool{
"rhel": true, "centos": true, "fedora": true, "almalinux": true,
"rocky": true, "amzn": true,
}
)
func familyFor(distro string) string {
switch {
case debianFamily[distro]:
return "Debian"
case redHatFamily[distro]:
return "RedHat"
default:
return "Unknown"
}
}

113
internal/lint/lint.go Normal file
View file

@ -0,0 +1,113 @@
// Package lint реализует guardrails против превращения декларативных
// плейбуков в императивный код. Никакой плагинной системы для правил —
// они часть бинарника и расширяются через PR, а не через рантайм-загрузку
// (в отличие от internal/module/external, который расширяем намеренно).
package lint
import (
"strconv"
"github.com/vladimir/goherence/internal/parser"
)
type Severity int
const (
Error Severity = iota // блокирует запуск, требует --force
Warning // печатается, но не блокирует
)
func (s Severity) String() string {
if s == Error {
return "ERROR"
}
return "WARNING"
}
type Violation struct {
Rule string
Severity Severity
Location string
Message string
}
// Rule — контракт, которому подчиняются все проверки.
type Rule interface {
Name() string
Check(pb *parser.Playbook) []Violation
}
// Linter — фиксированный, не настраиваемый снаружи список правил.
type Linter struct {
rules []Rule
}
// New возвращает линтер с полным набором правил первой версии.
// Пороги (MaxNesting/MaxBranches) — тут единственное, что параметризовано,
// и то как явные поля структуры, а не внешний конфиг-файл.
func New() *Linter {
return &Linter{rules: []Rule{
&WhenGrammarRule{},
&ShellUsageRule{},
&SetFactStateRule{},
&DynamicIncludeRule{},
&ComplexityRule{MaxNesting: 3, MaxBranches: 8},
}}
}
// Run прогоняет все правила по плейбуку и возвращает все нарушения сразу —
// принципиально не останавливается на первом, чтобы за один прогон
// показать разработчику всё, что нужно поправить.
func (l *Linter) Run(pb *parser.Playbook) []Violation {
var out []Violation
for _, r := range l.rules {
out = append(out, r.Check(pb)...)
}
return out
}
// HasError — есть ли среди нарушений хотя бы одно уровня Error.
func HasError(violations []Violation) bool {
for _, v := range violations {
if v.Severity == Error {
return true
}
}
return false
}
// walkTasks — общий обход всех тасков плейбука (в play и во всех
// подключённых ролях), с человекочитаемой Location для каждого.
// Вынесен сюда как единственная точка обхода, чтобы правила не
// дублировали логику "как пройтись по всем таскам плейбука".
func walkTasks(pb *parser.Playbook, fn func(location string, t *parser.Task)) {
for _, play := range pb.Plays {
for i := range play.Tasks {
t := &play.Tasks[i]
fn(locationFor(play.Name, "", i, t), t)
}
for _, roleName := range play.Roles {
role, ok := pb.Roles[roleName]
if !ok {
continue
}
for i := range role.Tasks {
t := &role.Tasks[i]
fn(locationFor(play.Name, roleName, i, t), t)
}
}
}
}
func locationFor(playName, roleName string, idx int, t *parser.Task) string {
loc := "play " + playName
if roleName != "" {
loc += ": role " + roleName
}
if t.Name != "" {
loc += ": task " + t.Name
} else {
loc += ": task #" + strconv.Itoa(idx)
}
return loc
}

View file

@ -0,0 +1,42 @@
package lint
import (
"fmt"
"github.com/vladimir/goherence/internal/parser"
)
// ComplexityRule ограничивает число условных тасков (`when`) в одной роли.
// Превышение порога — обычно значит, что роль пытается быть программой
// с ветвлениями, а не описанием желаемого состояния хостов. Это Warning,
// а не Error: сложность — вопрос вкуса и контекста, а не жёсткого правила,
// как shell-usage или dynamic-include.
type ComplexityRule struct {
MaxNesting int // зарезервировано на будущее — реальная вложенность block/block
MaxBranches int // сколько тасков с `when` допустимо в одной роли
}
func (r *ComplexityRule) Name() string { return "complexity" }
func (r *ComplexityRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
for roleName, role := range pb.Roles {
branches := 0
for _, t := range role.Tasks {
if t.When != "" {
branches++
}
}
if branches > r.MaxBranches {
out = append(out, Violation{
Rule: r.Name(), Severity: Warning,
Location: "role " + roleName,
Message: fmt.Sprintf(
"условных тасков (when) = %d, порог %d — похоже на процедурный код "+
"внутри роли, стоит разбить на несколько ролей поменьше",
branches, r.MaxBranches),
})
}
}
return out
}

View file

@ -0,0 +1,37 @@
package lint
import (
"strings"
"github.com/vladimir/goherence/internal/parser"
)
// DynamicIncludeRule требует, чтобы путь include_tasks/import_role был
// статической строкой, известной уже на этапе парсинга плейбука — без
// подстановки переменных внутрь пути. Это убирает GOTO-с-параметрами
// как способ "программировать" маршрутизацию тасков вместо декларативного
// описания состояния.
type DynamicIncludeRule struct{}
func (r *DynamicIncludeRule) Name() string { return "static-include-only" }
func (r *DynamicIncludeRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.Module != "include_tasks" && t.Module != "import_role" {
return
}
path, _ := t.Args["path"].(string)
if path == "" {
path, _ = t.Args["name"].(string) // import_role обычно использует "name"
}
if strings.Contains(path, "{{") {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: "путь include должен быть статическим — динамический " +
t.Module + " запрещён: " + path,
})
}
})
return out
}

View file

@ -0,0 +1,44 @@
package lint
import (
"strings"
"github.com/vladimir/goherence/internal/parser"
)
// SetFactStateRule ловит паттерн `set_fact: x="{{ .x + 1 }}"` — попытку
// завести изменяемое состояние (счётчик, накопитель) поверх декларативной
// модели, где каждый таск должен описывать конечное состояние, а не шаг
// вычисления, зависящий от предыдущего запуска этого же таска.
type SetFactStateRule struct{}
func (r *SetFactStateRule) Name() string { return "set-fact-self-reference" }
func (r *SetFactStateRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.Module != "set_fact" {
return
}
for varName, val := range t.Args {
expr, ok := val.(string)
if !ok {
continue
}
if referencesVar(expr, varName) {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: "set_fact: " + varName + " ссылается сам на себя — похоже на попытку " +
"завести изменяемое состояние между тасками, что нарушает идемпотентность плейбука",
})
}
}
})
return out
}
// referencesVar — грубая, но достаточная для первой версии проверка:
// содержит ли выражение шаблона `.varName` внутри себя же (self-reference).
func referencesVar(expr, varName string) bool {
return strings.Contains(expr, "."+varName)
}

View file

@ -0,0 +1,55 @@
package lint
import (
"strings"
"github.com/vladimir/goherence/internal/parser"
)
// shellToModuleHints — известные паттерны команд, для которых есть
// прямой декларативный модуль. Список расширяется по мере роста
// internal/module/builtin — если добавляешь builtin-модуль, стоит
// сразу подумать, какие shell-паттерны он делает лишними.
var shellToModuleHints = map[string]string{
"apt-get install": "apt",
"apt install": "apt",
"yum install": "rpm_package",
"dnf install": "rpm_package",
"systemctl": "systemd",
"mkdir -p": "file (state=directory)",
"chmod": "file (mode=...)",
"rm -rf": "file (state=absent)",
}
// ShellUsageRule ловит использование shell/command там, где уже есть
// builtin-модуль с тем же эффектом, но идемпотентный по своей природе.
// Единственный способ обойти правило — явная пометка allow_shell: true
// прямо на таске: она остаётся видна в коде плейбука навсегда, так что
// причина обхода на виду у любого, кто читает файл при code review.
type ShellUsageRule struct{}
func (r *ShellUsageRule) Name() string { return "shell-usage" }
func (r *ShellUsageRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.Module != "shell" && t.Module != "command" {
return
}
if t.AllowShell {
return
}
cmd, _ := t.Args["cmd"].(string)
for pattern, alt := range shellToModuleHints {
if strings.Contains(cmd, pattern) {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: "используй декларативный модуль " + alt +
" вместо shell/command, либо явно пометь таск allow_shell: true: " + cmd,
})
return // одного совпадения достаточно, не дублируем сообщение
}
}
})
return out
}

View file

@ -0,0 +1,45 @@
package lint
import (
"regexp"
"github.com/vladimir/goherence/internal/parser"
)
// WhenGrammarRule разрешает в `when` только чистые предикаты:
// сравнения (==, !=, <, >, <=, >=), логику (and, or, not) и обращения
// к переменным. Вызов функций (sprig или любых других) в when запрещён —
// это первый и самый частый путь протащить в условие вычисление с
// побочным эффектом (случайные числа, текущее время, счётчики).
//
// Реализация первой версии — не полноценный парсер выражений, а
// консервативный regex-фильтр: ищем характерный синтаксис вызова функции
// `имя(...)` и паттерн `| filterName` (пайп — тоже вызов функции в Go-шаблонах).
// Ложные срабатывания возможны и это осознанный компромисс: лучше
// требовать явный allow_shell-подобный обход, чем пропустить реальный вызов.
type WhenGrammarRule struct{}
func (r *WhenGrammarRule) Name() string { return "when-grammar" }
var (
funcCallPattern = regexp.MustCompile(`[a-zA-Z_][a-zA-Z0-9_]*\s*\(`)
pipePattern = regexp.MustCompile(`\|\s*[a-zA-Z_]`)
)
func (r *WhenGrammarRule) Check(pb *parser.Playbook) []Violation {
var out []Violation
walkTasks(pb, func(loc string, t *parser.Task) {
if t.When == "" {
return
}
if funcCallPattern.MatchString(t.When) || pipePattern.MatchString(t.When) {
out = append(out, Violation{
Rule: r.Name(), Severity: Error, Location: loc,
Message: "when-выражение должно быть чистым предикатом " +
"(сравнения / and / or / not над переменными), вызовы функций и " +
"фильтры (|) внутри when запрещены: " + t.When,
})
}
})
return out
}

View file

@ -0,0 +1,73 @@
package builtin
import (
"context"
"fmt"
"strings"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// AptModule — аналог ansible.builtin.apt: приводит наличие пакета
// к желаемому state (present/absent). Идемпотентен через `dpkg -s`
// перед любым изменяющим действием.
type AptModule struct{}
func (m *AptModule) Name() string { return "apt" }
func (m *AptModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
name, ok := in.Args["name"].(string)
if !ok {
return module.Result{}, fmt.Errorf("apt: аргумент name обязателен")
}
state, _ := in.Args["state"].(string)
if state == "" {
state = "present"
}
installed, err := m.isInstalled(conn, name)
if err != nil {
return module.Result{}, err
}
wantInstalled := state == "present" || state == "latest"
if installed == wantInstalled {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: name + ": уже в нужном состоянии"}, nil
}
if in.CheckMode {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true,
Msg: fmt.Sprintf("check mode: %s был бы %s", name, state)}, nil
}
var cmd string
if wantInstalled {
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y %s", shArg(name))
} else {
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y %s", shArg(name))
}
res, err := conn.Run(cmd)
if err != nil {
return module.Result{}, err
}
if res.ExitCode != 0 {
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
Msg: fmt.Sprintf("apt-get завершился с кодом %d: %s", res.ExitCode, res.Stderr)}, nil
}
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: name + ": " + state}, nil
}
func (m *AptModule) isInstalled(conn *ssh.Conn, name string) (bool, error) {
res, err := conn.Run(fmt.Sprintf("dpkg -s %s 2>/dev/null | grep -q '^Status: install ok installed'", shArg(name)))
if err != nil {
return false, err
}
return res.ExitCode == 0, nil
}
func shArg(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View file

@ -0,0 +1,52 @@
package builtin
import (
"bytes"
"context"
"fmt"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// CopyModule — аналог ansible.builtin.copy: приводит файл на хосте
// к заданному содержимому. Идемпотентен: сначала читает текущее
// содержимое и сравнивает, "changed" выставляется только при реальном отличии.
type CopyModule struct{}
func (m *CopyModule) Name() string { return "copy" }
func (m *CopyModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
dest, ok := in.Args["dest"].(string)
if !ok {
return module.Result{}, fmt.Errorf("copy: аргумент dest обязателен")
}
content, ok := in.Args["content"].(string)
if !ok {
return module.Result{}, fmt.Errorf("copy: аргумент content обязателен (первая версия не читает src-файлы с control node)")
}
mode, _ := in.Args["mode"].(string)
if mode == "" {
mode = "0644"
}
current, err := conn.ReadFile(dest)
same := err == nil && bytes.Equal(current, []byte(content))
if same {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: "уже в нужном состоянии"}, nil
}
if in.CheckMode {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: "check mode: файл был бы изменён: " + dest}, nil
}
if err := conn.WriteFile(dest, []byte(content), mode); err != nil {
return module.Result{}, fmt.Errorf("copy: запись %s: %w", dest, err)
}
return module.Result{
SchemaVersion: module.SchemaVersion,
Changed: true,
Msg: "файл записан: " + dest,
}, nil
}

View file

@ -0,0 +1,101 @@
package builtin
import (
"context"
"fmt"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// FileModule — аналог ansible.builtin.file: управляет состоянием пути
// (директория/файл/отсутствие) и правами доступа. Идемпотентен через `stat`.
type FileModule struct{}
func (m *FileModule) Name() string { return "file" }
func (m *FileModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
path, ok := in.Args["path"].(string)
if !ok {
return module.Result{}, fmt.Errorf("file: аргумент path обязателен")
}
state, _ := in.Args["state"].(string)
if state == "" {
state = "file"
}
mode, _ := in.Args["mode"].(string)
current, err := m.stat(conn, path)
if err != nil {
return module.Result{}, err
}
changed := false
var action string
switch state {
case "directory":
if current != "directory" {
action = fmt.Sprintf("mkdir -p %s", shArg(path))
changed = true
}
case "absent":
if current != "absent" {
action = fmt.Sprintf("rm -rf %s", shArg(path))
changed = true
}
case "touch":
if current == "absent" {
action = fmt.Sprintf("touch %s", shArg(path))
changed = true
}
default:
return module.Result{}, fmt.Errorf("file: неподдерживаемый state %q (первая версия: directory/absent/touch)", state)
}
if mode != "" && current != "absent" {
if action != "" {
action += " && "
}
action += fmt.Sprintf("chmod %s %s", mode, shArg(path))
changed = true // не проверяем текущий режим отдельно в первой версии — упрощение
}
if !changed {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: "уже в нужном состоянии"}, nil
}
if in.CheckMode {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: "check mode: " + action}, nil
}
res, err := conn.Run(action)
if err != nil {
return module.Result{}, err
}
if res.ExitCode != 0 {
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
Msg: fmt.Sprintf("команда завершилась с кодом %d: %s", res.ExitCode, res.Stderr)}, nil
}
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: action}, nil
}
func (m *FileModule) stat(conn *ssh.Conn, path string) (string, error) {
res, err := conn.Run(fmt.Sprintf(
`test -d %s && echo directory || (test -e %s && echo file || echo absent)`,
shArg(path), shArg(path)))
if err != nil {
return "", err
}
switch {
case containsTrim(res.Stdout, "directory"):
return "directory", nil
case containsTrim(res.Stdout, "absent"):
return "absent", nil
default:
return "file", nil
}
}
func containsTrim(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || s == sub+"\n")
}

View file

@ -0,0 +1,63 @@
package builtin
import (
"context"
"fmt"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// PackageModule — аналог puppet-style типизированного ресурса package:
// один модуль на все дистрибутивы, сам выбирающий провайдера (apt/dnf/yum),
// вместо того чтобы заставлять плейбук писать `when: os_family == ...`
// для каждой пары модуль/ОС. Выбор провайдера:
// 1. если в hostVars есть os_family (факты собраны) — используем его напрямую,
// без единого лишнего запроса на хост;
// 2. если фактов нет (gather_facts: false) — определяем прямо на месте
// через наличие apt-get/dnf/yum на хосте (одна дополнительная команда,
// но только в этом случае — не платим за это, когда факты уже есть).
type PackageModule struct {
// OSFamily — предвычисленный на этапе резолва переменных os_family
// (пусто, если факты не собирались). Заполняется executor'ом так же,
// как TemplateModule.Vars — см. builtin.RegisterAll.
OSFamily string
}
func (m *PackageModule) Name() string { return "package" }
func (m *PackageModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
family := m.OSFamily
if family == "" {
detected, err := detectOSFamily(conn)
if err != nil {
return module.Result{}, fmt.Errorf("package: не удалось определить дистрибутив " +
"(факты не собраны и автоопределение не удалось): " + err.Error())
}
family = detected
}
switch family {
case "Debian":
return (&AptModule{}).Run(ctx, in, conn)
case "RedHat":
return (&PackageRPMModule{}).Run(ctx, in, conn)
default:
return module.Result{}, fmt.Errorf(
"package: неизвестное семейство ОС %q — используй apt или rpm_package явно", family)
}
}
// detectOSFamily — резервный путь для случая gather_facts: false: одна
// команда на хосте вместо полноценного facts.Gather (который делает
// дополнительный `cat /etc/os-release`) — здесь достаточно проверить
// наличие пакетного менеджера, сам os-release не нужен.
func detectOSFamily(conn *ssh.Conn) (string, error) {
if res, err := conn.Run("command -v apt-get"); err == nil && res.ExitCode == 0 {
return "Debian", nil
}
if res, err := conn.Run("command -v dnf || command -v yum"); err == nil && res.ExitCode == 0 {
return "RedHat", nil
}
return "", fmt.Errorf("не найден ни apt-get, ни dnf/yum")
}

View file

@ -0,0 +1,21 @@
package builtin
import "github.com/vladimir/goherence/internal/module"
// RegisterAll добавляет все builtin-модули первой версии в реестр.
// Единственное место, которое нужно трогать при добавлении нового модуля —
// сам модуль остаётся в отдельном файле по одному на модуль (см. shell.go,
// copy.go, apt.go и т.д.), а не сваливается всё в один monolith-modules.go.
func RegisterAll(reg *module.Registry, hostVars map[string]interface{}) {
reg.RegisterBuiltin(&ShellModule{})
reg.RegisterBuiltin(&CommandModule{})
reg.RegisterBuiltin(&CopyModule{})
reg.RegisterBuiltin(&TemplateModule{Vars: hostVars})
reg.RegisterBuiltin(&AptModule{})
reg.RegisterBuiltin(&PackageRPMModule{})
reg.RegisterBuiltin(&FileModule{})
reg.RegisterBuiltin(&SystemdModule{})
osFamily, _ := hostVars["os_family"].(string) // пусто, если факты не собирались — см. package.go
reg.RegisterBuiltin(&PackageModule{OSFamily: osFamily})
}

View file

@ -0,0 +1,82 @@
package builtin
import (
"context"
"fmt"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// PackageRPMModule — аналог ansible.builtin.yum/dnf для RPM-based систем
// (RHEL/CentOS/ALT Linux с apt-rpm тоже подходит частично, но здесь
// используется универсальный `rpm -q` для проверки + `dnf`/`yum` для установки).
type PackageRPMModule struct{}
func (m *PackageRPMModule) Name() string { return "rpm_package" }
func (m *PackageRPMModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
name, ok := in.Args["name"].(string)
if !ok {
return module.Result{}, fmt.Errorf("rpm_package: аргумент name обязателен")
}
state, _ := in.Args["state"].(string)
if state == "" {
state = "present"
}
installed, err := m.isInstalled(conn, name)
if err != nil {
return module.Result{}, err
}
wantInstalled := state == "present" || state == "latest"
if installed == wantInstalled {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: name + ": уже в нужном состоянии"}, nil
}
if in.CheckMode {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true,
Msg: fmt.Sprintf("check mode: %s был бы %s", name, state)}, nil
}
pm, err := m.packageManager(conn)
if err != nil {
return module.Result{}, err
}
var cmd string
if wantInstalled {
cmd = fmt.Sprintf("%s install -y %s", pm, shArg(name))
} else {
cmd = fmt.Sprintf("%s remove -y %s", pm, shArg(name))
}
res, err := conn.Run(cmd)
if err != nil {
return module.Result{}, err
}
if res.ExitCode != 0 {
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
Msg: fmt.Sprintf("%s завершился с кодом %d: %s", pm, res.ExitCode, res.Stderr)}, nil
}
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: name + ": " + state}, nil
}
func (m *PackageRPMModule) isInstalled(conn *ssh.Conn, name string) (bool, error) {
res, err := conn.Run(fmt.Sprintf("rpm -q %s > /dev/null 2>&1", shArg(name)))
if err != nil {
return false, err
}
return res.ExitCode == 0, nil
}
// packageManager определяет dnf/yum/apt-rpm по наличию бинарника —
// нужно, потому что RPM-based дистрибутивы разошлись по пакетным менеджерам.
func (m *PackageRPMModule) packageManager(conn *ssh.Conn) (string, error) {
for _, candidate := range []string{"dnf", "yum", "apt-get"} {
res, err := conn.Run("command -v " + candidate)
if err == nil && res.ExitCode == 0 {
return candidate, nil
}
}
return "", fmt.Errorf("не найден ни dnf, ни yum, ни apt-get на хосте")
}

View file

@ -0,0 +1,55 @@
package builtin
import (
"context"
"fmt"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// ShellModule — единственный по-настоящему императивный builtin-модуль.
// Его использование намеренно ограничивается линтером (internal/lint) —
// сам модуль здесь просто честно исполняет команду, никакой магии.
type ShellModule struct{}
func (m *ShellModule) Name() string { return "shell" }
func (m *ShellModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
cmd, ok := in.Args["cmd"].(string)
if !ok {
return module.Result{}, fmt.Errorf("shell: аргумент cmd обязателен и должен быть строкой")
}
if in.CheckMode {
return module.Result{
SchemaVersion: module.SchemaVersion,
Changed: true, // shell не умеет предсказывать идемпотентность — check-mode всегда "would change"
Msg: "check mode: команда не выполнена: " + cmd,
}, nil
}
res, err := conn.Run(cmd)
if err != nil {
return module.Result{}, err
}
return module.Result{
SchemaVersion: module.SchemaVersion,
Changed: true, // shell не имеет понятия состояния — считается изменяющим всегда
Failed: res.ExitCode != 0,
Msg: res.Stdout,
Diff: map[string]string{
"stderr": res.Stderr,
"exit_code": fmt.Sprintf("%d", res.ExitCode),
},
}, nil
}
// CommandModule — то же самое, что shell, но семантически "без shell-обвязки"
// (в реальном Ansible command не проходит через /bin/sh, что важно для
// экранирования). Для первой версии реализация идентична shell —
// разница добавляется отдельно, когда понадобится exec без shell-интерпретации.
type CommandModule struct{ ShellModule }
func (m *CommandModule) Name() string { return "command" }

View file

@ -0,0 +1,96 @@
package builtin
import (
"context"
"fmt"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// SystemdModule — аналог ansible.builtin.systemd: приводит сервис
// к желаемому состоянию (started/stopped) и/или включает автозапуск (enabled).
type SystemdModule struct{}
func (m *SystemdModule) Name() string { return "systemd" }
func (m *SystemdModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
name, ok := in.Args["name"].(string)
if !ok {
return module.Result{}, fmt.Errorf("systemd: аргумент name обязателен")
}
state, _ := in.Args["state"].(string)
enabledArg, hasEnabled := in.Args["enabled"].(bool)
changed := false
var actions []string
if state != "" {
active, err := m.isActive(conn, name)
if err != nil {
return module.Result{}, err
}
wantActive := state == "started"
if state == "restarted" {
actions = append(actions, "systemctl restart "+shArg(name))
changed = true
} else if active != wantActive {
verb := "stop"
if wantActive {
verb = "start"
}
actions = append(actions, "systemctl "+verb+" "+shArg(name))
changed = true
}
}
if hasEnabled {
enabled, err := m.isEnabled(conn, name)
if err != nil {
return module.Result{}, err
}
if enabled != enabledArg {
verb := "disable"
if enabledArg {
verb = "enable"
}
actions = append(actions, "systemctl "+verb+" "+shArg(name))
changed = true
}
}
if !changed {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: name + ": уже в нужном состоянии"}, nil
}
if in.CheckMode {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: fmt.Sprintf("check mode: %v", actions)}, nil
}
for _, action := range actions {
res, err := conn.Run(action)
if err != nil {
return module.Result{}, err
}
if res.ExitCode != 0 {
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
Msg: fmt.Sprintf("%q завершилась с кодом %d: %s", action, res.ExitCode, res.Stderr)}, nil
}
}
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: fmt.Sprintf("%v", actions)}, nil
}
func (m *SystemdModule) isActive(conn *ssh.Conn, name string) (bool, error) {
res, err := conn.Run("systemctl is-active " + shArg(name))
if err != nil {
return false, err
}
return res.ExitCode == 0, nil
}
func (m *SystemdModule) isEnabled(conn *ssh.Conn, name string) (bool, error) {
res, err := conn.Run("systemctl is-enabled " + shArg(name))
if err != nil {
return false, err
}
return res.ExitCode == 0, nil
}

View file

@ -0,0 +1,68 @@
package builtin
import (
"bytes"
"context"
"fmt"
"os"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
tmpl "github.com/vladimir/goherence/internal/template"
)
// TemplateModule — аналог ansible.builtin.template: рендерит файл шаблона
// (text/template + sprig, см. internal/template) с текущими переменными
// таска и приводит удалённый файл к результату — идемпотентно, как copy.
type TemplateModule struct {
// Vars — переменные, с которыми рендерится шаблон. Заполняется
// executor'ом перед вызовом Run (сам Module не знает про vars.Resolve,
// чтобы не тянуть в module-пакет зависимость на parser/vars).
Vars map[string]interface{}
}
func (m *TemplateModule) Name() string { return "template" }
func (m *TemplateModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
src, ok := in.Args["src"].(string)
if !ok {
return module.Result{}, fmt.Errorf("template: аргумент src обязателен")
}
dest, ok := in.Args["dest"].(string)
if !ok {
return module.Result{}, fmt.Errorf("template: аргумент dest обязателен")
}
mode, _ := in.Args["mode"].(string)
if mode == "" {
mode = "0644"
}
raw, err := os.ReadFile(src)
if err != nil {
return module.Result{}, fmt.Errorf("template: чтение шаблона %s: %w", src, err)
}
rendered, err := tmpl.Render(src, string(raw), m.Vars)
if err != nil {
return module.Result{}, fmt.Errorf("template: рендер %s: %w", src, err)
}
current, err := conn.ReadFile(dest)
same := err == nil && bytes.Equal(current, []byte(rendered))
if same {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: "уже в нужном состоянии"}, nil
}
if in.CheckMode {
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: "check mode: файл был бы изменён: " + dest}, nil
}
if err := conn.WriteFile(dest, []byte(rendered), mode); err != nil {
return module.Result{}, fmt.Errorf("template: запись %s: %w", dest, err)
}
return module.Result{
SchemaVersion: module.SchemaVersion,
Changed: true,
Msg: "шаблон отрендерен и записан: " + dest,
}, nil
}

133
internal/module/external/deploy.go vendored Normal file
View file

@ -0,0 +1,133 @@
package external
import (
"fmt"
"os"
"strings"
"github.com/vladimir/goherence/internal/ssh"
)
// detectRemoteArch определяет "goos/goarch" удалённого хоста через uname,
// чтобы выбрать нужный бинарник модуля из BinPath.
func detectRemoteArch(conn *ssh.Conn) (string, error) {
res, err := conn.Run("uname -s; uname -m")
if err != nil {
return "", fmt.Errorf("detectRemoteArch: %w", err)
}
lines := strings.Split(strings.TrimSpace(res.Stdout), "\n")
if len(lines) < 2 {
return "", fmt.Errorf("detectRemoteArch: неожиданный вывод uname: %q", res.Stdout)
}
goos := normalizeOS(lines[0])
goarch := normalizeArch(lines[1])
return goos + "/" + goarch, nil
}
func normalizeOS(uname string) string {
switch strings.TrimSpace(uname) {
case "Linux":
return "linux"
case "Darwin":
return "darwin"
default:
return strings.ToLower(strings.TrimSpace(uname))
}
}
func normalizeArch(uname string) string {
switch strings.TrimSpace(uname) {
case "x86_64", "amd64":
return "amd64"
case "aarch64", "arm64":
return "arm64"
default:
return strings.TrimSpace(uname)
}
}
// resolveBaseDir определяет директорию на хосте для доставленных модулей —
// под домашней директорией подключившегося пользователя, а не под /opt,
// который требует root. Если по какой-то причине $HOME недоступен (пустой
// вывод), откатываемся на /tmp — доступно всегда, но переживает только
// текущую сессию ОС на некоторых системах (tmpfs с очисткой при
// перезагрузке), так что это осознанно резервный, а не основной путь.
func resolveBaseDir(conn *ssh.Conn) (string, error) {
res, err := conn.Run("echo $HOME")
if err != nil {
return "", fmt.Errorf("resolveBaseDir: %w", err)
}
home := strings.TrimSpace(res.Stdout)
if home == "" || home == "$HOME" {
return "/tmp/goherence-modules", nil
}
return home + "/.goherence/modules", nil
}
// remotePath — куда на хосте кладётся конкретная версия бинарника модуля.
// Версия зашита в путь, так что старые версии просто остаются на диске
// как есть (без отдельной чистки в первой версии) — это сознательно
// простое решение вместо retention-политики, которой пока никто не просил.
func remotePath(baseDir string, m *Module) string {
return fmt.Sprintf("%s/%s-%s", baseDir, m.Manifest.Name, m.Manifest.Version)
}
// ensureDeployed проверяет через маркер-файл, лежит ли на хосте уже
// актуальная версия модуля, и копирует бинарник только если нужно.
// Все шаги, которые могут завершиться ошибкой на удалённой стороне
// (mkdir, test), явно проверяют exit code — раньше здесь проверялась
// только транспортная ошибка, и permission denied на mkdir тихо
// протекал в WriteFile, где всплывал уже нечитаемым EOF.
func ensureDeployed(conn *ssh.Conn, m *Module) (string, error) {
arch, err := detectRemoteArch(conn)
if err != nil {
return "", err
}
localBin, ok := m.BinPath[arch]
if !ok {
return "", fmt.Errorf("модуль %s: нет собранного бинарника под архитектуру %s "+
"(доступны: %v) — проверь кросс-компиляцию", m.Manifest.Name, arch, keysOf(m.BinPath))
}
baseDir, err := resolveBaseDir(conn)
if err != nil {
return "", err
}
target := remotePath(baseDir, m)
res, err := conn.Run("test -x " + shQuote(target))
if err == nil && res.ExitCode == 0 {
return target, nil // нужная версия уже на хосте — ничего не копируем
}
data, err := os.ReadFile(localBin)
if err != nil {
return "", fmt.Errorf("читаю локальный бинарник %s: %w", localBin, err)
}
mkdirRes, err := conn.Run("mkdir -p " + shQuote(baseDir))
if err != nil {
return "", fmt.Errorf("mkdir -p %s: %w", baseDir, err)
}
if mkdirRes.ExitCode != 0 {
return "", fmt.Errorf("mkdir -p %s: exit %d: %s", baseDir, mkdirRes.ExitCode, strings.TrimSpace(mkdirRes.Stderr))
}
if err := conn.WriteFile(target, data, "0755"); err != nil {
return "", fmt.Errorf("доставка модуля %s на хост: %w", m.Manifest.Name, err)
}
return target, nil
}
func keysOf(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func shQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

52
internal/module/external/loader.go vendored Normal file
View file

@ -0,0 +1,52 @@
package external
import (
"fmt"
"os"
"path/filepath"
)
// LoadDir сканирует директорию modules.d/ — по одной поддиректории на
// модуль, каждая с manifest.json и бинарниками под все заявленные
// архитектуры. Отсутствие бинарника под конкретную архитектуру — не
// фатально на этапе загрузки (модуль может использоваться только для
// хостов другой архитектуры), но резолвится в ошибку в момент деплоя.
func LoadDir(dir string) ([]*Module, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil // modules.d/ отсутствует — это нормально, внешних модулей просто нет
}
if err != nil {
return nil, fmt.Errorf("читаю %s: %w", dir, err)
}
var modules []*Module
for _, e := range entries {
if !e.IsDir() {
continue
}
moduleDir := filepath.Join(dir, e.Name())
manifestPath := filepath.Join(moduleDir, "manifest.json")
if _, err := os.Stat(manifestPath); err != nil {
continue // директория без manifest.json — не модуль, пропускаем молча
}
m, err := LoadManifest(manifestPath)
if err != nil {
return nil, fmt.Errorf("модуль в %s: %w", moduleDir, err)
}
binPaths := map[string]string{}
for _, arch := range m.Architectures {
p := binaryPath(moduleDir, m.Name, arch)
if _, err := os.Stat(p); err == nil {
binPaths[arch] = p
}
}
modules = append(modules, &Module{
Manifest: *m,
BinPath: binPaths,
})
}
return modules, nil
}

58
internal/module/external/manifest.go vendored Normal file
View file

@ -0,0 +1,58 @@
// Package external реализует расширяемость модулей без пересборки
// бинарника goherence: сторонний модуль — любой исполняемый файл,
// который читает JSON (module.Input) из stdin и пишет JSON (module.Result)
// в stdout. Контракт совпадает с тем, как устроены сами модули Ansible —
// осознанное решение, чтобы язык реализации модуля был не важен.
package external
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// Manifest — метаданные одного внешнего модуля, лежат в modules.d/<name>/manifest.json.
type Manifest struct {
Name string `json:"name"`
Version string `json:"version"`
SchemaVersion int `json:"schema_version"`
Architectures []string `json:"architectures"` // "linux/amd64", "linux/arm64"
Checksums map[string]string `json:"checksums"` // arch → sha256 (без префикса)
}
// LoadManifest читает и валидирует manifest.json.
func LoadManifest(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("читаю manifest.json: %w", err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("парсинг manifest.json: %w", err)
}
if m.Name == "" || m.Version == "" {
return nil, fmt.Errorf("manifest.json: name и version обязательны")
}
if len(m.Architectures) == 0 {
return nil, fmt.Errorf("manifest.json: architectures не может быть пустым")
}
return &m, nil
}
// binaryPath — путь к локальному (control node) бинарнику модуля для
// конкретной архитектуры. Соглашение об именовании: <moduleDir>/<name>-<goos>-<goarch>,
// например modules.d/hello/hello-linux-amd64 для "linux/amd64".
func binaryPath(moduleDir, name, arch string) string {
goos, goarch := splitArch(arch)
return filepath.Join(moduleDir, fmt.Sprintf("%s-%s-%s", name, goos, goarch))
}
func splitArch(arch string) (goos, goarch string) {
for i := 0; i < len(arch); i++ {
if arch[i] == '/' {
return arch[:i], arch[i+1:]
}
}
return "linux", arch
}

58
internal/module/external/module.go vendored Normal file
View file

@ -0,0 +1,58 @@
package external
import (
"context"
"encoding/json"
"fmt"
"github.com/vladimir/goherence/internal/module"
"github.com/vladimir/goherence/internal/ssh"
)
// Module — обёртка над внешним (сторонним) модулем: реализует тот же
// интерфейс module.Module, что и builtin-модули, но вместо чистого Go-кода
// доставляет и запускает на хосте subprocess, обмениваясь с ним JSON
// по stdin/stdout. Executor не отличает Module от builtin — он получает
// его из того же Registry, резолвя по имени.
type Module struct {
Manifest Manifest
BinPath map[string]string // arch ("linux/amd64") → путь к локальному бинарнику
}
func (m *Module) Name() string { return m.Manifest.Name }
func (m *Module) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
if m.Manifest.SchemaVersion != module.SchemaVersion {
return module.Result{}, fmt.Errorf(
"модуль %s: schema_version %d не совпадает с ожидаемой %d — "+
"обнови модуль или goherence", m.Manifest.Name, m.Manifest.SchemaVersion, module.SchemaVersion)
}
remoteBin, err := ensureDeployed(conn, m)
if err != nil {
return module.Result{}, err
}
inputJSON, err := json.Marshal(in)
if err != nil {
return module.Result{}, fmt.Errorf("сериализация input для %s: %w", m.Manifest.Name, err)
}
res, err := conn.RunWithInput(remoteBin, inputJSON)
if err != nil {
return module.Result{}, fmt.Errorf("запуск внешнего модуля %s: %w", m.Manifest.Name, err)
}
if res.ExitCode != 0 {
return module.Result{}, fmt.Errorf(
"внешний модуль %s завершился с кодом %d, stderr: %s",
m.Manifest.Name, res.ExitCode, res.Stderr)
}
var out module.Result
if err := json.Unmarshal([]byte(res.Stdout), &out); err != nil {
return module.Result{}, fmt.Errorf(
"внешний модуль %s вернул невалидный JSON: %w (stdout: %s)",
m.Manifest.Name, err, res.Stdout)
}
return out, nil
}

97
internal/module/module.go Normal file
View file

@ -0,0 +1,97 @@
// Package module определяет единственный контракт, которому подчиняются
// и встроенные (builtin, чистый Go), и внешние (external, subprocess+JSON)
// модули. Executor не знает разницы между ними — он просто резолвит
// имя модуля через Registry и вызывает Run.
package module
import (
"context"
"github.com/vladimir/goherence/internal/ssh"
)
// Input — то, что модуль получает на вход. Для builtin-модулей это просто
// структура в памяти; для external-модулей ровно эти же поля сериализуются
// в JSON и уходят в stdin subprocess'а — контракт единый и явный.
type Input struct {
SchemaVersion int `json:"schema_version"`
Args map[string]interface{} `json:"args"`
Host string `json:"host"`
Become bool `json:"become"`
CheckMode bool `json:"check_mode"`
}
// Result — то, что модуль возвращает после выполнения.
type Result struct {
SchemaVersion int `json:"schema_version"`
Changed bool `json:"changed"`
Failed bool `json:"failed"`
Msg string `json:"msg"`
Diff interface{} `json:"diff,omitempty"`
}
// SchemaVersion — текущая версия контракта Input/Result. Меняется только
// при несовместимых изменениях формата; внешние модули должны проверять
// это поле и явно отказываться работать с версией, которую не понимают.
const SchemaVersion = 1
// Module — контракт, общий для builtin-функций и обёрток над subprocess.
type Module interface {
Name() string
Run(ctx context.Context, in Input, conn *ssh.Conn) (Result, error)
}
// Registry хранит все известные модули и резолвит их по имени.
// Порядок поиска: сперва builtin (быстрее, без overhead процесса),
// затем external — так что локальный модуль с тем же именем, что и
// встроенный, никогда не переопределяет его молча.
type Registry struct {
builtin map[string]Module
external map[string]Module // заполняется отдельно, см. internal/module/external
}
func NewRegistry() *Registry {
return &Registry{
builtin: map[string]Module{},
external: map[string]Module{},
}
}
func (r *Registry) RegisterBuiltin(m Module) {
r.builtin[m.Name()] = m
}
func (r *Registry) RegisterExternal(m Module) {
r.external[m.Name()] = m
}
// Resolve возвращает модуль по имени. Принимает как короткие имена
// (`shell`), так и полные (`ansible.builtin.shell`) — второй компонент
// после последней точки трактуется как основное имя.
func (r *Registry) Resolve(name string) (Module, error) {
short := shortName(name)
if m, ok := r.builtin[short]; ok {
return m, nil
}
if m, ok := r.external[short]; ok {
return m, nil
}
return nil, ErrModuleNotFound{Name: name}
}
func shortName(name string) string {
for i := len(name) - 1; i >= 0; i-- {
if name[i] == '.' {
return name[i+1:]
}
}
return name
}
// ErrModuleNotFound — отдельный тип ошибки, чтобы executor мог отличить
// "модуль не найден" от прочих ошибок выполнения и дать понятную подсказку.
type ErrModuleNotFound struct{ Name string }
func (e ErrModuleNotFound) Error() string {
return "модуль не найден: " + e.Name
}

View file

@ -0,0 +1,204 @@
package parser
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// rawGroup — форма группы прямо как она лежит в YAML-инвентаре.
type rawGroup struct {
Hosts map[string]map[string]interface{} `yaml:"hosts,omitempty"`
Vars map[string]interface{} `yaml:"vars,omitempty"`
Children map[string]rawGroup `yaml:"children,omitempty"`
}
// LoadInventory читает YAML-инвентарь (в стиле `all: children: ...`)
// и разворачивает его в плоскую карту групп с явным списком children.
func LoadInventory(path string) (*Inventory, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("читаю инвентарь: %w", err)
}
var root map[string]rawGroup
if err := yaml.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("парсинг инвентаря: %w", err)
}
inv := &Inventory{
Groups: map[string]*Group{},
Dir: filepath.Dir(path),
}
for name, rg := range root {
flatten(inv, name, rg)
}
return inv, nil
}
// flatten рекурсивно разворачивает rawGroup (с вложенными children)
// в плоский inv.Groups, попутно строя список Children для каждой группы —
// это то дерево, по которому потом считается порядок наложения group_vars.
func flatten(inv *Inventory, name string, rg rawGroup) {
group, ok := inv.Groups[name]
if !ok {
group = &Group{Name: name, Hosts: map[string]*Host{}, Vars: map[string]interface{}{}}
inv.Groups[name] = group
}
for k, v := range rg.Vars {
group.Vars[k] = v
}
for hostName, hostVars := range rg.Hosts {
h, ok := group.Hosts[hostName]
if !ok {
h = &Host{Name: hostName, Vars: map[string]interface{}{}}
group.Hosts[hostName] = h
}
for k, v := range hostVars {
h.Vars[k] = v
}
}
for childName, childGroup := range rg.Children {
group.Children = append(group.Children, childName)
flatten(inv, childName, childGroup)
}
}
// GroupsOf возвращает имена всех групп, в которые входит хост,
// включая родительские группы через children (транзитивно).
func (inv *Inventory) GroupsOf(hostName string) []string {
seen := map[string]bool{}
for name, g := range inv.Groups {
if _, ok := g.Hosts[hostName]; ok {
markAncestors(inv, name, seen)
}
}
out := make([]string, 0, len(seen))
for name := range seen {
out = append(out, name)
}
return out
}
// markAncestors помечает саму группу и всех её "родителей" (группы,
// у которых она указана в children) как содержащие данный хост.
func markAncestors(inv *Inventory, group string, seen map[string]bool) {
if seen[group] {
return
}
seen[group] = true
for name, g := range inv.Groups {
for _, child := range g.Children {
if child == group {
markAncestors(inv, name, seen)
}
}
}
}
// depth — расстояние группы от "all" по дереву children (all = 0).
func (inv *Inventory) depth(group string) int {
if group == "all" {
return 0
}
best := -1
for name, g := range inv.Groups {
for _, child := range g.Children {
if child == group {
d := inv.depth(name) + 1
if best == -1 || d < best {
best = d
}
}
}
}
if best == -1 {
return 0 // группа без родителя — считаем что она на уровне all
}
return best
}
// GroupChain возвращает группы хоста в порядке применения group_vars:
// от самых общих (all, глубина 0) к самым специфичным; на одном уровне
// глубины — по алфавиту, чтобы результат был детерминирован.
func (inv *Inventory) GroupChain(hostName string) []string {
groups := inv.GroupsOf(hostName)
sort.Slice(groups, func(i, j int) bool {
di, dj := inv.depth(groups[i]), inv.depth(groups[j])
if di != dj {
return di < dj
}
return groups[i] < groups[j]
})
return groups
}
// GroupVars отдаёт переменные группы из самого инвентаря, смешанные
// с файлами group_vars/<name>.yml и group_vars/<name>/*.yml с диска.
func (inv *Inventory) GroupVars(name string) map[string]interface{} {
out := map[string]interface{}{}
if g, ok := inv.Groups[name]; ok {
for k, v := range g.Vars {
out[k] = v
}
}
for k, v := range inv.loadVarsFromDisk("group_vars", name) {
out[k] = v
}
return out
}
// HostVars — аналогично GroupVars, но для конкретного хоста
// (host_vars/<name>.yml или host_vars/<name>/*.yml).
func (inv *Inventory) HostVars(name string) map[string]interface{} {
out := map[string]interface{}{}
for _, g := range inv.Groups {
if h, ok := g.Hosts[name]; ok {
for k, v := range h.Vars {
out[k] = v
}
}
}
for k, v := range inv.loadVarsFromDisk("host_vars", name) {
out[k] = v
}
return out
}
// loadVarsFromDisk грузит либо <kind>/<name>.yml, либо все файлы
// в директории <kind>/<name>/*.yml по алфавиту, мерджа их по порядку.
func (inv *Inventory) loadVarsFromDisk(kind, name string) map[string]interface{} {
out := map[string]interface{}{}
singleFile := filepath.Join(inv.Dir, kind, name+".yml")
if vars, err := loadVarsFile(singleFile); err == nil {
for k, v := range vars {
out[k] = v
}
}
dir := filepath.Join(inv.Dir, kind, name)
entries, err := os.ReadDir(dir)
if err != nil {
return out
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".yml") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, n := range names {
if vars, err := loadVarsFile(filepath.Join(dir, n)); err == nil {
for k, v := range vars {
out[k] = v
}
}
}
return out
}

209
internal/parser/playbook.go Normal file
View file

@ -0,0 +1,209 @@
package parser
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// известные служебные ключи таска — всё остальное в YAML-мапе
// считается именем модуля + его аргументами.
var taskMetaKeys = map[string]bool{
"name": true, "when": true, "loop": true, "tags": true,
"register": true, "notify": true, "allow_shell": true,
"after": true, "before": true,
}
// UnmarshalYAML у Task разбирает произвольную YAML-мапу: известные поля
// (name/when/loop/...) уходят в соответствующие структурные поля,
// единственный оставшийся неизвестный ключ — это имя модуля, а его
// значение — аргументы модуля.
func (t *Task) UnmarshalYAML(node *yaml.Node) error {
raw := map[string]interface{}{}
if err := node.Decode(&raw); err != nil {
return fmt.Errorf("decode task: %w", err)
}
t.Raw = raw
if v, ok := raw["name"].(string); ok {
t.Name = v
}
if v, ok := raw["when"].(string); ok {
t.When = v
}
if v, ok := raw["loop"]; ok {
t.Loop = v
}
if v, ok := raw["register"].(string); ok {
t.Register = v
}
if v, ok := raw["allow_shell"].(bool); ok {
t.AllowShell = v
}
if v, ok := raw["tags"]; ok {
t.Tags = toStringSlice(v)
}
if v, ok := raw["notify"]; ok {
t.Notify = toStringSlice(v)
}
if v, ok := raw["after"]; ok {
t.After = toStringSlice(v)
}
if v, ok := raw["before"]; ok {
t.Before = toStringSlice(v)
}
moduleName, moduleArgs, err := extractModule(raw)
if err != nil {
return err
}
t.Module = moduleName
t.Args = moduleArgs
return nil
}
// extractModule находит единственный ключ, не входящий в taskMetaKeys,
// и трактует его как имя модуля. Если такой ключ не один — это ошибка
// плейбука (двусмысленность, какой модуль на самом деле выполняется).
func extractModule(raw map[string]interface{}) (string, map[string]interface{}, error) {
var moduleName string
found := 0
for k := range raw {
if taskMetaKeys[k] {
continue
}
moduleName = k
found++
}
if found == 0 {
return "", nil, fmt.Errorf("таск без модуля: %v", raw)
}
if found > 1 {
return "", nil, fmt.Errorf("таск с несколькими модулями сразу — неоднозначно: %v", raw)
}
switch v := raw[moduleName].(type) {
case map[string]interface{}:
return moduleName, v, nil
case string:
// короткая форма: `shell: "apt-get update"` — заворачиваем в единственный
// аргумент "cmd", которого ждут shell/command модули.
return moduleName, map[string]interface{}{"cmd": v}, nil
case nil:
return moduleName, map[string]interface{}{}, nil
default:
return "", nil, fmt.Errorf("модуль %q: неожиданный тип аргументов %T", moduleName, v)
}
}
func toStringSlice(v interface{}) []string {
switch vv := v.(type) {
case []interface{}:
out := make([]string, 0, len(vv))
for _, item := range vv {
out = append(out, fmt.Sprintf("%v", item))
}
return out
case string:
return []string{vv}
default:
return nil
}
}
// LoadPlaybook читает playbook.yml, парсит все play и подгружает роли,
// на которые есть ссылки в `roles:`, из директории `roles/` рядом с плейбуком.
func LoadPlaybook(path string) (*Playbook, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("читаю плейбук: %w", err)
}
var plays []Play
if err := yaml.Unmarshal(data, &plays); err != nil {
return nil, fmt.Errorf("парсинг плейбука: %w", err)
}
pb := &Playbook{
Plays: plays,
Roles: map[string]*Role{},
Dir: filepath.Dir(path),
}
rolesDir := filepath.Join(pb.Dir, "roles")
for _, play := range plays {
for _, roleName := range play.Roles {
if _, ok := pb.Roles[roleName]; ok {
continue
}
role, err := loadRole(rolesDir, roleName)
if err != nil {
return nil, fmt.Errorf("роль %q: %w", roleName, err)
}
pb.Roles[roleName] = role
}
}
return pb, nil
}
// loadRole читает директорийную структуру одной роли:
// tasks/main.yml, defaults/main.yml, vars/main.yml, handlers/main.yml.
// Отсутствующие файлы — не ошибка, просто пустые значения.
func loadRole(rolesDir, name string) (*Role, error) {
dir := filepath.Join(rolesDir, name)
role := &Role{Name: name, Dir: dir}
tasks, err := loadTaskFile(filepath.Join(dir, "tasks", "main.yml"))
if err != nil {
return nil, err
}
role.Tasks = tasks
handlers, err := loadTaskFile(filepath.Join(dir, "handlers", "main.yml"))
if err != nil {
return nil, err
}
role.Handlers = handlers
role.Defaults, err = loadVarsFile(filepath.Join(dir, "defaults", "main.yml"))
if err != nil {
return nil, err
}
role.Vars, err = loadVarsFile(filepath.Join(dir, "vars", "main.yml"))
if err != nil {
return nil, err
}
return role, nil
}
func loadTaskFile(path string) ([]Task, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var tasks []Task
if err := yaml.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return tasks, nil
}
func loadVarsFile(path string) (map[string]interface{}, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return map[string]interface{}{}, nil
}
if err != nil {
return nil, err
}
vars := map[string]interface{}{}
if err := yaml.Unmarshal(data, &vars); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return vars, nil
}

75
internal/parser/types.go Normal file
View file

@ -0,0 +1,75 @@
// Package parser описывает структуры плейбука, инвентаря и ролей
// и умеет загружать их из YAML. Никакой логики выполнения тут нет —
// только данные и их загрузка с диска.
package parser
// Task — один шаг плейбука или роли.
type Task struct {
Name string `yaml:"name"`
Module string `yaml:"-"` // заполняется при разборе (имя ключа модуля)
Args map[string]interface{} `yaml:"-"` // аргументы этого модуля
When string `yaml:"when,omitempty"`
Loop interface{} `yaml:"loop,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Register string `yaml:"register,omitempty"`
Notify []string `yaml:"notify,omitempty"`
AllowShell bool `yaml:"allow_shell,omitempty"`
// After/Before — явные зависимости по имени таска в пределах того же
// списка (play.Tasks или role.Tasks), как require/before в Puppet.
// Без них порядок остаётся тем же, что в файле (см. internal/executor
// graph.go — стабильная топологическая сортировка не переставляет
// таски без объявленных зависимостей).
After []string `yaml:"after,omitempty"`
Before []string `yaml:"before,omitempty"`
// Raw хранит исходную YAML-map таска — из неё вычленяется Module/Args
// на этапе UnmarshalYAML, см. parser.go.
Raw map[string]interface{} `yaml:"-"`
}
// Play — один play плейбука: на каких хостах, какими переменными, какие роли/таски.
type Play struct {
Name string `yaml:"name"`
Hosts string `yaml:"hosts"`
Vars map[string]interface{} `yaml:"vars,omitempty"`
Roles []string `yaml:"roles,omitempty"`
Tasks []Task `yaml:"tasks,omitempty"`
}
// Role — загруженная с диска роль: taskи + defaults/vars/handlers/templates.
type Role struct {
Name string
Tasks []Task
Defaults map[string]interface{}
Vars map[string]interface{}
Handlers []Task
Dir string // корневая директория роли, нужна для поиска templates/files
}
// Playbook — верхнеуровневая единица: список play + резолвленные роли.
type Playbook struct {
Plays []Play
Roles map[string]*Role // имя роли → загруженная роль
Dir string // директория, где лежит сам playbook.yml (для relative include)
}
// Host — один хост инвентаря.
type Host struct {
Name string
Vars map[string]interface{}
}
// Group — группа хостов, может содержать дочерние группы (children).
type Group struct {
Name string
Hosts map[string]*Host
Vars map[string]interface{}
Children []string
}
// Inventory — весь инвентарь: группы + пути к group_vars/host_vars на диске.
type Inventory struct {
Groups map[string]*Group
Dir string // директория инвентаря, для поиска group_vars/host_vars
}

268
internal/ssh/client.go Normal file
View file

@ -0,0 +1,268 @@
// Package ssh — тонкая обвязка над golang.org/x/crypto/ssh: подключение
// по ключу или паролю, выполнение команд, become/sudo, запись файлов
// на удалённый хост. Никакой модульной логики тут нет — это только
// транспорт, которым пользуются builtin- и external-модули.
package ssh
import (
"bytes"
"fmt"
"net"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// Conn — открытое SSH-соединение с одним хостом.
type Conn struct {
client *ssh.Client
Host string
Become bool // выполнять команды через sudo
}
// Config описывает, как подключаться к хосту.
type Config struct {
Host string
Port int // по умолчанию 22
User string
PrivateKeyPath string // путь к приватному ключу, если пусто — пробуем ssh-agent
Password string // используется только если PrivateKeyPath пуст
KnownHostsPath string // пусто — проверка отключена (см. предупреждение ниже)
Timeout time.Duration
}
// Dial открывает соединение по Config. Если KnownHostsPath не задан,
// используется ssh.InsecureIgnoreHostKey — это осознанный компромисс
// для первой версии, но должен быть заменён на реальную проверку
// перед использованием где-либо, кроме локальных тестов.
func Dial(cfg Config) (*Conn, error) {
auth, err := buildAuth(cfg)
if err != nil {
return nil, err
}
hostKeyCallback := ssh.InsecureIgnoreHostKey()
if cfg.KnownHostsPath != "" {
cb, err := knownhosts.New(cfg.KnownHostsPath)
if err != nil {
return nil, fmt.Errorf("known_hosts: %w", err)
}
hostKeyCallback = cb
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
port := cfg.Port
if port == 0 {
port = 22
}
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Auth: auth,
HostKeyCallback: hostKeyCallback,
Timeout: timeout,
}
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
client, err := ssh.Dial("tcp", addr, clientCfg)
if err != nil {
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
}
return &Conn{client: client, Host: cfg.Host}, nil
}
func buildAuth(cfg Config) ([]ssh.AuthMethod, error) {
if cfg.PrivateKeyPath != "" {
keyPath, err := expandHome(cfg.PrivateKeyPath)
if err != nil {
return nil, err
}
key, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("читаю приватный ключ: %w", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, fmt.Errorf("парсинг приватного ключа: %w", err)
}
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
}
if cfg.Password != "" {
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
}
return nil, fmt.Errorf("не задан ни PrivateKeyPath, ни Password")
}
// expandHome разворачивает ведущий "~" в путь к домашней директории —
// ни os.ReadFile, ни что-либо в стандартной библиотеке не делает этого
// само по себе, а путь к ключу в конфиге/флагах CLI обычно пишут с "~".
func expandHome(path string) (string, error) {
if !strings.HasPrefix(path, "~") {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("не удалось определить домашнюю директорию для %q: %w", path, err)
}
return home + strings.TrimPrefix(path, "~"), nil
}
// ExecResult — итог выполнения команды на хосте.
type ExecResult struct {
Stdout string
Stderr string
ExitCode int
}
// Run выполняет команду на хосте. Если c.Become — оборачивает в sudo -n.
func (c *Conn) Run(cmd string) (ExecResult, error) {
if c.Become {
cmd = "sudo -n -- " + cmd
}
session, err := c.client.NewSession()
if err != nil {
return ExecResult{}, fmt.Errorf("новая ssh-сессия: %w", err)
}
defer session.Close()
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
err = session.Run(cmd)
exitCode := 0
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
exitCode = exitErr.ExitStatus()
} else {
return ExecResult{}, fmt.Errorf("выполнение команды: %w", err)
}
}
return ExecResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: exitCode,
}, nil
}
// WriteFile записывает content в remotePath с правами mode. Реализовано
// через `cat > file` по тому же SSH-каналу — без отдельного SFTP-подключения,
// чтобы не тянуть ещё одну зависимость в первой версии. Для больших файлов
// и бинарной доставки внешних модулей это стоит заменить на настоящий SFTP
// (см. internal/module/external — там уже отдельный контракт).
func (c *Conn) WriteFile(remotePath string, content []byte, mode string) error {
cmd := fmt.Sprintf("cat > %s && chmod %s %s", shellQuote(remotePath), mode, shellQuote(remotePath))
if c.Become {
cmd = "sudo -n -- sh -c " + shellQuote(cmd)
}
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("новая ssh-сессия: %w", err)
}
defer session.Close()
var stderr bytes.Buffer
session.Stderr = &stderr
stdin, err := session.StdinPipe()
if err != nil {
return err
}
if err := session.Start(cmd); err != nil {
return err
}
_, writeErr := stdin.Write(content)
stdin.Close()
waitErr := session.Wait()
// сперва разбираем exit status/stderr удалённой команды — это
// содержательная причина (например "Permission denied"), тогда как
// ошибка самого stdin.Write (обычно EOF) — лишь её следствие и
// без exit-кода ничего не объясняет пользователю.
if waitErr != nil {
if exitErr, ok := waitErr.(*ssh.ExitError); ok {
return fmt.Errorf("запись %s: команда завершилась с кодом %d: %s",
remotePath, exitErr.ExitStatus(), strings.TrimSpace(stderr.String()))
}
return fmt.Errorf("запись %s: %w (stderr: %s)", remotePath, waitErr, strings.TrimSpace(stderr.String()))
}
if writeErr != nil {
return fmt.Errorf("запись %s: ошибка передачи данных: %w (stderr: %s)",
remotePath, writeErr, strings.TrimSpace(stderr.String()))
}
return nil
}
// ReadFile читает содержимое удалённого файла целиком.
func (c *Conn) ReadFile(remotePath string) ([]byte, error) {
res, err := c.Run(fmt.Sprintf("cat %s", shellQuote(remotePath)))
if err != nil {
return nil, err
}
if res.ExitCode != 0 {
return nil, fmt.Errorf("cat %s: exit %d: %s", remotePath, res.ExitCode, res.Stderr)
}
return []byte(res.Stdout), nil
}
// RunWithInput выполняет команду, передавая content в её stdin, и
// возвращает результат целиком. Используется external-модулями для
// обмена JSON по контракту stdin/stdout (см. internal/module/external).
func (c *Conn) RunWithInput(cmd string, content []byte) (ExecResult, error) {
if c.Become {
cmd = "sudo -n -- " + cmd
}
session, err := c.client.NewSession()
if err != nil {
return ExecResult{}, fmt.Errorf("новая ssh-сессия: %w", err)
}
defer session.Close()
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
stdin, err := session.StdinPipe()
if err != nil {
return ExecResult{}, err
}
if err := session.Start(cmd); err != nil {
return ExecResult{}, err
}
if _, err := stdin.Write(content); err != nil {
return ExecResult{}, err
}
stdin.Close()
err = session.Wait()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
exitCode = exitErr.ExitStatus()
} else {
return ExecResult{}, fmt.Errorf("выполнение команды: %w", err)
}
}
return ExecResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, nil
}
func (c *Conn) Close() error {
return c.client.Close()
}
// shellQuote — минимальное экранирование пути для подстановки в shell-команду.
func shellQuote(s string) string {
return "'" + string(bytes.ReplaceAll([]byte(s), []byte("'"), []byte(`'\''`))) + "'"
}

View file

@ -0,0 +1,14 @@
package template
import "text/template"
// CustomFuncs — функции, которых нет в sprig, но есть в конкретных
// Ansible-фильтрах, которыми пользуется команда (например ansible.utils.ipaddr).
// Пусто в первой версии — заполняется по мере миграции реальных .j2-шаблонов
// и обнаружения фильтров без прямого аналога.
func CustomFuncs() template.FuncMap {
return template.FuncMap{
// пример на будущее:
// "ipaddr": func(cidr string) (string, error) { ... },
}
}

View file

@ -0,0 +1,31 @@
// Package template рендерит шаблоны файлов (аналог модуля `template` из
// Ansible) через стандартный text/template с добавленными функциями
// sprig — свой парсер, а не сторонняя реализация Jinja2, чтобы не зависеть
// от чужой грамматики.
package template
import (
"strings"
"text/template"
"github.com/Masterminds/sprig/v3"
)
// Render рендерит src (содержимое шаблона) с переменными vars.
// name используется только для сообщений об ошибках парсинга.
func Render(name, src string, vars map[string]interface{}) (string, error) {
tmpl, err := template.New(name).
Funcs(sprig.FuncMap()).
Funcs(CustomFuncs()).
Option("missingkey=error"). // опечатка в имени переменной — ошибка, а не пустая строка
Parse(src)
if err != nil {
return "", err
}
var buf strings.Builder
if err := tmpl.Execute(&buf, vars); err != nil {
return "", err
}
return buf.String(), nil
}

58
internal/vars/merge.go Normal file
View file

@ -0,0 +1,58 @@
// Package vars реализует единственную ответственность: для заданного
// хоста построить финальный набор переменных, накатывая слои строго
// в порядке приоритета Ansible (упрощённом до того, что реально нужно).
package vars
import "github.com/vladimir/goherence/internal/parser"
// Resolve строит финальные переменные для (хост, роль, play), от самого
// низкого приоритета к самому высокому. Порядок совпадает с комментарием
// в архитектурном документе — намеренно не абстрагирован дальше, чтобы
// последовательность накатывания оставалась видна с первого взгляда.
// facts — собранные internal/facts данные о хосте (os_family и
// т.п.); в реальном Ansible факты имеют высокий приоритет, но ниже
// явных play/role vars и extra-vars — этот порядок здесь и соблюдён.
func Resolve(
host string,
inv *parser.Inventory,
play *parser.Play,
role *parser.Role,
facts map[string]interface{},
extraVars map[string]interface{},
) map[string]interface{} {
result := map[string]interface{}{}
if role != nil {
merge(result, role.Defaults) // 1. role defaults — самый низкий приоритет
}
merge(result, inv.GroupVars("all")) // 2. group_vars/all
for _, g := range inv.GroupChain(host) {
if g == "all" {
continue
}
merge(result, inv.GroupVars(g)) // 3. group_vars/<group>, родители раньше потомков
}
merge(result, inv.HostVars(host)) // 4. host_vars/<host>
merge(result, facts) // 4.5. факты хоста (os_family и т.п.)
if play != nil {
merge(result, play.Vars) // 5. play vars
}
if role != nil {
merge(result, role.Vars) // 6. role vars/main.yml
}
// 7. set_fact / registered vars — накатываются executor'ом во время
// выполнения, а не здесь: Resolve вызывается один раз до старта таска.
merge(result, extraVars) // 8. extra-vars — самый высокий приоритет, всегда последний
return result
}
// merge — намеренно плоская перезапись по ключу (dst[k] = src[k]), БЕЗ
// рекурсивного deep-merge вложенных map. Ровно так себя ведёт и сам
// Ansible по умолчанию (hash_behaviour=replace) — это осознанное решение
// для совместимости ожиданий, а не недосмотр.
func merge(dst, src map[string]interface{}) {
for k, v := range src {
dst[k] = v
}
}

BIN
modules.d/hello/hello-linux-amd64 Executable file

Binary file not shown.

BIN
modules.d/hello/hello-linux-arm64 Executable file

Binary file not shown.

View file

@ -0,0 +1,10 @@
{
"name": "hello",
"version": "1.0.0",
"schema_version": 1,
"architectures": ["linux/amd64", "linux/arm64"],
"checksums": {
"linux/amd64": "7555da5b213603e4c727ad2525ac3cf4a6c84bc58c2cfb6339fbf07bc13781a6",
"linux/arm64": "46d24aba72ac29ae15de1878417f3b2e676fcc1d1d89c5a1e70df3d66aa78e16"
}
}