commit 918fc531f27d67a3ebe1c4a752e31ec7e4c18247 Author: Magnus Root Date: Fri Sep 11 10:17:25 2026 +0300 Alpha diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a316424 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# binary +goherence +j2conv \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cd3e39 --- /dev/null +++ b/README.md @@ -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 фиксирует, +что из плана уже реализовано и какие упрощения приняты сознательно. diff --git a/examples/external-module-hello/build.sh b/examples/external-module-hello/build.sh new file mode 100755 index 0000000..a270975 --- /dev/null +++ b/examples/external-module-hello/build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Кросс-компилирует пример внешнего модуля hello под linux/amd64 и +# linux/arm64 и кладёт результат в modules.d/hello/ рядом с manifest.json. +# Тот же паттерн (GOOS/GOARCH + go build) применим к любому другому +# внешнему модулю — имя бинарника обязано быть --, +# это соглашение, по которому 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}" diff --git a/examples/external-module-hello/main.go b/examples/external-module-hello/main.go new file mode 100644 index 0000000..ebfb364 --- /dev/null +++ b/examples/external-module-hello/main.go @@ -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) +} diff --git a/examples/group_vars/prod.yml b/examples/group_vars/prod.yml new file mode 100644 index 0000000..643d5f6 --- /dev/null +++ b/examples/group_vars/prod.yml @@ -0,0 +1 @@ +clear_cache: true diff --git a/examples/host_vars/web1.yml b/examples/host_vars/web1.yml new file mode 100644 index 0000000..c438b04 --- /dev/null +++ b/examples/host_vars/web1.yml @@ -0,0 +1 @@ +server_name: web1.internal.example.com diff --git a/examples/host_vars/web2.yml b/examples/host_vars/web2.yml new file mode 100644 index 0000000..ba2567b --- /dev/null +++ b/examples/host_vars/web2.yml @@ -0,0 +1 @@ +server_name: web2.internal.example.com diff --git a/examples/inventory.yml b/examples/inventory.yml new file mode 100644 index 0000000..9a0fbe2 --- /dev/null +++ b/examples/inventory.yml @@ -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 diff --git a/examples/playbook.yml b/examples/playbook.yml new file mode 100644 index 0000000..b76d24f --- /dev/null +++ b/examples/playbook.yml @@ -0,0 +1,6 @@ +- name: Настройка веб-серверов + hosts: webservers + vars: + nginx_worker_connections: 1024 + roles: + - webserver diff --git a/examples/roles/webserver/defaults/main.yml b/examples/roles/webserver/defaults/main.yml new file mode 100644 index 0000000..f7bf8ab --- /dev/null +++ b/examples/roles/webserver/defaults/main.yml @@ -0,0 +1,5 @@ +nginx_worker_connections: 512 +clear_cache: false +required_packages: + - curl + - htop diff --git a/examples/roles/webserver/handlers/main.yml b/examples/roles/webserver/handlers/main.yml new file mode 100644 index 0000000..67c400a --- /dev/null +++ b/examples/roles/webserver/handlers/main.yml @@ -0,0 +1,4 @@ +- name: Restart nginx + systemd: + name: nginx + state: restarted diff --git a/examples/roles/webserver/tasks/main.yml b/examples/roles/webserver/tasks/main.yml new file mode 100644 index 0000000..e970023 --- /dev/null +++ b/examples/roles/webserver/tasks/main.yml @@ -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 }}" diff --git a/examples/roles/webserver/templates/nginx.conf.tmpl b/examples/roles/webserver/templates/nginx.conf.tmpl new file mode 100644 index 0000000..7161b7a --- /dev/null +++ b/examples/roles/webserver/templates/nginx.conf.tmpl @@ -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 "_" }}; + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8cb7c17 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7ea4959 --- /dev/null +++ b/go.sum @@ -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= diff --git a/goherence-architecture.md b/goherence-architecture.md new file mode 100644 index 0000000..e43a547 --- /dev/null +++ b/goherence-architecture.md @@ -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/ (родительские группы раньше дочерних) +4. inventory: host_vars/ +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/.yml` или `group_vars//*.yml` (директория — +для разбиения на несколько файлов) рядом с инвентарём. Стоит повторить оба варианта: + +```go +func (inv *Inventory) GroupVars(group string) map[string]interface{} { + // 1. group_vars/.yml — один файл + // 2. group_vars//*.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**: скрипт для массовой миграции существующих шаблонов + прогон тестов на них. + +Пункты 1–4 — это уже рабочий MVP для внутреннего использования. +Пункты 5–6 нужны только когда появится первый реальный сторонний модуль. diff --git a/goherence.yml b/goherence.yml new file mode 100644 index 0000000..634ba31 --- /dev/null +++ b/goherence.yml @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e5600ff --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/executor/condition.go b/internal/executor/condition.go new file mode 100644 index 0000000..c4097e2 --- /dev/null +++ b/internal/executor/condition.go @@ -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 +} diff --git a/internal/executor/condition_test.go b/internal/executor/condition_test.go new file mode 100644 index 0000000..e46b2b1 --- /dev/null +++ b/internal/executor/condition_test.go @@ -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) + } + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..8cca6cb --- /dev/null +++ b/internal/executor/executor.go @@ -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 +} diff --git a/internal/executor/graph.go b/internal/executor/graph.go new file mode 100644 index 0000000..5aea266 --- /dev/null +++ b/internal/executor/graph.go @@ -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, ", ") +} diff --git a/internal/executor/graph_test.go b/internal/executor/graph_test.go new file mode 100644 index 0000000..f72e840 --- /dev/null +++ b/internal/executor/graph_test.go @@ -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 +} diff --git a/internal/facts/facts.go b/internal/facts/facts.go new file mode 100644 index 0000000..8edfbac --- /dev/null +++ b/internal/facts/facts.go @@ -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" + } +} diff --git a/internal/lint/lint.go b/internal/lint/lint.go new file mode 100644 index 0000000..caefa86 --- /dev/null +++ b/internal/lint/lint.go @@ -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 +} diff --git a/internal/lint/rules_complexity.go b/internal/lint/rules_complexity.go new file mode 100644 index 0000000..dafb236 --- /dev/null +++ b/internal/lint/rules_complexity.go @@ -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 +} diff --git a/internal/lint/rules_include.go b/internal/lint/rules_include.go new file mode 100644 index 0000000..e728a9c --- /dev/null +++ b/internal/lint/rules_include.go @@ -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 +} diff --git a/internal/lint/rules_setfact.go b/internal/lint/rules_setfact.go new file mode 100644 index 0000000..cd4cdf7 --- /dev/null +++ b/internal/lint/rules_setfact.go @@ -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) +} diff --git a/internal/lint/rules_shell.go b/internal/lint/rules_shell.go new file mode 100644 index 0000000..fd6063c --- /dev/null +++ b/internal/lint/rules_shell.go @@ -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 +} diff --git a/internal/lint/rules_when.go b/internal/lint/rules_when.go new file mode 100644 index 0000000..969845b --- /dev/null +++ b/internal/lint/rules_when.go @@ -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 +} diff --git a/internal/module/builtin/apt.go b/internal/module/builtin/apt.go new file mode 100644 index 0000000..1774021 --- /dev/null +++ b/internal/module/builtin/apt.go @@ -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, "'", `'\''`) + "'" +} diff --git a/internal/module/builtin/copy.go b/internal/module/builtin/copy.go new file mode 100644 index 0000000..7d11cfd --- /dev/null +++ b/internal/module/builtin/copy.go @@ -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 +} diff --git a/internal/module/builtin/file.go b/internal/module/builtin/file.go new file mode 100644 index 0000000..f1dab51 --- /dev/null +++ b/internal/module/builtin/file.go @@ -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") +} diff --git a/internal/module/builtin/package.go b/internal/module/builtin/package.go new file mode 100644 index 0000000..978d160 --- /dev/null +++ b/internal/module/builtin/package.go @@ -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") +} diff --git a/internal/module/builtin/register.go b/internal/module/builtin/register.go new file mode 100644 index 0000000..63476a4 --- /dev/null +++ b/internal/module/builtin/register.go @@ -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}) +} diff --git a/internal/module/builtin/rpm.go b/internal/module/builtin/rpm.go new file mode 100644 index 0000000..82ce4a6 --- /dev/null +++ b/internal/module/builtin/rpm.go @@ -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 на хосте") +} diff --git a/internal/module/builtin/shell.go b/internal/module/builtin/shell.go new file mode 100644 index 0000000..9d40e0d --- /dev/null +++ b/internal/module/builtin/shell.go @@ -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" } diff --git a/internal/module/builtin/systemd.go b/internal/module/builtin/systemd.go new file mode 100644 index 0000000..5ee1ee2 --- /dev/null +++ b/internal/module/builtin/systemd.go @@ -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 +} diff --git a/internal/module/builtin/template.go b/internal/module/builtin/template.go new file mode 100644 index 0000000..b35542c --- /dev/null +++ b/internal/module/builtin/template.go @@ -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 +} diff --git a/internal/module/external/deploy.go b/internal/module/external/deploy.go new file mode 100644 index 0000000..9c0ac1f --- /dev/null +++ b/internal/module/external/deploy.go @@ -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, "'", `'\''`) + "'" +} diff --git a/internal/module/external/loader.go b/internal/module/external/loader.go new file mode 100644 index 0000000..b6eb5d5 --- /dev/null +++ b/internal/module/external/loader.go @@ -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 +} diff --git a/internal/module/external/manifest.go b/internal/module/external/manifest.go new file mode 100644 index 0000000..d4d8717 --- /dev/null +++ b/internal/module/external/manifest.go @@ -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//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) бинарнику модуля для +// конкретной архитектуры. Соглашение об именовании: /--, +// например 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 +} diff --git a/internal/module/external/module.go b/internal/module/external/module.go new file mode 100644 index 0000000..3f38186 --- /dev/null +++ b/internal/module/external/module.go @@ -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 +} diff --git a/internal/module/module.go b/internal/module/module.go new file mode 100644 index 0000000..87651e0 --- /dev/null +++ b/internal/module/module.go @@ -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 +} diff --git a/internal/parser/inventory.go b/internal/parser/inventory.go new file mode 100644 index 0000000..7f147b6 --- /dev/null +++ b/internal/parser/inventory.go @@ -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/.yml и group_vars//*.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/.yml или host_vars//*.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 грузит либо /.yml, либо все файлы +// в директории //*.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 +} diff --git a/internal/parser/playbook.go b/internal/parser/playbook.go new file mode 100644 index 0000000..ccb3c0e --- /dev/null +++ b/internal/parser/playbook.go @@ -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 +} diff --git a/internal/parser/types.go b/internal/parser/types.go new file mode 100644 index 0000000..9dcabe0 --- /dev/null +++ b/internal/parser/types.go @@ -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 +} diff --git a/internal/ssh/client.go b/internal/ssh/client.go new file mode 100644 index 0000000..7d2fe16 --- /dev/null +++ b/internal/ssh/client.go @@ -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(`'\''`))) + "'" +} diff --git a/internal/template/custom.go b/internal/template/custom.go new file mode 100644 index 0000000..c367f85 --- /dev/null +++ b/internal/template/custom.go @@ -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) { ... }, + } +} diff --git a/internal/template/render.go b/internal/template/render.go new file mode 100644 index 0000000..7745fb8 --- /dev/null +++ b/internal/template/render.go @@ -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 +} diff --git a/internal/vars/merge.go b/internal/vars/merge.go new file mode 100644 index 0000000..f8239dc --- /dev/null +++ b/internal/vars/merge.go @@ -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/, родители раньше потомков + } + merge(result, inv.HostVars(host)) // 4. host_vars/ + 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 + } +} diff --git a/modules.d/hello/hello-linux-amd64 b/modules.d/hello/hello-linux-amd64 new file mode 100755 index 0000000..827df8e Binary files /dev/null and b/modules.d/hello/hello-linux-amd64 differ diff --git a/modules.d/hello/hello-linux-arm64 b/modules.d/hello/hello-linux-arm64 new file mode 100755 index 0000000..cf1e3ab Binary files /dev/null and b/modules.d/hello/hello-linux-arm64 differ diff --git a/modules.d/hello/manifest.json b/modules.d/hello/manifest.json new file mode 100644 index 0000000..2d34bf1 --- /dev/null +++ b/modules.d/hello/manifest.json @@ -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" + } +}