Alpha
This commit is contained in:
commit
918fc531f2
54 changed files with 4475 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# binary
|
||||||
|
goherence
|
||||||
|
j2conv
|
||||||
181
README.md
Normal file
181
README.md
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# Goherence
|
||||||
|
|
||||||
|
Замена Ansible на Go: без Python-зависимостей, с guardrails против
|
||||||
|
превращения плейбуков в императивный код.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
Реализованы все пункты плана из `goherence-architecture.md`, плюс параллелизм
|
||||||
|
и конфигурация, добавленные по ходу реального использования:
|
||||||
|
|
||||||
|
- Парсинг плейбуков, инвентаря (с вложенными группами и group_vars/host_vars),
|
||||||
|
ролей.
|
||||||
|
- Мердж переменных по 8-уровневой цепочке приоритета.
|
||||||
|
- SSH-транспорт (ключ/пароль, exec, запись файлов, RunWithInput для JSON-обмена,
|
||||||
|
`~` в пути к ключу разворачивается автоматически).
|
||||||
|
- **Builtin-модули**: `shell`, `command`, `copy`, `template` (text/template + sprig),
|
||||||
|
`apt`, `rpm_package`, `file`, `systemd`, **`package`** (сам выбирает
|
||||||
|
`apt`/`rpm_package` по `os_family`, либо автоопределяет провайдера на
|
||||||
|
хосте, если факты не собирались).
|
||||||
|
- `loop` — таск выполняется по одному разу на каждый элемент списка.
|
||||||
|
- Guardrails-линтер (`internal/lint`): чистота `when`, запрет "тихого" shell,
|
||||||
|
запрет self-referencing `set_fact`, запрет динамических `include_tasks`,
|
||||||
|
предупреждение о сложности роли.
|
||||||
|
- External-модули по JSON stdin/stdout контракту (`internal/module/external`),
|
||||||
|
с доставкой на хост под `$HOME/.goherence/modules` (без требования root)
|
||||||
|
и кэшем по версии.
|
||||||
|
- Конвертер Jinja2 → text/template+sprig (`cmd/j2conv`).
|
||||||
|
- **Сбор минимальных фактов** (`internal/facts`): `os_family`,
|
||||||
|
`distribution`, `system` — включается/выключается через
|
||||||
|
`gather_facts` в конфиге или `--gather-facts` в CLI.
|
||||||
|
- **Хэндлеры** (`notify`/`handlers/main.yml`) — выполняются один раз в конце
|
||||||
|
play, только если хотя бы один уведомивший таск реально изменил состояние.
|
||||||
|
- **Граф зависимостей тасков** (`before`/`after`, `internal/executor/graph.go`):
|
||||||
|
стабильная топологическая сортировка — без объявленных зависимостей
|
||||||
|
порядок в точности как в файле, с ними переставляются только реально
|
||||||
|
связанные таски. Аналог `require`/`before` в Puppet. Юнит-тесты —
|
||||||
|
`graph_test.go`.
|
||||||
|
- **Файл конфигурации** (`internal/config`, пример — `goherence.yml`):
|
||||||
|
`forks`, `serial`, `max_fails`, `become`, `gather_facts`, `timeout`,
|
||||||
|
ssh-дефолты, `modules_dir`. Приоритет: built-in default → `goherence.yml`
|
||||||
|
→ явный флаг CLI.
|
||||||
|
- **Rolling-обновление с порогом ошибок** (`serial`/`max_fails`): хосты
|
||||||
|
обрабатываются батчами по `serial` (1 — классическое "один хост за
|
||||||
|
раз"), и если упавших хостов набирается `max_fails`, оставшиеся батчи
|
||||||
|
не запускаются — то, что уже обновилось, не откатывается.
|
||||||
|
- **Параллелизм по хостам** (`internal/executor`, goroutine на хост,
|
||||||
|
ограничено `forks` через буферизованный семафор). Подтверждено реальным
|
||||||
|
замером: 3 хоста × 2-секундный таск — 8 сек при `forks=1`, 3 сек при
|
||||||
|
`forks=3`. Вывод сериализован мьютексом, `go test -race` и `go build -race`
|
||||||
|
чисты.
|
||||||
|
|
||||||
|
## Известные упрощения первой версии
|
||||||
|
|
||||||
|
- `TemplateModule.Vars` пересчитывается один раз на весь набор тасков
|
||||||
|
play/роли, а не per-task.
|
||||||
|
- `command` реализован идентично `shell`.
|
||||||
|
- `--check` даёт содержательный diff только для `copy`/`template`.
|
||||||
|
- Ретеншн-политики для старых версий доставленных модулей нет.
|
||||||
|
- `j2conv` — набор regex-правил, не полноценный парсер Jinja2.
|
||||||
|
- Хэндлеры используют переменные play/host/facts, но не role.Defaults/Vars
|
||||||
|
той роли, где объявлены — общий пул хэндлеров на весь play.
|
||||||
|
- `become` — только глобальный переключатель на весь прогон, без per-task
|
||||||
|
или per-play override.
|
||||||
|
- При параллельном запуске (`forks > 1`) первая ошибка на любом хосте
|
||||||
|
останавливает запуск новых хостов, но уже идущие доводятся до конца;
|
||||||
|
ошибки с разных хостов не агрегируются в единый multi-error — возвращается
|
||||||
|
только первая.
|
||||||
|
|
||||||
|
## Баги, найденные и исправленные при реальном интеграционном тестировании
|
||||||
|
|
||||||
|
Всё ниже — не гипотетические соображения, а то, что реально всплыло при
|
||||||
|
прогоне против настоящего `sshd` (локальный сервер, `ssh-keygen`, тестовый
|
||||||
|
пользователь без root):
|
||||||
|
|
||||||
|
1. **Порядок аргументов CLI** — флаги должны идти до пути к плейбуку
|
||||||
|
(ограничение пакета `flag`, не решение архитектуры).
|
||||||
|
2. **Адрес подключения игнорировался** — SSH шло по имени хоста
|
||||||
|
из инвентаря вместо `goherence_host` из его переменных.
|
||||||
|
3. **`ssh.Conn.WriteFile` возвращал сырой `EOF`** вместо содержательной
|
||||||
|
ошибки при падении удалённой команды.
|
||||||
|
4. **`internal/module/external`: путь для модулей требовал root**
|
||||||
|
(`/opt/goherence/modules`) — теперь резолвится через `$HOME`
|
||||||
|
подключившегося пользователя.
|
||||||
|
5. **`when`-сравнения не резолвили переменные** — `os_family == "Debian"`
|
||||||
|
был всегда `false`, потому что сравнивалось имя переменной как
|
||||||
|
строковый литерал, а не её значение. Есть юнит-тест на 13 разных
|
||||||
|
`when`-выражений (`internal/executor/condition_test.go`).
|
||||||
|
6. **`~` в пути к SSH-ключу не разворачивался** — `os.ReadFile("~/...")`
|
||||||
|
в Go не понимает `~` сам по себе.
|
||||||
|
|
||||||
|
## Переменные подключения
|
||||||
|
|
||||||
|
Адрес и пользователь для SSH могут переопределяться per-host/per-group
|
||||||
|
переменными в инвентаре (аналог `ansible_host`/`ansible_user`, но без
|
||||||
|
чужого нейминга):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
all:
|
||||||
|
vars:
|
||||||
|
goherence_user: deploy # дефолтный пользователь для всех хостов
|
||||||
|
children:
|
||||||
|
webservers:
|
||||||
|
hosts:
|
||||||
|
web1:
|
||||||
|
goherence_host: 10.0.0.1 # реальный адрес, если он не совпадает с именем хоста
|
||||||
|
```
|
||||||
|
|
||||||
|
Приоритет обычный инвентарный: `host_vars` > группа хоста > `all`. Флаг
|
||||||
|
CLI `-u`/`--key`/`--port` действует, если ни то, ни другое не задано.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
`goherence.yml` в текущей директории подхватывается автоматически (или
|
||||||
|
явно через `-c path/to/config.yml`). Все поля опциональны:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
forks: 5 # сколько хостов обрабатывать одновременно
|
||||||
|
become: false # глобально sudo для всех команд
|
||||||
|
gather_facts: true # собирать os_family и т.п.
|
||||||
|
timeout: 10s # таймаут SSH-подключения
|
||||||
|
ssh_user: deploy
|
||||||
|
ssh_key_path: ~/.ssh/id_ed25519
|
||||||
|
ssh_port: 22
|
||||||
|
modules_dir: modules.d
|
||||||
|
```
|
||||||
|
|
||||||
|
Приоритет: built-in default → `goherence.yml` → явный флаг CLI (`--forks`,
|
||||||
|
`--become`, `--gather-facts`, `-u`, `--key`, `--port`, `--modules-dir`).
|
||||||
|
|
||||||
|
## Запуск примера
|
||||||
|
|
||||||
|
Флаги — первыми, путь к плейбуку — последним аргументом (это ограничение
|
||||||
|
самого пакета `flag` в стандартной библиотеке Go, не наша прихоть):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# основной плейбук
|
||||||
|
./goherence -i examples/inventory.yml -u deploy --check examples/playbook.yml
|
||||||
|
|
||||||
|
# с конфигом, параллелизмом и без сбора фактов
|
||||||
|
./goherence -c goherence.yml -i examples/inventory.yml --forks 10 \
|
||||||
|
--gather-facts=false examples/playbook.yml
|
||||||
|
|
||||||
|
# с внешним модулем hello (см. modules.d/hello/, уже собран для linux/amd64+arm64)
|
||||||
|
./goherence -i examples/inventory.yml -u deploy --modules-dir modules.d --check \
|
||||||
|
examples/playbook.yml
|
||||||
|
|
||||||
|
# массовая конвертация существующих .j2 в .tmpl
|
||||||
|
go run ./cmd/j2conv path/to/existing/templates/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Кросс-дистрибутивный сценарий (Ubuntu + RHEL в одном инвентаре)
|
||||||
|
|
||||||
|
Роль `examples/roles/webserver` — рабочий пример именно такого сценария:
|
||||||
|
установить nginx через модуль `package` (сам выбирает `apt` или
|
||||||
|
`rpm_package`/`dnf` по `os_family`, а если `gather_facts: false` —
|
||||||
|
автоопределяет прямо на хосте по наличию пакетного менеджера, см.
|
||||||
|
`internal/module/builtin/package.go`), сразу выключить и снять с
|
||||||
|
автозагрузки, разложить конфиг из шаблона с per-host данными
|
||||||
|
(`examples/host_vars/web1.yml`, `web2.yml` — разные `server_name` на
|
||||||
|
каждом хосте), включить и запустить. Изменение конфига триггерит хэндлер
|
||||||
|
`Restart nginx` (`examples/roles/webserver/handlers/main.yml`), который
|
||||||
|
прогоняется один раз в конце play.
|
||||||
|
|
||||||
|
Добавь оба тестовых хоста (Ubuntu и RHEL) в `examples/inventory.yml` под
|
||||||
|
`webservers`, и один и тот же плейбук отработает на обоих без единого
|
||||||
|
`when` для выбора пакетного менеджера — это и есть весь смысл модуля
|
||||||
|
`package`.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod tidy # подтянет sprig, x/crypto, yaml.v3
|
||||||
|
go build -o goherence ./cmd/goherence
|
||||||
|
go build -o j2conv ./cmd/j2conv
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
См. `goherence-architecture.md` — карта проекта; этот README фиксирует,
|
||||||
|
что из плана уже реализовано и какие упрощения приняты сознательно.
|
||||||
23
examples/external-module-hello/build.sh
Executable file
23
examples/external-module-hello/build.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Кросс-компилирует пример внешнего модуля hello под linux/amd64 и
|
||||||
|
# linux/arm64 и кладёт результат в modules.d/hello/ рядом с manifest.json.
|
||||||
|
# Тот же паттерн (GOOS/GOARCH + go build) применим к любому другому
|
||||||
|
# внешнему модулю — имя бинарника обязано быть <name>-<goos>-<goarch>,
|
||||||
|
# это соглашение, по которому internal/module/external его находит.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODULE_NAME="hello"
|
||||||
|
SRC_DIR="$(dirname "$0")"
|
||||||
|
OUT_DIR="$(dirname "$0")/../../modules.d/${MODULE_NAME}"
|
||||||
|
|
||||||
|
mkdir -p "${OUT_DIR}"
|
||||||
|
|
||||||
|
for target in "linux/amd64" "linux/arm64"; do
|
||||||
|
goos="${target%/*}"
|
||||||
|
goarch="${target#*/}"
|
||||||
|
out="${OUT_DIR}/${MODULE_NAME}-${goos}-${goarch}"
|
||||||
|
echo "сборка ${out}..."
|
||||||
|
GOOS="${goos}" GOARCH="${goarch}" go build -o "${out}" "${SRC_DIR}/main.go"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "готово: ${OUT_DIR}"
|
||||||
51
examples/external-module-hello/main.go
Normal file
51
examples/external-module-hello/main.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Пример внешнего модуля для goherence — демонстрирует контракт
|
||||||
|
// stdin/stdout JSON, тот же, что использует и сам goherence для builtin.
|
||||||
|
// Собери его через build.sh и положи результат в modules.d/hello/
|
||||||
|
// рядом с manifest.json — goherence подхватит его как модуль `hello`.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input и Result дублируют контракт из internal/module/module.go —
|
||||||
|
// внешний модуль не импортирует goherence как зависимость (он может
|
||||||
|
// быть написан вообще не на Go), поэтому просто повторяет форму JSON.
|
||||||
|
type Input struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Args map[string]interface{} `json:"args"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Become bool `json:"become"`
|
||||||
|
CheckMode bool `json:"check_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Changed bool `json:"changed"`
|
||||||
|
Failed bool `json:"failed"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Diff interface{} `json:"diff,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var in Input
|
||||||
|
if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "hello: невалидный input JSON:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, _ := in.Args["name"].(string)
|
||||||
|
if name == "" {
|
||||||
|
name = "world"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Result{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Changed: false, // модуль ничего не меняет на хосте — просто демонстрационный
|
||||||
|
Msg: fmt.Sprintf("hello, %s (host=%s, check_mode=%v)", name, in.Host, in.CheckMode),
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(os.Stdout).Encode(result)
|
||||||
|
}
|
||||||
1
examples/group_vars/prod.yml
Normal file
1
examples/group_vars/prod.yml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
clear_cache: true
|
||||||
1
examples/host_vars/web1.yml
Normal file
1
examples/host_vars/web1.yml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
server_name: web1.internal.example.com
|
||||||
1
examples/host_vars/web2.yml
Normal file
1
examples/host_vars/web2.yml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
server_name: web2.internal.example.com
|
||||||
17
examples/inventory.yml
Normal file
17
examples/inventory.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
all:
|
||||||
|
vars:
|
||||||
|
goherence_user: deploy
|
||||||
|
children:
|
||||||
|
webservers:
|
||||||
|
hosts:
|
||||||
|
web1:
|
||||||
|
goherence_host: 10.0.0.1
|
||||||
|
web2:
|
||||||
|
goherence_host: 10.0.0.2
|
||||||
|
vars:
|
||||||
|
http_port: 80
|
||||||
|
prod:
|
||||||
|
children:
|
||||||
|
webservers: {}
|
||||||
|
vars:
|
||||||
|
env: production
|
||||||
6
examples/playbook.yml
Normal file
6
examples/playbook.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
- name: Настройка веб-серверов
|
||||||
|
hosts: webservers
|
||||||
|
vars:
|
||||||
|
nginx_worker_connections: 1024
|
||||||
|
roles:
|
||||||
|
- webserver
|
||||||
5
examples/roles/webserver/defaults/main.yml
Normal file
5
examples/roles/webserver/defaults/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
nginx_worker_connections: 512
|
||||||
|
clear_cache: false
|
||||||
|
required_packages:
|
||||||
|
- curl
|
||||||
|
- htop
|
||||||
4
examples/roles/webserver/handlers/main.yml
Normal file
4
examples/roles/webserver/handlers/main.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
- name: Restart nginx
|
||||||
|
systemd:
|
||||||
|
name: nginx
|
||||||
|
state: restarted
|
||||||
39
examples/roles/webserver/tasks/main.yml
Normal file
39
examples/roles/webserver/tasks/main.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
- name: Установить nginx (сам выберет apt/dnf по дистрибутиву)
|
||||||
|
package:
|
||||||
|
name: nginx
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Убедиться, что nginx выключен сразу после установки
|
||||||
|
systemd:
|
||||||
|
name: nginx
|
||||||
|
state: stopped
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
- name: Уложить конфиг nginx из шаблона (per-host/group данные)
|
||||||
|
template:
|
||||||
|
src: examples/roles/webserver/templates/nginx.conf.tmpl
|
||||||
|
dest: /etc/nginx/nginx.conf
|
||||||
|
mode: "0644"
|
||||||
|
notify:
|
||||||
|
- Restart nginx
|
||||||
|
|
||||||
|
- name: Установить пакеты через loop
|
||||||
|
package:
|
||||||
|
name: "{{ .item }}"
|
||||||
|
state: present
|
||||||
|
loop: "{{ .required_packages }}"
|
||||||
|
|
||||||
|
- name: Разово почистить кэш при явном флаге (пример allow_shell)
|
||||||
|
shell: "rm -rf /var/cache/nginx/*"
|
||||||
|
allow_shell: true
|
||||||
|
when: clear_cache == true
|
||||||
|
|
||||||
|
- name: Включить и запустить nginx
|
||||||
|
systemd:
|
||||||
|
name: nginx
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Пример вызова внешнего модуля (см. modules.d/hello)
|
||||||
|
hello:
|
||||||
|
name: "{{ .goherence_user }}"
|
||||||
12
examples/roles/webserver/templates/nginx.conf.tmpl
Normal file
12
examples/roles/webserver/templates/nginx.conf.tmpl
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections {{ .nginx_worker_connections | default 1024 }};
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
server {
|
||||||
|
listen {{ .http_port | default 80 }};
|
||||||
|
server_name {{ .server_name | default "_" }};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
go.mod
Normal file
9
go.mod
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
module github.com/vladimir/goherence
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Masterminds/sprig/v3 v3.3.0
|
||||||
|
golang.org/x/crypto v0.31.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
48
go.sum
Normal file
48
go.sum
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||||
|
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||||
|
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
|
||||||
|
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||||
|
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||||
|
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
616
goherence-architecture.md
Normal file
616
goherence-architecture.md
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
# Goherence — черновая архитектура
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├── cmd/
|
||||||
|
│ └── goherence/
|
||||||
|
│ └── main.go # CLI-обвязка (флаги, вывод прогресса)
|
||||||
|
│
|
||||||
|
├── internal/
|
||||||
|
│ ├── parser/ # YAML → структуры плейбука
|
||||||
|
│ │ ├── playbook.go # Playbook, Play, Task
|
||||||
|
│ │ ├── inventory.go # Inventory, Host, Group
|
||||||
|
│ │ └── role.go # загрузка ролей по директорийной конвенции
|
||||||
|
│ │
|
||||||
|
│ ├── executor/ # движок исполнения
|
||||||
|
│ │ ├── executor.go # оркестрация: хосты × таски
|
||||||
|
│ │ ├── vars.go # мердж переменных (defaults < vars < extra-vars < host-vars)
|
||||||
|
│ │ ├── condition.go # обработка `when`
|
||||||
|
│ │ └── loop.go # аналог `loop`/`with_items`
|
||||||
|
│ │
|
||||||
|
│ ├── template/
|
||||||
|
│ │ └── render.go # text/template + sprig.FuncMap()
|
||||||
|
│ │
|
||||||
|
│ ├── ssh/
|
||||||
|
│ │ ├── client.go # golang.org/x/crypto/ssh обвязка
|
||||||
|
│ │ ├── sftp.go # копирование файлов/модулей на хост
|
||||||
|
│ │ └── become.go # sudo/become
|
||||||
|
│ │
|
||||||
|
│ ├── module/
|
||||||
|
│ │ ├── module.go # интерфейс Module + Registry
|
||||||
|
│ │ ├── builtin/ # встроенные модули (нативный Go, без subprocess)
|
||||||
|
│ │ │ ├── shell.go
|
||||||
|
│ │ │ ├── copy.go
|
||||||
|
│ │ │ ├── template.go
|
||||||
|
│ │ │ ├── apt.go
|
||||||
|
│ │ │ └── rpm.go
|
||||||
|
│ │ └── external/
|
||||||
|
│ │ ├── loader.go # чтение manifest.json, поиск бинарников
|
||||||
|
│ │ ├── deploy.go # доставка модуля на хост + кэш по версии/хешу
|
||||||
|
│ │ └── protocol.go # (де)сериализация JSON stdin/stdout
|
||||||
|
│ │
|
||||||
|
│ └── manifest/
|
||||||
|
│ └── manifest.go # схема manifest.json внешних модулей
|
||||||
|
│
|
||||||
|
├── modules.d/ # внешние модули (по одному на директорию)
|
||||||
|
│ └── mymodule/
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ ├── mymodule-linux-amd64
|
||||||
|
│ └── mymodule-linux-arm64
|
||||||
|
│
|
||||||
|
└── examples/
|
||||||
|
├── inventory.yml
|
||||||
|
├── playbook.yml
|
||||||
|
└── roles/
|
||||||
|
└── webserver/
|
||||||
|
├── tasks/main.yml
|
||||||
|
├── templates/nginx.conf.tmpl
|
||||||
|
├── defaults/main.yml
|
||||||
|
└── handlers/main.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ключевые интерфейсы
|
||||||
|
|
||||||
|
### Module (внутренний контракт, единый для builtin и external)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package module
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Args map[string]interface{} `json:"args"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Become bool `json:"become"`
|
||||||
|
CheckMode bool `json:"check_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Changed bool `json:"changed"`
|
||||||
|
Failed bool `json:"failed"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Diff interface{} `json:"diff,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module — общий интерфейс, которому соответствуют
|
||||||
|
// и builtin-функции, и обёртка над внешним subprocess-модулем.
|
||||||
|
type Module interface {
|
||||||
|
Name() string
|
||||||
|
Run(ctx context.Context, in Input, conn ssh.Conn) (Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry — реестр, из которого executor достаёт модуль по имени.
|
||||||
|
type Registry struct {
|
||||||
|
builtin map[string]Module
|
||||||
|
external map[string]*ExternalModule // лениво резолвится через manifest.json
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Resolve(name string) (Module, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ExternalModule (subprocess по JSON-контракту)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package module
|
||||||
|
|
||||||
|
type ExternalManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Architectures []string `json:"architectures"` // "linux/amd64", "linux/arm64"
|
||||||
|
Checksums map[string]string `json:"checksums"` // arch → sha256
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalModule struct {
|
||||||
|
Manifest ExternalManifest
|
||||||
|
BinPath map[string]string // arch → путь к локальному бинарнику на control node
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run — доставляет модуль на хост (если его там ещё нет/версия устарела),
|
||||||
|
// затем исполняет через stdin/stdout JSON.
|
||||||
|
func (m *ExternalModule) Run(ctx context.Context, in module.Input, conn ssh.Conn) (module.Result, error) {
|
||||||
|
if err := ensureDeployed(ctx, conn, m); err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
remotePath := remoteModulePath(m)
|
||||||
|
return execRemoteJSON(ctx, conn, remotePath, in)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Доставка модуля на хост с кэшированием
|
||||||
|
|
||||||
|
```go
|
||||||
|
package external
|
||||||
|
|
||||||
|
// ensureDeployed проверяет, есть ли актуальная версия модуля на хосте
|
||||||
|
// (по версии+checksum из manifest), и копирует бинарник только если нужно.
|
||||||
|
func ensureDeployed(ctx context.Context, conn ssh.Conn, m *ExternalModule) error {
|
||||||
|
arch, err := detectRemoteArch(ctx, conn) // uname -m / uname -s
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remoteMarker := fmt.Sprintf("/opt/goherence/modules/%s-%s.version", m.Manifest.Name, arch)
|
||||||
|
installed, _ := readRemoteFile(ctx, conn, remoteMarker)
|
||||||
|
if string(installed) == m.Manifest.Version {
|
||||||
|
return nil // уже актуальна
|
||||||
|
}
|
||||||
|
|
||||||
|
localBin, ok := m.BinPath[arch]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no build for arch %s", arch)
|
||||||
|
}
|
||||||
|
remoteBin := remoteModulePath(m)
|
||||||
|
if err := sftpUpload(ctx, conn, localBin, remoteBin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := remoteChmodExec(ctx, conn, remoteBin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeRemoteFile(ctx, conn, remoteMarker, []byte(m.Manifest.Version))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## manifest.json внешнего модуля (пример)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "mymodule",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"schema_version": 1,
|
||||||
|
"architectures": ["linux/amd64", "linux/arm64"],
|
||||||
|
"checksums": {
|
||||||
|
"linux/amd64": "sha256:abc123...",
|
||||||
|
"linux/arm64": "sha256:def456..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаблонизация (sprig)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"text/template"
|
||||||
|
"github.com/Masterminds/sprig/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Render(name, src string, vars map[string]interface{}) (string, error) {
|
||||||
|
tmpl, err := template.New(name).Funcs(sprig.FuncMap()).Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := tmpl.Execute(&buf, vars); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Кастомные фильтры без аналога в sprig (`ansible.utils.ipaddr` и подобные) добавляются
|
||||||
|
тем же `Funcs(...)` — отдельный `FuncMap` в `internal/template/custom.go`.
|
||||||
|
|
||||||
|
## Инвентарь и приоритет переменных
|
||||||
|
|
||||||
|
### Формат инвентаря (YAML, с вложенными группами)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
all:
|
||||||
|
vars:
|
||||||
|
goherence_user: deploy
|
||||||
|
children:
|
||||||
|
webservers:
|
||||||
|
hosts:
|
||||||
|
web1:
|
||||||
|
goherence_host: 10.0.0.1
|
||||||
|
web2:
|
||||||
|
goherence_host: 10.0.0.2
|
||||||
|
vars:
|
||||||
|
http_port: 80
|
||||||
|
prod:
|
||||||
|
children:
|
||||||
|
webservers: {}
|
||||||
|
vars:
|
||||||
|
env: production
|
||||||
|
```
|
||||||
|
|
||||||
|
Группы могут входить друг в друга (`prod` включает `webservers`) — это и есть
|
||||||
|
«вложенность», которую нужно резолвить при подсчёте переменных для конкретного хоста.
|
||||||
|
|
||||||
|
### Источники переменных и порядок приоритета (от низкого к высокому)
|
||||||
|
|
||||||
|
Повторяем реальный порядок Ansible, но без экзотики (facts/vars_prompt и т.п. не нужны для MVP):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. role defaults/main.yml (самый низкий приоритет)
|
||||||
|
2. inventory: group_vars/all
|
||||||
|
3. inventory: group_vars/<group> (родительские группы раньше дочерних)
|
||||||
|
4. inventory: host_vars/<host>
|
||||||
|
5. play vars (в самом плейбуке: `vars:`)
|
||||||
|
6. role vars/main.yml
|
||||||
|
7. set_fact / registered vars (во время выполнения)
|
||||||
|
8. extra-vars (-e из CLI) (самый высокий, всегда побеждает)
|
||||||
|
```
|
||||||
|
|
||||||
|
Если хост состоит в нескольких группах на одном уровне вложенности — порядок
|
||||||
|
между ними определяется алфавитным сравнением имён групп (так делает и сам
|
||||||
|
Ansible), чтобы результат был детерминирован и не зависел от порядка в YAML.
|
||||||
|
|
||||||
|
### Резолв группы (topological order по дереву `children`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package inventory
|
||||||
|
|
||||||
|
// GroupChain возвращает группы хоста в порядке от самых общих (all)
|
||||||
|
// к самым специфичным — именно в этом порядке накатываются group_vars.
|
||||||
|
func (inv *Inventory) GroupChain(host string) []string {
|
||||||
|
groups := inv.groupsOf(host) // все группы, включая через children
|
||||||
|
sort.Slice(groups, func(i, j int) bool {
|
||||||
|
di, dj := inv.depth(groups[i]), inv.depth(groups[j])
|
||||||
|
if di != dj {
|
||||||
|
return di < dj // родители раньше потомков
|
||||||
|
}
|
||||||
|
return groups[i] < groups[j] // на одном уровне — алфавит
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func (inv *Inventory) depth(group string) int {
|
||||||
|
// "all" = 0, каждый уровень children = +1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Мердж переменных
|
||||||
|
|
||||||
|
```go
|
||||||
|
package vars
|
||||||
|
|
||||||
|
// Resolve строит финальный набор переменных для (хост, роль, play)
|
||||||
|
// последовательно накатывая слои в порядке приоритета.
|
||||||
|
func Resolve(host string, inv *inventory.Inventory, play *parser.Play,
|
||||||
|
role *parser.Role, extraVars map[string]interface{}) map[string]interface{} {
|
||||||
|
|
||||||
|
result := map[string]interface{}{}
|
||||||
|
merge(result, role.Defaults)
|
||||||
|
merge(result, inv.GroupVars("all"))
|
||||||
|
for _, g := range inv.GroupChain(host) {
|
||||||
|
merge(result, inv.GroupVars(g))
|
||||||
|
}
|
||||||
|
merge(result, inv.HostVars(host))
|
||||||
|
merge(result, play.Vars)
|
||||||
|
merge(result, role.Vars)
|
||||||
|
merge(result, extraVars)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge — плоская перезапись по ключу (dst[k] = src[k]).
|
||||||
|
// Для словарей внутри переменных (аналог Ansible hash_behaviour=merge)
|
||||||
|
// понадобится рекурсивный merge — но по умолчанию в самом Ansible
|
||||||
|
// это replace, а не deep-merge, так что для совместимости лучше
|
||||||
|
// тоже начать с replace и добавить deep-merge отдельным явным флагом.
|
||||||
|
func merge(dst, src map[string]interface{}) {
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Загрузка group_vars/host_vars с диска
|
||||||
|
|
||||||
|
Ansible ищет `group_vars/<name>.yml` или `group_vars/<name>/*.yml` (директория —
|
||||||
|
для разбиения на несколько файлов) рядом с инвентарём. Стоит повторить оба варианта:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (inv *Inventory) GroupVars(group string) map[string]interface{} {
|
||||||
|
// 1. group_vars/<group>.yml — один файл
|
||||||
|
// 2. group_vars/<group>/*.yml — директория, файлы грузятся по алфавиту
|
||||||
|
// и мерджатся между собой в этом порядке
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guardrails против императивного использования (`internal/lint/`)
|
||||||
|
|
||||||
|
В духе Go — никакой отдельной "опциональной" линтер-утилиты, никакого `--skip-lint`
|
||||||
|
по умолчанию. Проверки встроены в сам `executor` и срабатывают **на этапе парсинга**,
|
||||||
|
до подключения к хостам. Философия: то, что нельзя выразить декларативно —
|
||||||
|
не должно молча компилироваться, а должно упасть с понятной ошибкой.
|
||||||
|
|
||||||
|
### Структура пакета
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/lint/
|
||||||
|
├── lint.go # Linter, Rule, Violation — общий контракт
|
||||||
|
├── rules_when.go # ограничение грамматики `when`
|
||||||
|
├── rules_shell.go # детект shell/command вместо декларативных модулей
|
||||||
|
├── rules_setfact.go # детект self-referencing set_fact (эмуляция состояния)
|
||||||
|
├── rules_include.go # запрет динамических include_tasks
|
||||||
|
├── rules_complexity.go # цикломатическая сложность роли/плея
|
||||||
|
└── config.go # пороги и allow-листы (без внешнего конфиг-файла — только Go-структура)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Общий контракт
|
||||||
|
|
||||||
|
```go
|
||||||
|
package lint
|
||||||
|
|
||||||
|
type Severity int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Error Severity = iota // блокирует запуск, требует --force
|
||||||
|
Warning // печатается, но не блокирует
|
||||||
|
)
|
||||||
|
|
||||||
|
type Violation struct {
|
||||||
|
Rule string
|
||||||
|
Severity Severity
|
||||||
|
Location string // "playbook.yml:role webserver:task 3"
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule — единственный интерфейс, которому подчиняются все проверки.
|
||||||
|
// Никакой плагинной системы для правил: правила — часть бинарника,
|
||||||
|
// расширяются через PR, а не через рантайм-загрузку (в отличие от
|
||||||
|
// внешних модулей, которые расширяемы намеренно).
|
||||||
|
type Rule interface {
|
||||||
|
Name() string
|
||||||
|
Check(pb *parser.Playbook) []Violation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linter — фиксированный, неконфигурируемый снаружи список правил.
|
||||||
|
type Linter struct {
|
||||||
|
rules []Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Linter {
|
||||||
|
return &Linter{rules: []Rule{
|
||||||
|
&WhenGrammarRule{},
|
||||||
|
&ShellUsageRule{},
|
||||||
|
&SetFactStateRule{},
|
||||||
|
&DynamicIncludeRule{},
|
||||||
|
&ComplexityRule{MaxNesting: 3, MaxBranches: 8},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Linter) Run(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
for _, r := range l.rules {
|
||||||
|
out = append(out, r.Check(pb)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Правило 1 — грамматика `when` (allow-list, не Jinja2/произвольные выражения)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package lint
|
||||||
|
|
||||||
|
// WhenGrammarRule разрешает в `when` только:
|
||||||
|
// сравнения: ==, !=, <, >, <=, >=
|
||||||
|
// логику: and, or, not
|
||||||
|
// обращение к переменным/фактам (без вызова функций с побочным эффектом)
|
||||||
|
// Всё остальное (вызовы sprig-функций вроде `now`, `randAlphaNum`,
|
||||||
|
// произвольная арифметика с накоплением) — Error.
|
||||||
|
type WhenGrammarRule struct{}
|
||||||
|
|
||||||
|
func (r *WhenGrammarRule) Name() string { return "when-grammar" }
|
||||||
|
|
||||||
|
func (r *WhenGrammarRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.When == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expr, err := parseCondition(t.When) // свой мини-парсер, не text/template
|
||||||
|
if err != nil || !expr.IsPure() {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: "when-выражение должно быть чистым предикатом " +
|
||||||
|
"(сравнения/and/or/not над переменными), без функций с побочным эффектом",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Правило 2 — использование `shell`/`command` там, где есть декларативный модуль
|
||||||
|
|
||||||
|
```go
|
||||||
|
package lint
|
||||||
|
|
||||||
|
// известные паттерны: "apt-get install" → модуль apt, "systemctl" → модуль systemd,
|
||||||
|
// "mkdir"/"cp"/"chmod" → модули file/copy. Список расширяется по мере роста builtin-модулей.
|
||||||
|
var shellToModuleHints = map[string]string{
|
||||||
|
`apt-get install`: "apt",
|
||||||
|
`apt install`: "apt",
|
||||||
|
`yum install`: "package",
|
||||||
|
`systemctl`: "systemd",
|
||||||
|
`mkdir -p`: "file (state=directory)",
|
||||||
|
`chmod`: "file (mode=...)",
|
||||||
|
`chown`: "file (owner=/group=)",
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShellUsageRule struct{}
|
||||||
|
|
||||||
|
func (r *ShellUsageRule) Name() string { return "shell-usage" }
|
||||||
|
|
||||||
|
func (r *ShellUsageRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.Module != "shell" && t.Module != "command" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !t.AllowShell { // явный флаг на таске — единственный способ обойти правило
|
||||||
|
for pattern, alt := range shellToModuleHints {
|
||||||
|
if strings.Contains(t.Args["cmd"].(string), pattern) {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"используй декларативный модуль %q вместо shell, "+
|
||||||
|
"либо явно пометь таск allow_shell: true", alt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевая деталь: `allow_shell: true` — это **явная, видимая в code review пометка**,
|
||||||
|
а не глобальный флаг запуска. Она остаётся в самом плейбуке навсегда, так что
|
||||||
|
причина обхода правила всегда на виду у следующего, кто читает файл.
|
||||||
|
|
||||||
|
### Правило 3 — `set_fact`, ссылающийся сам на себя (эмуляция состояния/счётчиков)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package lint
|
||||||
|
|
||||||
|
// SetFactStateRule ловит паттерн `set_fact: x="{{ x + 1 }}"` —
|
||||||
|
// попытку завести изменяемое состояние поверх декларативной модели.
|
||||||
|
type SetFactStateRule struct{}
|
||||||
|
|
||||||
|
func (r *SetFactStateRule) Name() string { return "set-fact-self-reference" }
|
||||||
|
|
||||||
|
func (r *SetFactStateRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.Module != "set_fact" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for varName, expr := range t.Args {
|
||||||
|
if referencesVar(expr.(string), varName) {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"set_fact: %s ссылается сам на себя — похоже на попытку "+
|
||||||
|
"завести изменяемое состояние между тасками, что "+
|
||||||
|
"нарушает идемпотентность плейбука", varName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Правило 4 — запрет динамических `include_tasks`/`import_role`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package lint
|
||||||
|
|
||||||
|
// DynamicIncludeRule требует, чтобы путь include был статической строкой,
|
||||||
|
// известной на этапе парсинга — без Jinja2-переменных внутри пути.
|
||||||
|
// Это убирает GOTO-с-параметрами как способ "программировать" плейбук.
|
||||||
|
type DynamicIncludeRule struct{}
|
||||||
|
|
||||||
|
func (r *DynamicIncludeRule) Name() string { return "static-include-only" }
|
||||||
|
|
||||||
|
func (r *DynamicIncludeRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.Module != "include_tasks" && t.Module != "import_role" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, _ := t.Args["path"].(string)
|
||||||
|
if strings.Contains(path, "{{") {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: "путь include должен быть статическим — " +
|
||||||
|
"динамический include_tasks запрещён",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Правило 5 — цикломатическая сложность роли/плея
|
||||||
|
|
||||||
|
```go
|
||||||
|
package lint
|
||||||
|
|
||||||
|
// ComplexityRule ограничивает глубину вложенности block/when
|
||||||
|
// и число условных ветвлений в одной роли — превышение обычно
|
||||||
|
// значит, что роль пытается быть программой, а не описанием состояния.
|
||||||
|
type ComplexityRule struct {
|
||||||
|
MaxNesting int
|
||||||
|
MaxBranches int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComplexityRule) Name() string { return "complexity" }
|
||||||
|
|
||||||
|
func (r *ComplexityRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
for _, role := range pb.Roles {
|
||||||
|
nesting := maxBlockNesting(role)
|
||||||
|
branches := countConditionalTasks(role)
|
||||||
|
if nesting > r.MaxNesting {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Warning,
|
||||||
|
Location: "role " + role.Name,
|
||||||
|
Message: fmt.Sprintf("вложенность block/when = %d (порог %d) — "+
|
||||||
|
"разбей роль на несколько ролей поменьше", nesting, r.MaxNesting),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if branches > r.MaxBranches {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Warning,
|
||||||
|
Location: "role " + role.Name,
|
||||||
|
Message: fmt.Sprintf("условных тасков = %d (порог %d) — "+
|
||||||
|
"похоже на процедурный код внутри плейбука", branches, r.MaxBranches),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Интеграция в executor
|
||||||
|
|
||||||
|
```go
|
||||||
|
// перед подключением к хостам — блокирующий шаг, не опциональный
|
||||||
|
violations := lint.New().Run(playbook)
|
||||||
|
hasError := false
|
||||||
|
for _, v := range violations {
|
||||||
|
printViolation(v)
|
||||||
|
if v.Severity == lint.Error {
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasError && !flags.Force {
|
||||||
|
return fmt.Errorf("плейбук нарушает guardrails декларативности; " +
|
||||||
|
"используй --force для запуска несмотря на ошибки (не рекомендуется)")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`--force` существует (иногда guardrail даёт false positive), но каждое его
|
||||||
|
применение — это осознанное, видимое в логах/CI решение, а не тихий обход.
|
||||||
|
|
||||||
|
## Порядок реализации (примерные вехи)
|
||||||
|
|
||||||
|
1. **Skeleton**: parser (playbook + inventory) → executor без модулей → SSH-клиент → `shell`-модуль. Цель: `goherence playbook.yml -i inventory.yml` реально что-то выполняет на хосте.
|
||||||
|
2. **Builtin-модули**: copy, template (sprig), apt, rpm, systemd, file, user — 8-10 самых частых.
|
||||||
|
3. **Инвентарь и переменные**: вложенные группы (`children`), group_vars/host_vars с диска, полная цепочка приоритета из раздела выше, `when`-условия, `loop` по спискам/словарям.
|
||||||
|
4. **Roles**: загрузка директорийной структуры, defaults/vars/handlers, `notify`.
|
||||||
|
5. **External module protocol**: JSON stdin/stdout контракт, Registry с резолвом builtin → external, кросс-компиляция модулей под целевые архитектуры, версионирование протокола (`schema_version`).
|
||||||
|
6. **Deploy & cache внешних модулей**: manifest.json, детект архитектуры, SFTP-доставка модулей на хосты по требованию, кэш по версии.
|
||||||
|
7. **Guardrails (`internal/lint/`)**: 5 правил выше, обязательный блокирующий проход перед подключением к хостам, `--force` как единственный явный обход.
|
||||||
|
8. **Конвертер j2 → text/template+sprig**: скрипт для массовой миграции существующих шаблонов + прогон тестов на них.
|
||||||
|
|
||||||
|
Пункты 1–4 — это уже рабочий MVP для внутреннего использования.
|
||||||
|
Пункты 5–6 нужны только когда появится первый реальный сторонний модуль.
|
||||||
37
goherence.yml
Normal file
37
goherence.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Пример конфигурации goherence (аналог ansible.cfg по назначению, но
|
||||||
|
# свой YAML-формат). Полностью опционален: любое из этих значений можно
|
||||||
|
# не указывать (тогда действует built-in дефолт, см. internal/config.Default())
|
||||||
|
# или переопределить флагом CLI — порядок приоритета:
|
||||||
|
# built-in default → этот файл → явный флаг CLI.
|
||||||
|
|
||||||
|
# сколько хостов обрабатывать одновременно внутри одного батча
|
||||||
|
forks: 5
|
||||||
|
|
||||||
|
# размер батча rolling-обновления (0 — все хосты одним батчом,
|
||||||
|
# 1 — классическое "один хост за раз")
|
||||||
|
serial: 0
|
||||||
|
|
||||||
|
# после скольких упавших хостов прервать оставшиеся батчи
|
||||||
|
# (0 — без ограничения, прогонять все батчи независимо от числа ошибок)
|
||||||
|
max_fails: 0
|
||||||
|
|
||||||
|
# глобально поднимать привилегии (sudo -n) для всех команд на всех хостах;
|
||||||
|
# per-task/per-play override в первой версии не реализован — это
|
||||||
|
# переключатель на весь прогон
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# собирать ли os_family/distribution/system перед тасками; false
|
||||||
|
# экономит одну SSH-сессию на хост, если эти факты плейбуку не нужны
|
||||||
|
gather_facts: true
|
||||||
|
|
||||||
|
# таймаут на установление SSH-соединения к одному хосту
|
||||||
|
timeout: 10s
|
||||||
|
|
||||||
|
# дефолты для SSH-подключения — используются, если не заданы флагом CLI
|
||||||
|
# и не переопределены per-host переменными goherence_host/goherence_user
|
||||||
|
ssh_user: deploy
|
||||||
|
ssh_key_path: ~/.ssh/id_ed25519
|
||||||
|
ssh_port: 22
|
||||||
|
|
||||||
|
# путь к modules.d/ с внешними модулями по умолчанию
|
||||||
|
modules_dir: modules.d
|
||||||
160
internal/config/config.go
Normal file
160
internal/config/config.go
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// Package config читает файл настроек goherence (по умолчанию
|
||||||
|
// goherence.yml в текущей директории) — аналог ansible.cfg, но с
|
||||||
|
// явными Go-типами вместо INI-секций. CLI-флаги имеют приоритет над
|
||||||
|
// файлом: Load возвращает Config с дефолтами, а cmd/goherence поверх
|
||||||
|
// него применяет флаги, которые пользователь указал явно.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config — то, что чаще всего настраивают в ansible.cfg: параллелизм,
|
||||||
|
// become, факты, таймауты, дефолтные SSH-параметры.
|
||||||
|
type Config struct {
|
||||||
|
// Forks — сколько хостов внутри одного батча обрабатывать
|
||||||
|
// одновременно (аналог ansible.cfg forks). 1 — строго последовательно.
|
||||||
|
Forks int `yaml:"forks"`
|
||||||
|
|
||||||
|
// Serial — размер батча rolling-обновления: сначала обновляется
|
||||||
|
// Serial хостов, потом (если MaxFails не превышен) следующие Serial,
|
||||||
|
// и так далее. 1 — классическое "один хост за раз". 0/не задано —
|
||||||
|
// один батч на всех сразу (Forks по-прежнему ограничивает
|
||||||
|
// параллелизм внутри этого единственного батча).
|
||||||
|
Serial int `yaml:"serial"`
|
||||||
|
|
||||||
|
// MaxFails — после скольких упавших хостов прервать оставшиеся
|
||||||
|
// батчи rolling-обновления. 0 — без ограничения (прогонять все
|
||||||
|
// батчи независимо от числа ошибок, как было по умолчанию раньше).
|
||||||
|
MaxFails int `yaml:"max_fails"`
|
||||||
|
|
||||||
|
// Become — глобально поднимать привилегии (sudo) для всех команд.
|
||||||
|
// Гранулярнее, per-task become в первой версии не реализовано —
|
||||||
|
// это включатель на весь прогон, как ansible.cfg become=true
|
||||||
|
// без per-play override.
|
||||||
|
Become bool `yaml:"become"`
|
||||||
|
|
||||||
|
// GatherFacts — собирать ли internal/facts перед тасками. false
|
||||||
|
// экономит одну SSH-сессию на хост и полезно, если os_family
|
||||||
|
// и подобные факты плейбуку не нужны.
|
||||||
|
GatherFacts bool `yaml:"gather_facts"`
|
||||||
|
|
||||||
|
// Timeout — таймаut на установление SSH-соединения к одному хосту.
|
||||||
|
Timeout time.Duration `yaml:"timeout"`
|
||||||
|
|
||||||
|
// SSHUser / SSHKeyPath / SSHPort — дефолты для подключения,
|
||||||
|
// используются, если соответствующий CLI-флаг не передан явно.
|
||||||
|
SSHUser string `yaml:"ssh_user"`
|
||||||
|
SSHKeyPath string `yaml:"ssh_key_path"`
|
||||||
|
SSHPort int `yaml:"ssh_port"`
|
||||||
|
|
||||||
|
// ModulesDir — путь к modules.d/ по умолчанию.
|
||||||
|
ModulesDir string `yaml:"modules_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default — значения, которые действуют, если ни в файле, ни в CLI
|
||||||
|
// ничего не указано. Forks=5 — тот же дефолт, что у самого Ansible.
|
||||||
|
func Default() Config {
|
||||||
|
return Config{
|
||||||
|
Forks: 5,
|
||||||
|
Serial: 0,
|
||||||
|
MaxFails: 0,
|
||||||
|
Become: false,
|
||||||
|
GatherFacts: true,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
SSHUser: "root",
|
||||||
|
SSHPort: 22,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlConfig — то же самое, что Config, но Timeout — строка ("10s"),
|
||||||
|
// потому что time.Duration не умеет сам себя парсить из YAML-скаляра
|
||||||
|
// без кастомного UnmarshalYAML; отдельный тип проще, чем городить его
|
||||||
|
// прямо на Config.
|
||||||
|
type yamlConfig struct {
|
||||||
|
Forks *int `yaml:"forks"`
|
||||||
|
Serial *int `yaml:"serial"`
|
||||||
|
MaxFails *int `yaml:"max_fails"`
|
||||||
|
Become *bool `yaml:"become"`
|
||||||
|
GatherFacts *bool `yaml:"gather_facts"`
|
||||||
|
Timeout string `yaml:"timeout"`
|
||||||
|
SSHUser string `yaml:"ssh_user"`
|
||||||
|
SSHKeyPath string `yaml:"ssh_key_path"`
|
||||||
|
SSHPort *int `yaml:"ssh_port"`
|
||||||
|
ModulesDir string `yaml:"modules_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load читает YAML-файл конфигурации и накладывает его поверх Default().
|
||||||
|
// Отсутствие файла — не ошибка (Load(path) с несуществующим path
|
||||||
|
// возвращает Default() как есть) — файл конфигурации в goherence
|
||||||
|
// полностью опционален, все параметры дублируются флагами CLI.
|
||||||
|
func Load(path string) (Config, error) {
|
||||||
|
cfg := Default()
|
||||||
|
if path == "" {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("читаю конфиг %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var y yamlConfig
|
||||||
|
if err := yaml.Unmarshal(data, &y); err != nil {
|
||||||
|
return cfg, fmt.Errorf("парсинг конфига %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if y.Forks != nil {
|
||||||
|
cfg.Forks = *y.Forks
|
||||||
|
}
|
||||||
|
if y.Serial != nil {
|
||||||
|
cfg.Serial = *y.Serial
|
||||||
|
}
|
||||||
|
if y.MaxFails != nil {
|
||||||
|
cfg.MaxFails = *y.MaxFails
|
||||||
|
}
|
||||||
|
if y.Become != nil {
|
||||||
|
cfg.Become = *y.Become
|
||||||
|
}
|
||||||
|
if y.GatherFacts != nil {
|
||||||
|
cfg.GatherFacts = *y.GatherFacts
|
||||||
|
}
|
||||||
|
if y.Timeout != "" {
|
||||||
|
d, err := time.ParseDuration(y.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("конфиг %s: timeout: %w", path, err)
|
||||||
|
}
|
||||||
|
cfg.Timeout = d
|
||||||
|
}
|
||||||
|
if y.SSHUser != "" {
|
||||||
|
cfg.SSHUser = y.SSHUser
|
||||||
|
}
|
||||||
|
if y.SSHKeyPath != "" {
|
||||||
|
cfg.SSHKeyPath = y.SSHKeyPath
|
||||||
|
}
|
||||||
|
if y.SSHPort != nil {
|
||||||
|
cfg.SSHPort = *y.SSHPort
|
||||||
|
}
|
||||||
|
if y.ModulesDir != "" {
|
||||||
|
cfg.ModulesDir = y.ModulesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Forks < 1 {
|
||||||
|
return cfg, fmt.Errorf("конфиг %s: forks должен быть >= 1", path)
|
||||||
|
}
|
||||||
|
if cfg.Serial < 0 {
|
||||||
|
return cfg, fmt.Errorf("конфиг %s: serial не может быть отрицательным", path)
|
||||||
|
}
|
||||||
|
if cfg.MaxFails < 0 {
|
||||||
|
return cfg, fmt.Errorf("конфиг %s: max_fails не может быть отрицательным", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
245
internal/executor/condition.go
Normal file
245
internal/executor/condition.go
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// evalWhen вычисляет when-выражение над hostVars. Грамматика намеренно
|
||||||
|
// минимальна и совпадает с тем, что разрешает lint.WhenGrammarRule:
|
||||||
|
// сравнения (== != < > <= >=), and/or/not, переменные и литералы.
|
||||||
|
// Никаких вызовов функций — если выражение прошло линтер, сюда оно
|
||||||
|
// попадает уже гарантированно "чистым".
|
||||||
|
//
|
||||||
|
// Переменные резолвятся через hostVars сразу в момент разбора атома
|
||||||
|
// (parseAtom), а не отложенно — hostVars известен целиком заранее, так
|
||||||
|
// что откладывать резолюцию нет причины, а более ранняя версия из-за
|
||||||
|
// этого имела баг: compare() сравнивал имя переменной как литерал,
|
||||||
|
// а не её значение.
|
||||||
|
func evalWhen(expr string, hostVars map[string]interface{}) bool {
|
||||||
|
p := &condParser{tokens: tokenize(expr), vars: hostVars}
|
||||||
|
v, err := p.parseOr()
|
||||||
|
if err != nil {
|
||||||
|
// невалидное when — считаем условие ложным и не роняем весь плейбук:
|
||||||
|
// синтаксическая проверка уже была на этапе lint, здесь это
|
||||||
|
// подстраховка на случай --force поверх ошибки линтера.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return toBool(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type condParser struct {
|
||||||
|
tokens []string
|
||||||
|
pos int
|
||||||
|
vars map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *condParser) peek() string {
|
||||||
|
if p.pos >= len(p.tokens) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.tokens[p.pos]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *condParser) next() string {
|
||||||
|
t := p.peek()
|
||||||
|
p.pos++
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *condParser) parseOr() (interface{}, error) {
|
||||||
|
left, err := p.parseAnd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for p.peek() == "or" {
|
||||||
|
p.next()
|
||||||
|
right, err := p.parseAnd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
left = toBool(left) || toBool(right)
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *condParser) parseAnd() (interface{}, error) {
|
||||||
|
left, err := p.parseNot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for p.peek() == "and" {
|
||||||
|
p.next()
|
||||||
|
right, err := p.parseNot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
left = toBool(left) && toBool(right)
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *condParser) parseNot() (interface{}, error) {
|
||||||
|
if p.peek() == "not" {
|
||||||
|
p.next()
|
||||||
|
v, err := p.parseNot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return !toBool(v), nil
|
||||||
|
}
|
||||||
|
return p.parseComparison()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *condParser) parseComparison() (interface{}, error) {
|
||||||
|
left, err := p.parseAtom()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op := p.peek()
|
||||||
|
switch op {
|
||||||
|
case "==", "!=", "<", ">", "<=", ">=":
|
||||||
|
p.next()
|
||||||
|
right, err := p.parseAtom()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return compare(op, left, right), nil
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAtom резолвит переменные сразу через p.vars — идентификатор,
|
||||||
|
// не являющийся числом/строкой/true/false, ищется в hostVars и
|
||||||
|
// возвращается уже как реальное значение (или nil, если переменной нет).
|
||||||
|
func (p *condParser) parseAtom() (interface{}, error) {
|
||||||
|
tok := p.next()
|
||||||
|
switch {
|
||||||
|
case tok == "(":
|
||||||
|
v, err := p.parseOr()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.next() != ")" {
|
||||||
|
return nil, fmt.Errorf("ожидалась закрывающая скобка")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case tok == "":
|
||||||
|
return nil, fmt.Errorf("неожиданный конец выражения")
|
||||||
|
case strings.HasPrefix(tok, `"`) || strings.HasPrefix(tok, "'"):
|
||||||
|
return strings.Trim(tok, `"'`), nil
|
||||||
|
case isNumber(tok):
|
||||||
|
f, _ := strconv.ParseFloat(tok, 64)
|
||||||
|
return f, nil
|
||||||
|
case tok == "true":
|
||||||
|
return true, nil
|
||||||
|
case tok == "false":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return p.vars[tok], nil // отсутствующая переменная — nil, не ошибка
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compare(op string, left, right interface{}) bool {
|
||||||
|
// числовое сравнение, если оба значения — float64
|
||||||
|
lf, lok := left.(float64)
|
||||||
|
rf, rok := right.(float64)
|
||||||
|
if lok && rok {
|
||||||
|
switch op {
|
||||||
|
case "==":
|
||||||
|
return lf == rf
|
||||||
|
case "!=":
|
||||||
|
return lf != rf
|
||||||
|
case "<":
|
||||||
|
return lf < rf
|
||||||
|
case ">":
|
||||||
|
return lf > rf
|
||||||
|
case "<=":
|
||||||
|
return lf <= rf
|
||||||
|
case ">=":
|
||||||
|
return lf >= rf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// булево сравнение, если оба значения — bool (частый случай:
|
||||||
|
// `when: clear_cache == true`, где true уже распознан как bool атом)
|
||||||
|
lb, lbok := left.(bool)
|
||||||
|
rb, rbok := right.(bool)
|
||||||
|
if lbok && rbok {
|
||||||
|
switch op {
|
||||||
|
case "==":
|
||||||
|
return lb == rb
|
||||||
|
case "!=":
|
||||||
|
return lb != rb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ls := fmt.Sprintf("%v", left)
|
||||||
|
rs := fmt.Sprintf("%v", right)
|
||||||
|
switch op {
|
||||||
|
case "==":
|
||||||
|
return ls == rs
|
||||||
|
case "!=":
|
||||||
|
return ls != rs
|
||||||
|
default:
|
||||||
|
return ls < rs // строковое < / > — редкий случай, оставлен для полноты
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toBool приводит уже резолвленное значение (не varRef — резолюция
|
||||||
|
// происходит раньше, в parseAtom) к bool.
|
||||||
|
func toBool(v interface{}) bool {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return vv
|
||||||
|
case string:
|
||||||
|
return vv != ""
|
||||||
|
case float64:
|
||||||
|
return vv != 0
|
||||||
|
default:
|
||||||
|
return v != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumber(s string) bool {
|
||||||
|
_, err := strconv.ParseFloat(s, 64)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize — примитивный токенизатор: числа/строки/идентификаторы через
|
||||||
|
// пробелы, операторы сравнения и скобки — отдельными токенами.
|
||||||
|
func tokenize(expr string) []string {
|
||||||
|
var tokens []string
|
||||||
|
i := 0
|
||||||
|
for i < len(expr) {
|
||||||
|
c := expr[i]
|
||||||
|
switch {
|
||||||
|
case c == ' ' || c == '\t':
|
||||||
|
i++
|
||||||
|
case c == '(' || c == ')':
|
||||||
|
tokens = append(tokens, string(c))
|
||||||
|
i++
|
||||||
|
case strings.HasPrefix(expr[i:], "=="), strings.HasPrefix(expr[i:], "!="),
|
||||||
|
strings.HasPrefix(expr[i:], "<="), strings.HasPrefix(expr[i:], ">="):
|
||||||
|
tokens = append(tokens, expr[i:i+2])
|
||||||
|
i += 2
|
||||||
|
case c == '<' || c == '>':
|
||||||
|
tokens = append(tokens, string(c))
|
||||||
|
i++
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
j := i + 1
|
||||||
|
for j < len(expr) && expr[j] != c {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
tokens = append(tokens, expr[i:j+1])
|
||||||
|
i = j + 1
|
||||||
|
default:
|
||||||
|
j := i
|
||||||
|
for j < len(expr) && expr[j] != ' ' && expr[j] != '(' && expr[j] != ')' {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
tokens = append(tokens, expr[i:j])
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
35
internal/executor/condition_test.go
Normal file
35
internal/executor/condition_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEvalWhen(t *testing.T) {
|
||||||
|
vars := map[string]interface{}{
|
||||||
|
"os_family": "Debian",
|
||||||
|
"clear_cache": true,
|
||||||
|
"http_port": float64(80),
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
expr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{`os_family == "Debian"`, true},
|
||||||
|
{`os_family == "RedHat"`, false},
|
||||||
|
{`os_family != "RedHat"`, true},
|
||||||
|
{`clear_cache == true`, true},
|
||||||
|
{`clear_cache == false`, false},
|
||||||
|
{`http_port == 80`, true},
|
||||||
|
{`http_port > 79`, true},
|
||||||
|
{`http_port < 79`, false},
|
||||||
|
{`os_family == "Debian" and clear_cache == true`, true},
|
||||||
|
{`os_family == "RedHat" or clear_cache == true`, true},
|
||||||
|
{`not clear_cache == false`, true},
|
||||||
|
{`(os_family == "Debian") and (http_port == 80)`, true},
|
||||||
|
{`unknown_var == "x"`, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := evalWhen(c.expr, vars)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("evalWhen(%q) = %v, хотел %v", c.expr, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
472
internal/executor/executor.go
Normal file
472
internal/executor/executor.go
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
// Package executor связывает все остальные пакеты воедино: парсер даёт
|
||||||
|
// структуру плейбука, lint проверяет её на guardrails, vars считает
|
||||||
|
// переменные для каждого хоста, module.Registry резолвит модуль по имени,
|
||||||
|
// ssh.Conn исполняет его на хосте. Сам executor не содержит модульной
|
||||||
|
// логики — только оркестрацию.
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/facts"
|
||||||
|
"github.com/vladimir/goherence/internal/lint"
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/module/builtin"
|
||||||
|
"github.com/vladimir/goherence/internal/module/external"
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
tmpl "github.com/vladimir/goherence/internal/template"
|
||||||
|
"github.com/vladimir/goherence/internal/vars"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options — то, что задаётся снаружи (флагами CLI/конфигом) при запуске плейбука.
|
||||||
|
type Options struct {
|
||||||
|
Limit string // ограничить выполнение одним хостом/группой
|
||||||
|
ExtraVars map[string]interface{}
|
||||||
|
CheckMode bool
|
||||||
|
Force bool // пропустить блокирующие ошибки линтера
|
||||||
|
ModulesDir string // путь к modules.d/ с внешними модулями; пусто — внешних модулей нет
|
||||||
|
Forks int // сколько хостов внутри одного батча обрабатывать одновременно; 0/1 — последовательно
|
||||||
|
Serial int // размер батча rolling-обновления; 0 — один батч на всех хостов сразу
|
||||||
|
MaxFails int // после скольких упавших хостов прервать оставшиеся батчи; 0 — без ограничения
|
||||||
|
Become bool // глобально поднимать привилегии (sudo) для всех команд на всех хостах
|
||||||
|
GatherFacts bool // собирать ли internal/facts перед тасками
|
||||||
|
OnTaskStart func(host string, t *parser.Task)
|
||||||
|
OnTaskEnd func(host string, t *parser.Task, res module.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHConfigFor — функция, которую вызывающий код должен предоставить,
|
||||||
|
// чтобы получить параметры подключения для конкретного хоста (ключ,
|
||||||
|
// пользователь и т.д.). Executor намеренно не знает, откуда эти данные
|
||||||
|
// берутся (инвентарь / vault / переменные окружения) — это ответственность
|
||||||
|
// вызывающего кода (см. cmd/goherence).
|
||||||
|
type SSHConfigFor func(host string) ssh.Config
|
||||||
|
|
||||||
|
// Run выполняет плейбук pb против инвентаря inv. Хосты разбиваются на
|
||||||
|
// батчи по opts.Serial (rolling-обновление — "один хост за раз" это
|
||||||
|
// Serial: 1); внутри батча до opts.Forks хостов обрабатываются
|
||||||
|
// параллельно. После каждого батча проверяется opts.MaxFails: если
|
||||||
|
// упавших хостов уже достаточно, оставшиеся батчи не запускаются —
|
||||||
|
// то, что уже успело обновиться, не откатывается (это не транзакция).
|
||||||
|
func Run(ctx context.Context, pb *parser.Playbook, inv *parser.Inventory, sshCfg SSHConfigFor, opts Options) error {
|
||||||
|
violations := lint.New().Run(pb)
|
||||||
|
for _, v := range violations {
|
||||||
|
fmt.Printf("[%s] %s: %s: %s\n", v.Severity, v.Rule, v.Location, v.Message)
|
||||||
|
}
|
||||||
|
if lint.HasError(violations) && !opts.Force {
|
||||||
|
return fmt.Errorf("плейбук нарушает guardrails декларативности; " +
|
||||||
|
"используй --force для запуска несмотря на ошибки (не рекомендуется)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printMu сериализует вывод OnTaskStart/OnTaskEnd между горутинами —
|
||||||
|
// без этого строки от разных хостов перемежались бы посимвольно.
|
||||||
|
printMu := &sync.Mutex{}
|
||||||
|
origStart, origEnd := opts.OnTaskStart, opts.OnTaskEnd
|
||||||
|
if origStart != nil {
|
||||||
|
opts.OnTaskStart = func(host string, t *parser.Task) {
|
||||||
|
printMu.Lock()
|
||||||
|
defer printMu.Unlock()
|
||||||
|
origStart(host, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if origEnd != nil {
|
||||||
|
opts.OnTaskEnd = func(host string, t *parser.Task, res module.Result) {
|
||||||
|
printMu.Lock()
|
||||||
|
defer printMu.Unlock()
|
||||||
|
origEnd(host, t, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, play := range pb.Plays {
|
||||||
|
hosts := resolveHosts(inv, play.Hosts, opts.Limit)
|
||||||
|
if err := runPlaySerially(ctx, pb, inv, &play, hosts, sshCfg, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPlaySerially делит hosts на батчи по opts.Serial и прогоняет их
|
||||||
|
// последовательно один за другим, проверяя opts.MaxFails между батчами.
|
||||||
|
func runPlaySerially(
|
||||||
|
ctx context.Context,
|
||||||
|
pb *parser.Playbook,
|
||||||
|
inv *parser.Inventory,
|
||||||
|
play *parser.Play,
|
||||||
|
hosts []string,
|
||||||
|
sshCfg SSHConfigFor,
|
||||||
|
opts Options,
|
||||||
|
) error {
|
||||||
|
batchSize := opts.Serial
|
||||||
|
if batchSize < 1 {
|
||||||
|
batchSize = len(hosts) // без serial — один батч на всех, как раньше
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed []error
|
||||||
|
for start := 0; start < len(hosts); start += batchSize {
|
||||||
|
end := start + batchSize
|
||||||
|
if end > len(hosts) {
|
||||||
|
end = len(hosts)
|
||||||
|
}
|
||||||
|
batch := hosts[start:end]
|
||||||
|
|
||||||
|
failed = append(failed, runBatch(ctx, pb, inv, play, batch, sshCfg, opts)...)
|
||||||
|
|
||||||
|
if opts.MaxFails > 0 && len(failed) >= opts.MaxFails {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"остановлено после %d ошибок (лимит max_fails=%d), оставшиеся хосты не тронуты: %s",
|
||||||
|
len(failed), opts.MaxFails, joinErrors(failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failed) > 0 {
|
||||||
|
return fmt.Errorf("ошибки на %d из %d хостов: %s", len(failed), len(hosts), joinErrors(failed))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBatch запускает play на hosts, максимум opts.Forks одновременно,
|
||||||
|
// и возвращает ВСЕ ошибки батча (а не первую попавшуюся) — иначе после
|
||||||
|
// каждого батча нечего было бы сравнивать с MaxFails: одна необработанная
|
||||||
|
// ошибка ничем не отличалась бы от трёх.
|
||||||
|
func runBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
pb *parser.Playbook,
|
||||||
|
inv *parser.Inventory,
|
||||||
|
play *parser.Play,
|
||||||
|
hosts []string,
|
||||||
|
sshCfg SSHConfigFor,
|
||||||
|
opts Options,
|
||||||
|
) []error {
|
||||||
|
forks := opts.Forks
|
||||||
|
if forks < 1 {
|
||||||
|
forks = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if forks == 1 {
|
||||||
|
var errs []error
|
||||||
|
for _, host := range hosts {
|
||||||
|
if err := runPlayOnHost(ctx, pb, inv, play, host, sshCfg, opts); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("хост %s: %w", host, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, forks)
|
||||||
|
errCh := make(chan error, len(hosts))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
host := host
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
if err := runPlayOnHost(ctx, pb, inv, play, host, sshCfg, opts); err != nil {
|
||||||
|
errCh <- fmt.Errorf("хост %s: %w", host, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for err := range errCh {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinErrors(errs []error) string {
|
||||||
|
parts := make([]string, len(errs))
|
||||||
|
for i, e := range errs {
|
||||||
|
parts[i] = e.Error()
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHosts разворачивает play.Hosts (имя группы или "all") в список
|
||||||
|
// конкретных хостов, применяя --limit, если он задан.
|
||||||
|
func resolveHosts(inv *parser.Inventory, hostsSpec, limit string) []string {
|
||||||
|
group, ok := inv.Groups[hostsSpec]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for name := range group.Hosts {
|
||||||
|
if limit == "" || limit == name || limit == hostsSpec {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPlayOnHost(
|
||||||
|
ctx context.Context,
|
||||||
|
pb *parser.Playbook,
|
||||||
|
inv *parser.Inventory,
|
||||||
|
play *parser.Play,
|
||||||
|
host string,
|
||||||
|
sshCfg SSHConfigFor,
|
||||||
|
opts Options,
|
||||||
|
) error {
|
||||||
|
conn, err := ssh.Dial(sshCfg(host))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("подключение по ssh: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
conn.Become = opts.Become // глобальный переключатель на весь прогон, см. internal/config
|
||||||
|
|
||||||
|
// факты собираются один раз на хост в начале play — ровно как
|
||||||
|
// implicit gather_facts в Ansible перед первым таском. Отключается
|
||||||
|
// через opts.GatherFacts=false (config: gather_facts: false), если
|
||||||
|
// плейбуку факты не нужны — это экономит одну SSH-сессию на хост.
|
||||||
|
hostFacts := map[string]interface{}{}
|
||||||
|
if opts.GatherFacts {
|
||||||
|
hostFacts, err = facts.Gather(conn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("сбор фактов: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notified — хэндлеры, на которые сработал notify хотя бы одного
|
||||||
|
// изменившего состояние таска, в порядке первого срабатывания.
|
||||||
|
// Собирается по всему play (play-level tasks + все роли) и
|
||||||
|
// выполняется один раз в конце — ровно так же, как в Ansible.
|
||||||
|
var notified []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
notify := func(names []string) {
|
||||||
|
for _, n := range names {
|
||||||
|
if !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
notified = append(notified, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// сначала таски самого play, затем таски каждой подключённой роли —
|
||||||
|
// в первой версии roles всегда выполняются после play-level tasks
|
||||||
|
n, err := runTasks(ctx, play.Tasks, nil, inv, play, host, conn, hostFacts, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
notify(n)
|
||||||
|
|
||||||
|
handlers := map[string]parser.Task{}
|
||||||
|
for _, roleName := range play.Roles {
|
||||||
|
role := pb.Roles[roleName]
|
||||||
|
if role == nil {
|
||||||
|
return fmt.Errorf("роль %q не загружена", roleName)
|
||||||
|
}
|
||||||
|
for _, h := range role.Handlers {
|
||||||
|
handlers[h.Name] = h
|
||||||
|
}
|
||||||
|
n, err := runTasks(ctx, role.Tasks, role, inv, play, host, conn, hostFacts, opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("роль %s: %w", roleName, err)
|
||||||
|
}
|
||||||
|
notify(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runHandlers(ctx, notified, handlers, inv, play, host, conn, hostFacts, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHandlers выполняет по одному разу каждый хэндлер из notified —
|
||||||
|
// аналог того, как Ansible прогоняет notify-хэндлеры в конце play.
|
||||||
|
// Хэндлер резолвится с переменными play/host/facts, но без role.Defaults/Vars
|
||||||
|
// конкретной роли (в первой версии хэндлеры общие на весь play, без
|
||||||
|
// привязки к переменным той роли, где они были объявлены) — сознательное
|
||||||
|
// упрощение, отмеченное в README.
|
||||||
|
func runHandlers(
|
||||||
|
ctx context.Context,
|
||||||
|
notified []string,
|
||||||
|
handlers map[string]parser.Task,
|
||||||
|
inv *parser.Inventory,
|
||||||
|
play *parser.Play,
|
||||||
|
host string,
|
||||||
|
conn *ssh.Conn,
|
||||||
|
hostFacts map[string]interface{},
|
||||||
|
opts Options,
|
||||||
|
) error {
|
||||||
|
if len(notified) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var toRun []parser.Task
|
||||||
|
for _, name := range notified {
|
||||||
|
h, ok := handlers[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("notify указывает на неизвестный хэндлер %q", name)
|
||||||
|
}
|
||||||
|
toRun = append(toRun, h)
|
||||||
|
}
|
||||||
|
_, err := runTasks(ctx, toRun, nil, inv, play, host, conn, hostFacts, opts)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTasks выполняет список тасков на одном хосте и возвращает имена
|
||||||
|
// хэндлеров, на которые сработал notify (для тасков, реально изменивших
|
||||||
|
// состояние — Result.Changed). Вызывающий код (runPlayOnHost) собирает
|
||||||
|
// notify со всех групп тасков play и роли и прогоняет хэндлеры один раз в конце.
|
||||||
|
func runTasks(
|
||||||
|
ctx context.Context,
|
||||||
|
tasks []parser.Task,
|
||||||
|
role *parser.Role,
|
||||||
|
inv *parser.Inventory,
|
||||||
|
play *parser.Play,
|
||||||
|
host string,
|
||||||
|
conn *ssh.Conn,
|
||||||
|
hostFacts map[string]interface{},
|
||||||
|
opts Options,
|
||||||
|
) ([]string, error) {
|
||||||
|
hostVars := vars.Resolve(host, inv, play, role, hostFacts, opts.ExtraVars)
|
||||||
|
|
||||||
|
// граф зависимостей (after/before) — см. graph.go; без объявленных
|
||||||
|
// зависимостей порядок не меняется вообще.
|
||||||
|
orderedTasks, err := orderTasks(tasks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tasks = orderedTasks
|
||||||
|
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
builtin.RegisterAll(reg, hostVars) // TemplateModule/PackageModule получают vars именно здесь,
|
||||||
|
// на пересчёт для каждого набора тасков — см. ограничение в register.go
|
||||||
|
|
||||||
|
if opts.ModulesDir != "" {
|
||||||
|
extModules, err := external.LoadDir(opts.ModulesDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("загрузка внешних модулей из %s: %w", opts.ModulesDir, err)
|
||||||
|
}
|
||||||
|
for _, m := range extModules {
|
||||||
|
reg.RegisterExternal(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var notified []string
|
||||||
|
|
||||||
|
for i := range tasks {
|
||||||
|
t := &tasks[i]
|
||||||
|
|
||||||
|
if t.When != "" && !evalWhen(t.When, hostVars) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mod, err := reg.Resolve(t.Module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := loopItems(t.Loop, hostVars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("таск %q: loop: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
args, err := renderTaskArgs(t.Args, hostVars, item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("таск %q: рендер аргументов: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OnTaskStart != nil {
|
||||||
|
opts.OnTaskStart(host, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := mod.Run(ctx, module.Input{
|
||||||
|
SchemaVersion: module.SchemaVersion,
|
||||||
|
Args: args,
|
||||||
|
Host: host,
|
||||||
|
CheckMode: opts.CheckMode,
|
||||||
|
}, conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("таск %q: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OnTaskEnd != nil {
|
||||||
|
opts.OnTaskEnd(host, t, res)
|
||||||
|
}
|
||||||
|
if res.Failed {
|
||||||
|
return nil, fmt.Errorf("таск %q завершился с ошибкой: %s", t.Name, res.Msg)
|
||||||
|
}
|
||||||
|
if res.Changed && len(t.Notify) > 0 {
|
||||||
|
notified = append(notified, t.Notify...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notified, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loopItems разворачивает t.Loop в список элементов, по одному на итерацию.
|
||||||
|
// nil-loop — это ровно один "элемент" (пустой), то есть таск выполняется
|
||||||
|
// один раз как обычно; это позволяет не разветвлять код на "с loop"/"без loop".
|
||||||
|
func loopItems(loopSpec interface{}, hostVars map[string]interface{}) ([]interface{}, error) {
|
||||||
|
if loopSpec == nil {
|
||||||
|
return []interface{}{nil}, nil
|
||||||
|
}
|
||||||
|
switch v := loopSpec.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
return v, nil
|
||||||
|
case string:
|
||||||
|
// "{{ .some_list }}" — ссылка на переменную-список в hostVars
|
||||||
|
varName := extractVarName(v)
|
||||||
|
if varName == "" {
|
||||||
|
return nil, fmt.Errorf("loop-выражение %q не распознано (первая версия понимает только {{ .var }})", v)
|
||||||
|
}
|
||||||
|
val, ok := hostVars[varName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("переменная %q для loop не найдена", varName)
|
||||||
|
}
|
||||||
|
list, ok := val.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("переменная %q не является списком", varName)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("неподдерживаемый тип loop: %T", loopSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractVarName вытаскивает имя переменной из "{{ .name }}" — только
|
||||||
|
// этот один паттерн, без произвольных выражений (loop — не место для логики).
|
||||||
|
func extractVarName(expr string) string {
|
||||||
|
s := strings.TrimSpace(expr)
|
||||||
|
s = strings.TrimPrefix(s, "{{")
|
||||||
|
s = strings.TrimSuffix(s, "}}")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.TrimPrefix(s, ".")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTaskArgs рендерит каждое строковое значение в args через
|
||||||
|
// internal/template (text/template+sprig), добавляя текущий item из
|
||||||
|
// loop под именем `.item` — ровно так же, как Ansible подставляет `item`
|
||||||
|
// внутри тасков с loop. Нестроковые значения (числа, bool, вложенные map)
|
||||||
|
// передаются как есть.
|
||||||
|
func renderTaskArgs(args map[string]interface{}, hostVars map[string]interface{}, item interface{}) (map[string]interface{}, error) {
|
||||||
|
scopedVars := make(map[string]interface{}, len(hostVars)+1)
|
||||||
|
for k, v := range hostVars {
|
||||||
|
scopedVars[k] = v
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
scopedVars["item"] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]interface{}, len(args))
|
||||||
|
for k, v := range args {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok || !strings.Contains(s, "{{") {
|
||||||
|
out[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rendered, err := tmpl.Render("arg:"+k, s, scopedVars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[k] = rendered
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
112
internal/executor/graph.go
Normal file
112
internal/executor/graph.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// orderTasks сортирует tasks по явным зависимостям (after/before), как
|
||||||
|
// require/before в Puppet, вместо жёсткого порядка "как написано в файле".
|
||||||
|
// Это СТАБИЛЬНАЯ топологическая сортировка: если ни один таск не объявляет
|
||||||
|
// after/before, результат побайтово совпадает с исходным порядком — граф
|
||||||
|
// зависимостей влияет только на то, что реально зависимостями объявлено,
|
||||||
|
// и не может незаметно переставить местами таски без причины.
|
||||||
|
//
|
||||||
|
// Алгоритм Кана с выбором наименьшего доступного индекса на каждом шаге
|
||||||
|
// (а не FIFO/DFS) — это и даёт стабильность: без явных рёбер все узлы
|
||||||
|
// доступны сразу, и они просто разбираются по порядку.
|
||||||
|
//
|
||||||
|
// Важная деталь тай-брейка: если из двух тасков без прямой связи друг
|
||||||
|
// с другом один вынужден сдвинуться из-за чужой зависимости, второй
|
||||||
|
// остаётся на исходном относительном месте — а не "уступает дорогу"
|
||||||
|
// первому. Иными словами, при конфликте сохраняется как можно больше
|
||||||
|
// исходных пар "было раньше" — но если это математически невозможно
|
||||||
|
// для ВСЕХ пар сразу (что бывает при before/after), какая именно пара
|
||||||
|
// "жертвуется" — вопрос тай-брейка, а не более раннего решения "правильно/неправильно".
|
||||||
|
func orderTasks(tasks []parser.Task) ([]parser.Task, error) {
|
||||||
|
n := len(tasks)
|
||||||
|
|
||||||
|
index := map[string]int{}
|
||||||
|
for i, t := range tasks {
|
||||||
|
if t.Name != "" {
|
||||||
|
if _, dup := index[t.Name]; dup {
|
||||||
|
return nil, fmt.Errorf("два таска с одинаковым именем %q — "+
|
||||||
|
"after/before не может однозначно на них сослаться", t.Name)
|
||||||
|
}
|
||||||
|
index[t.Name] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adj := make([][]int, n) // adj[i] — какие таски должны идти после i
|
||||||
|
indeg := make([]int, n)
|
||||||
|
|
||||||
|
addEdge := func(from, to int) {
|
||||||
|
if from == to {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adj[from] = append(adj[from], to)
|
||||||
|
indeg[to]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, t := range tasks {
|
||||||
|
for _, depName := range t.After {
|
||||||
|
j, ok := index[depName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("таск %q: after ссылается на неизвестный таск %q", t.Name, depName)
|
||||||
|
}
|
||||||
|
addEdge(j, i) // depName должен выполниться до t
|
||||||
|
}
|
||||||
|
for _, targetName := range t.Before {
|
||||||
|
j, ok := index[targetName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("таск %q: before ссылается на неизвестный таск %q", t.Name, targetName)
|
||||||
|
}
|
||||||
|
addEdge(i, j) // t должен выполниться до targetName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visited := make([]bool, n)
|
||||||
|
var order []int
|
||||||
|
for len(order) < n {
|
||||||
|
picked := -1
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if !visited[i] && indeg[i] == 0 {
|
||||||
|
picked = i
|
||||||
|
break // наименьший индекс среди доступных — гарантирует стабильность
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if picked == -1 {
|
||||||
|
return nil, fmt.Errorf("цикл в зависимостях тасков (after/before): %s", cycleHint(tasks, visited))
|
||||||
|
}
|
||||||
|
visited[picked] = true
|
||||||
|
order = append(order, picked)
|
||||||
|
for _, next := range adj[picked] {
|
||||||
|
indeg[next]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]parser.Task, n)
|
||||||
|
for pos, idx := range order {
|
||||||
|
out[pos] = tasks[idx]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycleHint перечисляет имена тасков, которые не удалось разместить —
|
||||||
|
// то есть участников цикла (или зависящих от него), чтобы сообщение об
|
||||||
|
// ошибке указывало, где искать проблему, а не просто "где-то цикл".
|
||||||
|
func cycleHint(tasks []parser.Task, visited []bool) string {
|
||||||
|
var names []string
|
||||||
|
for i, v := range visited {
|
||||||
|
if !v {
|
||||||
|
name := tasks[i].Name
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("#%d", i)
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
108
internal/executor/graph_test.go
Normal file
108
internal/executor/graph_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func names(tasks []parser.Task) []string {
|
||||||
|
out := make([]string, len(tasks))
|
||||||
|
for i, t := range tasks {
|
||||||
|
out[i] = t.Name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderTasksNoDepsPreservesOrder(t *testing.T) {
|
||||||
|
tasks := []parser.Task{{Name: "a"}, {Name: "b"}, {Name: "c"}}
|
||||||
|
got, err := orderTasks(tasks)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"a", "b", "c"}
|
||||||
|
if got := names(got); !equal(got, want) {
|
||||||
|
t.Errorf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderTasksAfterReorders(t *testing.T) {
|
||||||
|
// "b" объявлен раньше "a" в файле, но зависит от него через after —
|
||||||
|
// должен переместиться после "a".
|
||||||
|
tasks := []parser.Task{
|
||||||
|
{Name: "b", After: []string{"a"}},
|
||||||
|
{Name: "a"},
|
||||||
|
{Name: "c"},
|
||||||
|
}
|
||||||
|
got, err := orderTasks(tasks)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"a", "b", "c"}
|
||||||
|
if got := names(got); !equal(got, want) {
|
||||||
|
t.Errorf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderTasksBefore(t *testing.T) {
|
||||||
|
tasks := []parser.Task{
|
||||||
|
{Name: "a"},
|
||||||
|
{Name: "b"},
|
||||||
|
{Name: "c", Before: []string{"a"}},
|
||||||
|
}
|
||||||
|
got, err := orderTasks(tasks)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Единственное жёсткое требование — c должен идти раньше a.
|
||||||
|
// b ничем не связан ни с a, ни с c, так что его позиция относительно
|
||||||
|
// них — вопрос тай-брейка алгоритма (см. комментарий в graph.go),
|
||||||
|
// а не то, что тест должен закреплять как "единственно верное".
|
||||||
|
order := names(got)
|
||||||
|
posA, posC := indexOf(order, "a"), indexOf(order, "c")
|
||||||
|
if posC >= posA {
|
||||||
|
t.Errorf("c должен идти раньше a, получили порядок %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s []string, v string) int {
|
||||||
|
for i, x := range s {
|
||||||
|
if x == v {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderTasksCycleDetected(t *testing.T) {
|
||||||
|
tasks := []parser.Task{
|
||||||
|
{Name: "a", After: []string{"b"}},
|
||||||
|
{Name: "b", After: []string{"a"}},
|
||||||
|
}
|
||||||
|
_, err := orderTasks(tasks)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка цикла, получили nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderTasksUnknownDependency(t *testing.T) {
|
||||||
|
tasks := []parser.Task{
|
||||||
|
{Name: "a", After: []string{"nonexistent"}},
|
||||||
|
}
|
||||||
|
_, err := orderTasks(tasks)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка неизвестной зависимости, получили nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equal(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
75
internal/facts/facts.go
Normal file
75
internal/facts/facts.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// Package facts собирает минимальный набор фактов о хосте — ровно
|
||||||
|
// столько, сколько нужно, чтобы плейбук мог сам выбрать нужную ветку
|
||||||
|
// для разных дистрибутивов (apt vs rpm_package), без ручного указания
|
||||||
|
// ОС в инвентаре. Это не полноценный gather_facts — осознанно маленький
|
||||||
|
// набор, расширяемый по мере реальной необходимости.
|
||||||
|
package facts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gather подключается к уже открытой SSH-сессии и вычисляет:
|
||||||
|
// - os_family: "Debian" | "RedHat" | "Unknown"
|
||||||
|
// - distribution: значение ID= из /etc/os-release (ubuntu, debian, rhel, almalinux...)
|
||||||
|
// - system: вывод `uname -s` (обычно "Linux")
|
||||||
|
func Gather(conn *ssh.Conn) (map[string]interface{}, error) {
|
||||||
|
res, err := conn.Run("cat /etc/os-release 2>/dev/null; echo '---'; uname -s")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(res.Stdout, "---", 2)
|
||||||
|
osRelease := parts[0]
|
||||||
|
system := "Unknown"
|
||||||
|
if len(parts) > 1 {
|
||||||
|
system = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
distro := parseOSReleaseID(osRelease)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"os_family": familyFor(distro),
|
||||||
|
"distribution": distro,
|
||||||
|
"system": system,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOSReleaseID вытаскивает значение ID= из /etc/os-release
|
||||||
|
// (например ID=ubuntu, ID="almalinux", ID=rhel).
|
||||||
|
func parseOSReleaseID(osRelease string) string {
|
||||||
|
for _, line := range strings.Split(osRelease, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "ID=") {
|
||||||
|
v := strings.TrimPrefix(line, "ID=")
|
||||||
|
return strings.Trim(v, `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// debianFamily / redHatFamily — известные ID из /etc/os-release,
|
||||||
|
// относящиеся к каждому семейству. Список расширяется по мере
|
||||||
|
// встречи новых дистрибутивов, а не пытается быть исчерпывающим сразу.
|
||||||
|
var (
|
||||||
|
debianFamily = map[string]bool{
|
||||||
|
"debian": true, "ubuntu": true, "raspbian": true, "linuxmint": true,
|
||||||
|
}
|
||||||
|
redHatFamily = map[string]bool{
|
||||||
|
"rhel": true, "centos": true, "fedora": true, "almalinux": true,
|
||||||
|
"rocky": true, "amzn": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func familyFor(distro string) string {
|
||||||
|
switch {
|
||||||
|
case debianFamily[distro]:
|
||||||
|
return "Debian"
|
||||||
|
case redHatFamily[distro]:
|
||||||
|
return "RedHat"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
113
internal/lint/lint.go
Normal file
113
internal/lint/lint.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// Package lint реализует guardrails против превращения декларативных
|
||||||
|
// плейбуков в императивный код. Никакой плагинной системы для правил —
|
||||||
|
// они часть бинарника и расширяются через PR, а не через рантайм-загрузку
|
||||||
|
// (в отличие от internal/module/external, который расширяем намеренно).
|
||||||
|
package lint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Severity int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Error Severity = iota // блокирует запуск, требует --force
|
||||||
|
Warning // печатается, но не блокирует
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Severity) String() string {
|
||||||
|
if s == Error {
|
||||||
|
return "ERROR"
|
||||||
|
}
|
||||||
|
return "WARNING"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Violation struct {
|
||||||
|
Rule string
|
||||||
|
Severity Severity
|
||||||
|
Location string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule — контракт, которому подчиняются все проверки.
|
||||||
|
type Rule interface {
|
||||||
|
Name() string
|
||||||
|
Check(pb *parser.Playbook) []Violation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linter — фиксированный, не настраиваемый снаружи список правил.
|
||||||
|
type Linter struct {
|
||||||
|
rules []Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// New возвращает линтер с полным набором правил первой версии.
|
||||||
|
// Пороги (MaxNesting/MaxBranches) — тут единственное, что параметризовано,
|
||||||
|
// и то как явные поля структуры, а не внешний конфиг-файл.
|
||||||
|
func New() *Linter {
|
||||||
|
return &Linter{rules: []Rule{
|
||||||
|
&WhenGrammarRule{},
|
||||||
|
&ShellUsageRule{},
|
||||||
|
&SetFactStateRule{},
|
||||||
|
&DynamicIncludeRule{},
|
||||||
|
&ComplexityRule{MaxNesting: 3, MaxBranches: 8},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run прогоняет все правила по плейбуку и возвращает все нарушения сразу —
|
||||||
|
// принципиально не останавливается на первом, чтобы за один прогон
|
||||||
|
// показать разработчику всё, что нужно поправить.
|
||||||
|
func (l *Linter) Run(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
for _, r := range l.rules {
|
||||||
|
out = append(out, r.Check(pb)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasError — есть ли среди нарушений хотя бы одно уровня Error.
|
||||||
|
func HasError(violations []Violation) bool {
|
||||||
|
for _, v := range violations {
|
||||||
|
if v.Severity == Error {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkTasks — общий обход всех тасков плейбука (в play и во всех
|
||||||
|
// подключённых ролях), с человекочитаемой Location для каждого.
|
||||||
|
// Вынесен сюда как единственная точка обхода, чтобы правила не
|
||||||
|
// дублировали логику "как пройтись по всем таскам плейбука".
|
||||||
|
func walkTasks(pb *parser.Playbook, fn func(location string, t *parser.Task)) {
|
||||||
|
for _, play := range pb.Plays {
|
||||||
|
for i := range play.Tasks {
|
||||||
|
t := &play.Tasks[i]
|
||||||
|
fn(locationFor(play.Name, "", i, t), t)
|
||||||
|
}
|
||||||
|
for _, roleName := range play.Roles {
|
||||||
|
role, ok := pb.Roles[roleName]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range role.Tasks {
|
||||||
|
t := &role.Tasks[i]
|
||||||
|
fn(locationFor(play.Name, roleName, i, t), t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationFor(playName, roleName string, idx int, t *parser.Task) string {
|
||||||
|
loc := "play " + playName
|
||||||
|
if roleName != "" {
|
||||||
|
loc += ": role " + roleName
|
||||||
|
}
|
||||||
|
if t.Name != "" {
|
||||||
|
loc += ": task " + t.Name
|
||||||
|
} else {
|
||||||
|
loc += ": task #" + strconv.Itoa(idx)
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
42
internal/lint/rules_complexity.go
Normal file
42
internal/lint/rules_complexity.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package lint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComplexityRule ограничивает число условных тасков (`when`) в одной роли.
|
||||||
|
// Превышение порога — обычно значит, что роль пытается быть программой
|
||||||
|
// с ветвлениями, а не описанием желаемого состояния хостов. Это Warning,
|
||||||
|
// а не Error: сложность — вопрос вкуса и контекста, а не жёсткого правила,
|
||||||
|
// как shell-usage или dynamic-include.
|
||||||
|
type ComplexityRule struct {
|
||||||
|
MaxNesting int // зарезервировано на будущее — реальная вложенность block/block
|
||||||
|
MaxBranches int // сколько тасков с `when` допустимо в одной роли
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComplexityRule) Name() string { return "complexity" }
|
||||||
|
|
||||||
|
func (r *ComplexityRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
for roleName, role := range pb.Roles {
|
||||||
|
branches := 0
|
||||||
|
for _, t := range role.Tasks {
|
||||||
|
if t.When != "" {
|
||||||
|
branches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if branches > r.MaxBranches {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Warning,
|
||||||
|
Location: "role " + roleName,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"условных тасков (when) = %d, порог %d — похоже на процедурный код "+
|
||||||
|
"внутри роли, стоит разбить на несколько ролей поменьше",
|
||||||
|
branches, r.MaxBranches),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
37
internal/lint/rules_include.go
Normal file
37
internal/lint/rules_include.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package lint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DynamicIncludeRule требует, чтобы путь include_tasks/import_role был
|
||||||
|
// статической строкой, известной уже на этапе парсинга плейбука — без
|
||||||
|
// подстановки переменных внутрь пути. Это убирает GOTO-с-параметрами
|
||||||
|
// как способ "программировать" маршрутизацию тасков вместо декларативного
|
||||||
|
// описания состояния.
|
||||||
|
type DynamicIncludeRule struct{}
|
||||||
|
|
||||||
|
func (r *DynamicIncludeRule) Name() string { return "static-include-only" }
|
||||||
|
|
||||||
|
func (r *DynamicIncludeRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.Module != "include_tasks" && t.Module != "import_role" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, _ := t.Args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
path, _ = t.Args["name"].(string) // import_role обычно использует "name"
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "{{") {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: "путь include должен быть статическим — динамический " +
|
||||||
|
t.Module + " запрещён: " + path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
44
internal/lint/rules_setfact.go
Normal file
44
internal/lint/rules_setfact.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package lint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetFactStateRule ловит паттерн `set_fact: x="{{ .x + 1 }}"` — попытку
|
||||||
|
// завести изменяемое состояние (счётчик, накопитель) поверх декларативной
|
||||||
|
// модели, где каждый таск должен описывать конечное состояние, а не шаг
|
||||||
|
// вычисления, зависящий от предыдущего запуска этого же таска.
|
||||||
|
type SetFactStateRule struct{}
|
||||||
|
|
||||||
|
func (r *SetFactStateRule) Name() string { return "set-fact-self-reference" }
|
||||||
|
|
||||||
|
func (r *SetFactStateRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.Module != "set_fact" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for varName, val := range t.Args {
|
||||||
|
expr, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if referencesVar(expr, varName) {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: "set_fact: " + varName + " ссылается сам на себя — похоже на попытку " +
|
||||||
|
"завести изменяемое состояние между тасками, что нарушает идемпотентность плейбука",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// referencesVar — грубая, но достаточная для первой версии проверка:
|
||||||
|
// содержит ли выражение шаблона `.varName` внутри себя же (self-reference).
|
||||||
|
func referencesVar(expr, varName string) bool {
|
||||||
|
return strings.Contains(expr, "."+varName)
|
||||||
|
}
|
||||||
55
internal/lint/rules_shell.go
Normal file
55
internal/lint/rules_shell.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package lint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shellToModuleHints — известные паттерны команд, для которых есть
|
||||||
|
// прямой декларативный модуль. Список расширяется по мере роста
|
||||||
|
// internal/module/builtin — если добавляешь builtin-модуль, стоит
|
||||||
|
// сразу подумать, какие shell-паттерны он делает лишними.
|
||||||
|
var shellToModuleHints = map[string]string{
|
||||||
|
"apt-get install": "apt",
|
||||||
|
"apt install": "apt",
|
||||||
|
"yum install": "rpm_package",
|
||||||
|
"dnf install": "rpm_package",
|
||||||
|
"systemctl": "systemd",
|
||||||
|
"mkdir -p": "file (state=directory)",
|
||||||
|
"chmod": "file (mode=...)",
|
||||||
|
"rm -rf": "file (state=absent)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellUsageRule ловит использование shell/command там, где уже есть
|
||||||
|
// builtin-модуль с тем же эффектом, но идемпотентный по своей природе.
|
||||||
|
// Единственный способ обойти правило — явная пометка allow_shell: true
|
||||||
|
// прямо на таске: она остаётся видна в коде плейбука навсегда, так что
|
||||||
|
// причина обхода на виду у любого, кто читает файл при code review.
|
||||||
|
type ShellUsageRule struct{}
|
||||||
|
|
||||||
|
func (r *ShellUsageRule) Name() string { return "shell-usage" }
|
||||||
|
|
||||||
|
func (r *ShellUsageRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.Module != "shell" && t.Module != "command" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.AllowShell {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd, _ := t.Args["cmd"].(string)
|
||||||
|
for pattern, alt := range shellToModuleHints {
|
||||||
|
if strings.Contains(cmd, pattern) {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: "используй декларативный модуль " + alt +
|
||||||
|
" вместо shell/command, либо явно пометь таск allow_shell: true: " + cmd,
|
||||||
|
})
|
||||||
|
return // одного совпадения достаточно, не дублируем сообщение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
45
internal/lint/rules_when.go
Normal file
45
internal/lint/rules_when.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package lint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WhenGrammarRule разрешает в `when` только чистые предикаты:
|
||||||
|
// сравнения (==, !=, <, >, <=, >=), логику (and, or, not) и обращения
|
||||||
|
// к переменным. Вызов функций (sprig или любых других) в when запрещён —
|
||||||
|
// это первый и самый частый путь протащить в условие вычисление с
|
||||||
|
// побочным эффектом (случайные числа, текущее время, счётчики).
|
||||||
|
//
|
||||||
|
// Реализация первой версии — не полноценный парсер выражений, а
|
||||||
|
// консервативный regex-фильтр: ищем характерный синтаксис вызова функции
|
||||||
|
// `имя(...)` и паттерн `| filterName` (пайп — тоже вызов функции в Go-шаблонах).
|
||||||
|
// Ложные срабатывания возможны и это осознанный компромисс: лучше
|
||||||
|
// требовать явный allow_shell-подобный обход, чем пропустить реальный вызов.
|
||||||
|
type WhenGrammarRule struct{}
|
||||||
|
|
||||||
|
func (r *WhenGrammarRule) Name() string { return "when-grammar" }
|
||||||
|
|
||||||
|
var (
|
||||||
|
funcCallPattern = regexp.MustCompile(`[a-zA-Z_][a-zA-Z0-9_]*\s*\(`)
|
||||||
|
pipePattern = regexp.MustCompile(`\|\s*[a-zA-Z_]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *WhenGrammarRule) Check(pb *parser.Playbook) []Violation {
|
||||||
|
var out []Violation
|
||||||
|
walkTasks(pb, func(loc string, t *parser.Task) {
|
||||||
|
if t.When == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if funcCallPattern.MatchString(t.When) || pipePattern.MatchString(t.When) {
|
||||||
|
out = append(out, Violation{
|
||||||
|
Rule: r.Name(), Severity: Error, Location: loc,
|
||||||
|
Message: "when-выражение должно быть чистым предикатом " +
|
||||||
|
"(сравнения / and / or / not над переменными), вызовы функций и " +
|
||||||
|
"фильтры (|) внутри when запрещены: " + t.When,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
73
internal/module/builtin/apt.go
Normal file
73
internal/module/builtin/apt.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AptModule — аналог ansible.builtin.apt: приводит наличие пакета
|
||||||
|
// к желаемому state (present/absent). Идемпотентен через `dpkg -s`
|
||||||
|
// перед любым изменяющим действием.
|
||||||
|
type AptModule struct{}
|
||||||
|
|
||||||
|
func (m *AptModule) Name() string { return "apt" }
|
||||||
|
|
||||||
|
func (m *AptModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
name, ok := in.Args["name"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("apt: аргумент name обязателен")
|
||||||
|
}
|
||||||
|
state, _ := in.Args["state"].(string)
|
||||||
|
if state == "" {
|
||||||
|
state = "present"
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := m.isInstalled(conn, name)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wantInstalled := state == "present" || state == "latest"
|
||||||
|
if installed == wantInstalled {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: name + ": уже в нужном состоянии"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.CheckMode {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true,
|
||||||
|
Msg: fmt.Sprintf("check mode: %s был бы %s", name, state)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
if wantInstalled {
|
||||||
|
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y %s", shArg(name))
|
||||||
|
} else {
|
||||||
|
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y %s", shArg(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := conn.Run(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
|
||||||
|
Msg: fmt.Sprintf("apt-get завершился с кодом %d: %s", res.ExitCode, res.Stderr)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: name + ": " + state}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AptModule) isInstalled(conn *ssh.Conn, name string) (bool, error) {
|
||||||
|
res, err := conn.Run(fmt.Sprintf("dpkg -s %s 2>/dev/null | grep -q '^Status: install ok installed'", shArg(name)))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res.ExitCode == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shArg(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
52
internal/module/builtin/copy.go
Normal file
52
internal/module/builtin/copy.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CopyModule — аналог ansible.builtin.copy: приводит файл на хосте
|
||||||
|
// к заданному содержимому. Идемпотентен: сначала читает текущее
|
||||||
|
// содержимое и сравнивает, "changed" выставляется только при реальном отличии.
|
||||||
|
type CopyModule struct{}
|
||||||
|
|
||||||
|
func (m *CopyModule) Name() string { return "copy" }
|
||||||
|
|
||||||
|
func (m *CopyModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
dest, ok := in.Args["dest"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("copy: аргумент dest обязателен")
|
||||||
|
}
|
||||||
|
content, ok := in.Args["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("copy: аргумент content обязателен (первая версия не читает src-файлы с control node)")
|
||||||
|
}
|
||||||
|
mode, _ := in.Args["mode"].(string)
|
||||||
|
if mode == "" {
|
||||||
|
mode = "0644"
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := conn.ReadFile(dest)
|
||||||
|
same := err == nil && bytes.Equal(current, []byte(content))
|
||||||
|
|
||||||
|
if same {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: "уже в нужном состоянии"}, nil
|
||||||
|
}
|
||||||
|
if in.CheckMode {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: "check mode: файл был бы изменён: " + dest}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.WriteFile(dest, []byte(content), mode); err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf("copy: запись %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return module.Result{
|
||||||
|
SchemaVersion: module.SchemaVersion,
|
||||||
|
Changed: true,
|
||||||
|
Msg: "файл записан: " + dest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
101
internal/module/builtin/file.go
Normal file
101
internal/module/builtin/file.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileModule — аналог ansible.builtin.file: управляет состоянием пути
|
||||||
|
// (директория/файл/отсутствие) и правами доступа. Идемпотентен через `stat`.
|
||||||
|
type FileModule struct{}
|
||||||
|
|
||||||
|
func (m *FileModule) Name() string { return "file" }
|
||||||
|
|
||||||
|
func (m *FileModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
path, ok := in.Args["path"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("file: аргумент path обязателен")
|
||||||
|
}
|
||||||
|
state, _ := in.Args["state"].(string)
|
||||||
|
if state == "" {
|
||||||
|
state = "file"
|
||||||
|
}
|
||||||
|
mode, _ := in.Args["mode"].(string)
|
||||||
|
|
||||||
|
current, err := m.stat(conn, path)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
var action string
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case "directory":
|
||||||
|
if current != "directory" {
|
||||||
|
action = fmt.Sprintf("mkdir -p %s", shArg(path))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
case "absent":
|
||||||
|
if current != "absent" {
|
||||||
|
action = fmt.Sprintf("rm -rf %s", shArg(path))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
case "touch":
|
||||||
|
if current == "absent" {
|
||||||
|
action = fmt.Sprintf("touch %s", shArg(path))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return module.Result{}, fmt.Errorf("file: неподдерживаемый state %q (первая версия: directory/absent/touch)", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode != "" && current != "absent" {
|
||||||
|
if action != "" {
|
||||||
|
action += " && "
|
||||||
|
}
|
||||||
|
action += fmt.Sprintf("chmod %s %s", mode, shArg(path))
|
||||||
|
changed = true // не проверяем текущий режим отдельно в первой версии — упрощение
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: "уже в нужном состоянии"}, nil
|
||||||
|
}
|
||||||
|
if in.CheckMode {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: "check mode: " + action}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := conn.Run(action)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
|
||||||
|
Msg: fmt.Sprintf("команда завершилась с кодом %d: %s", res.ExitCode, res.Stderr)}, nil
|
||||||
|
}
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: action}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *FileModule) stat(conn *ssh.Conn, path string) (string, error) {
|
||||||
|
res, err := conn.Run(fmt.Sprintf(
|
||||||
|
`test -d %s && echo directory || (test -e %s && echo file || echo absent)`,
|
||||||
|
shArg(path), shArg(path)))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case containsTrim(res.Stdout, "directory"):
|
||||||
|
return "directory", nil
|
||||||
|
case containsTrim(res.Stdout, "absent"):
|
||||||
|
return "absent", nil
|
||||||
|
default:
|
||||||
|
return "file", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsTrim(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || s == sub+"\n")
|
||||||
|
}
|
||||||
63
internal/module/builtin/package.go
Normal file
63
internal/module/builtin/package.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageModule — аналог puppet-style типизированного ресурса package:
|
||||||
|
// один модуль на все дистрибутивы, сам выбирающий провайдера (apt/dnf/yum),
|
||||||
|
// вместо того чтобы заставлять плейбук писать `when: os_family == ...`
|
||||||
|
// для каждой пары модуль/ОС. Выбор провайдера:
|
||||||
|
// 1. если в hostVars есть os_family (факты собраны) — используем его напрямую,
|
||||||
|
// без единого лишнего запроса на хост;
|
||||||
|
// 2. если фактов нет (gather_facts: false) — определяем прямо на месте
|
||||||
|
// через наличие apt-get/dnf/yum на хосте (одна дополнительная команда,
|
||||||
|
// но только в этом случае — не платим за это, когда факты уже есть).
|
||||||
|
type PackageModule struct {
|
||||||
|
// OSFamily — предвычисленный на этапе резолва переменных os_family
|
||||||
|
// (пусто, если факты не собирались). Заполняется executor'ом так же,
|
||||||
|
// как TemplateModule.Vars — см. builtin.RegisterAll.
|
||||||
|
OSFamily string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PackageModule) Name() string { return "package" }
|
||||||
|
|
||||||
|
func (m *PackageModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
family := m.OSFamily
|
||||||
|
if family == "" {
|
||||||
|
detected, err := detectOSFamily(conn)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf("package: не удалось определить дистрибутив " +
|
||||||
|
"(факты не собраны и автоопределение не удалось): " + err.Error())
|
||||||
|
}
|
||||||
|
family = detected
|
||||||
|
}
|
||||||
|
|
||||||
|
switch family {
|
||||||
|
case "Debian":
|
||||||
|
return (&AptModule{}).Run(ctx, in, conn)
|
||||||
|
case "RedHat":
|
||||||
|
return (&PackageRPMModule{}).Run(ctx, in, conn)
|
||||||
|
default:
|
||||||
|
return module.Result{}, fmt.Errorf(
|
||||||
|
"package: неизвестное семейство ОС %q — используй apt или rpm_package явно", family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectOSFamily — резервный путь для случая gather_facts: false: одна
|
||||||
|
// команда на хосте вместо полноценного facts.Gather (который делает
|
||||||
|
// дополнительный `cat /etc/os-release`) — здесь достаточно проверить
|
||||||
|
// наличие пакетного менеджера, сам os-release не нужен.
|
||||||
|
func detectOSFamily(conn *ssh.Conn) (string, error) {
|
||||||
|
if res, err := conn.Run("command -v apt-get"); err == nil && res.ExitCode == 0 {
|
||||||
|
return "Debian", nil
|
||||||
|
}
|
||||||
|
if res, err := conn.Run("command -v dnf || command -v yum"); err == nil && res.ExitCode == 0 {
|
||||||
|
return "RedHat", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("не найден ни apt-get, ни dnf/yum")
|
||||||
|
}
|
||||||
21
internal/module/builtin/register.go
Normal file
21
internal/module/builtin/register.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import "github.com/vladimir/goherence/internal/module"
|
||||||
|
|
||||||
|
// RegisterAll добавляет все builtin-модули первой версии в реестр.
|
||||||
|
// Единственное место, которое нужно трогать при добавлении нового модуля —
|
||||||
|
// сам модуль остаётся в отдельном файле по одному на модуль (см. shell.go,
|
||||||
|
// copy.go, apt.go и т.д.), а не сваливается всё в один monolith-modules.go.
|
||||||
|
func RegisterAll(reg *module.Registry, hostVars map[string]interface{}) {
|
||||||
|
reg.RegisterBuiltin(&ShellModule{})
|
||||||
|
reg.RegisterBuiltin(&CommandModule{})
|
||||||
|
reg.RegisterBuiltin(&CopyModule{})
|
||||||
|
reg.RegisterBuiltin(&TemplateModule{Vars: hostVars})
|
||||||
|
reg.RegisterBuiltin(&AptModule{})
|
||||||
|
reg.RegisterBuiltin(&PackageRPMModule{})
|
||||||
|
reg.RegisterBuiltin(&FileModule{})
|
||||||
|
reg.RegisterBuiltin(&SystemdModule{})
|
||||||
|
|
||||||
|
osFamily, _ := hostVars["os_family"].(string) // пусто, если факты не собирались — см. package.go
|
||||||
|
reg.RegisterBuiltin(&PackageModule{OSFamily: osFamily})
|
||||||
|
}
|
||||||
82
internal/module/builtin/rpm.go
Normal file
82
internal/module/builtin/rpm.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageRPMModule — аналог ansible.builtin.yum/dnf для RPM-based систем
|
||||||
|
// (RHEL/CentOS/ALT Linux с apt-rpm тоже подходит частично, но здесь
|
||||||
|
// используется универсальный `rpm -q` для проверки + `dnf`/`yum` для установки).
|
||||||
|
type PackageRPMModule struct{}
|
||||||
|
|
||||||
|
func (m *PackageRPMModule) Name() string { return "rpm_package" }
|
||||||
|
|
||||||
|
func (m *PackageRPMModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
name, ok := in.Args["name"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("rpm_package: аргумент name обязателен")
|
||||||
|
}
|
||||||
|
state, _ := in.Args["state"].(string)
|
||||||
|
if state == "" {
|
||||||
|
state = "present"
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := m.isInstalled(conn, name)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
wantInstalled := state == "present" || state == "latest"
|
||||||
|
if installed == wantInstalled {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: name + ": уже в нужном состоянии"}, nil
|
||||||
|
}
|
||||||
|
if in.CheckMode {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true,
|
||||||
|
Msg: fmt.Sprintf("check mode: %s был бы %s", name, state)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pm, err := m.packageManager(conn)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
if wantInstalled {
|
||||||
|
cmd = fmt.Sprintf("%s install -y %s", pm, shArg(name))
|
||||||
|
} else {
|
||||||
|
cmd = fmt.Sprintf("%s remove -y %s", pm, shArg(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := conn.Run(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
|
||||||
|
Msg: fmt.Sprintf("%s завершился с кодом %d: %s", pm, res.ExitCode, res.Stderr)}, nil
|
||||||
|
}
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: name + ": " + state}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PackageRPMModule) isInstalled(conn *ssh.Conn, name string) (bool, error) {
|
||||||
|
res, err := conn.Run(fmt.Sprintf("rpm -q %s > /dev/null 2>&1", shArg(name)))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res.ExitCode == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageManager определяет dnf/yum/apt-rpm по наличию бинарника —
|
||||||
|
// нужно, потому что RPM-based дистрибутивы разошлись по пакетным менеджерам.
|
||||||
|
func (m *PackageRPMModule) packageManager(conn *ssh.Conn) (string, error) {
|
||||||
|
for _, candidate := range []string{"dnf", "yum", "apt-get"} {
|
||||||
|
res, err := conn.Run("command -v " + candidate)
|
||||||
|
if err == nil && res.ExitCode == 0 {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("не найден ни dnf, ни yum, ни apt-get на хосте")
|
||||||
|
}
|
||||||
55
internal/module/builtin/shell.go
Normal file
55
internal/module/builtin/shell.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShellModule — единственный по-настоящему императивный builtin-модуль.
|
||||||
|
// Его использование намеренно ограничивается линтером (internal/lint) —
|
||||||
|
// сам модуль здесь просто честно исполняет команду, никакой магии.
|
||||||
|
type ShellModule struct{}
|
||||||
|
|
||||||
|
func (m *ShellModule) Name() string { return "shell" }
|
||||||
|
|
||||||
|
func (m *ShellModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
cmd, ok := in.Args["cmd"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("shell: аргумент cmd обязателен и должен быть строкой")
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.CheckMode {
|
||||||
|
return module.Result{
|
||||||
|
SchemaVersion: module.SchemaVersion,
|
||||||
|
Changed: true, // shell не умеет предсказывать идемпотентность — check-mode всегда "would change"
|
||||||
|
Msg: "check mode: команда не выполнена: " + cmd,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := conn.Run(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return module.Result{
|
||||||
|
SchemaVersion: module.SchemaVersion,
|
||||||
|
Changed: true, // shell не имеет понятия состояния — считается изменяющим всегда
|
||||||
|
Failed: res.ExitCode != 0,
|
||||||
|
Msg: res.Stdout,
|
||||||
|
Diff: map[string]string{
|
||||||
|
"stderr": res.Stderr,
|
||||||
|
"exit_code": fmt.Sprintf("%d", res.ExitCode),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandModule — то же самое, что shell, но семантически "без shell-обвязки"
|
||||||
|
// (в реальном Ansible command не проходит через /bin/sh, что важно для
|
||||||
|
// экранирования). Для первой версии реализация идентична shell —
|
||||||
|
// разница добавляется отдельно, когда понадобится exec без shell-интерпретации.
|
||||||
|
type CommandModule struct{ ShellModule }
|
||||||
|
|
||||||
|
func (m *CommandModule) Name() string { return "command" }
|
||||||
96
internal/module/builtin/systemd.go
Normal file
96
internal/module/builtin/systemd.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemdModule — аналог ansible.builtin.systemd: приводит сервис
|
||||||
|
// к желаемому состоянию (started/stopped) и/или включает автозапуск (enabled).
|
||||||
|
type SystemdModule struct{}
|
||||||
|
|
||||||
|
func (m *SystemdModule) Name() string { return "systemd" }
|
||||||
|
|
||||||
|
func (m *SystemdModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
name, ok := in.Args["name"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("systemd: аргумент name обязателен")
|
||||||
|
}
|
||||||
|
state, _ := in.Args["state"].(string)
|
||||||
|
enabledArg, hasEnabled := in.Args["enabled"].(bool)
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
var actions []string
|
||||||
|
|
||||||
|
if state != "" {
|
||||||
|
active, err := m.isActive(conn, name)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
wantActive := state == "started"
|
||||||
|
if state == "restarted" {
|
||||||
|
actions = append(actions, "systemctl restart "+shArg(name))
|
||||||
|
changed = true
|
||||||
|
} else if active != wantActive {
|
||||||
|
verb := "stop"
|
||||||
|
if wantActive {
|
||||||
|
verb = "start"
|
||||||
|
}
|
||||||
|
actions = append(actions, "systemctl "+verb+" "+shArg(name))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasEnabled {
|
||||||
|
enabled, err := m.isEnabled(conn, name)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
if enabled != enabledArg {
|
||||||
|
verb := "disable"
|
||||||
|
if enabledArg {
|
||||||
|
verb = "enable"
|
||||||
|
}
|
||||||
|
actions = append(actions, "systemctl "+verb+" "+shArg(name))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: name + ": уже в нужном состоянии"}, nil
|
||||||
|
}
|
||||||
|
if in.CheckMode {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: fmt.Sprintf("check mode: %v", actions)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, action := range actions {
|
||||||
|
res, err := conn.Run(action)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Failed: true,
|
||||||
|
Msg: fmt.Sprintf("%q завершилась с кодом %d: %s", action, res.ExitCode, res.Stderr)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: fmt.Sprintf("%v", actions)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SystemdModule) isActive(conn *ssh.Conn, name string) (bool, error) {
|
||||||
|
res, err := conn.Run("systemctl is-active " + shArg(name))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res.ExitCode == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SystemdModule) isEnabled(conn *ssh.Conn, name string) (bool, error) {
|
||||||
|
res, err := conn.Run("systemctl is-enabled " + shArg(name))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res.ExitCode == 0, nil
|
||||||
|
}
|
||||||
68
internal/module/builtin/template.go
Normal file
68
internal/module/builtin/template.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
tmpl "github.com/vladimir/goherence/internal/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplateModule — аналог ansible.builtin.template: рендерит файл шаблона
|
||||||
|
// (text/template + sprig, см. internal/template) с текущими переменными
|
||||||
|
// таска и приводит удалённый файл к результату — идемпотентно, как copy.
|
||||||
|
type TemplateModule struct {
|
||||||
|
// Vars — переменные, с которыми рендерится шаблон. Заполняется
|
||||||
|
// executor'ом перед вызовом Run (сам Module не знает про vars.Resolve,
|
||||||
|
// чтобы не тянуть в module-пакет зависимость на parser/vars).
|
||||||
|
Vars map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TemplateModule) Name() string { return "template" }
|
||||||
|
|
||||||
|
func (m *TemplateModule) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
src, ok := in.Args["src"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("template: аргумент src обязателен")
|
||||||
|
}
|
||||||
|
dest, ok := in.Args["dest"].(string)
|
||||||
|
if !ok {
|
||||||
|
return module.Result{}, fmt.Errorf("template: аргумент dest обязателен")
|
||||||
|
}
|
||||||
|
mode, _ := in.Args["mode"].(string)
|
||||||
|
if mode == "" {
|
||||||
|
mode = "0644"
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf("template: чтение шаблона %s: %w", src, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := tmpl.Render(src, string(raw), m.Vars)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf("template: рендер %s: %w", src, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := conn.ReadFile(dest)
|
||||||
|
same := err == nil && bytes.Equal(current, []byte(rendered))
|
||||||
|
if same {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: false, Msg: "уже в нужном состоянии"}, nil
|
||||||
|
}
|
||||||
|
if in.CheckMode {
|
||||||
|
return module.Result{SchemaVersion: module.SchemaVersion, Changed: true, Msg: "check mode: файл был бы изменён: " + dest}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.WriteFile(dest, []byte(rendered), mode); err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf("template: запись %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return module.Result{
|
||||||
|
SchemaVersion: module.SchemaVersion,
|
||||||
|
Changed: true,
|
||||||
|
Msg: "шаблон отрендерен и записан: " + dest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
133
internal/module/external/deploy.go
vendored
Normal file
133
internal/module/external/deploy.go
vendored
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectRemoteArch определяет "goos/goarch" удалённого хоста через uname,
|
||||||
|
// чтобы выбрать нужный бинарник модуля из BinPath.
|
||||||
|
func detectRemoteArch(conn *ssh.Conn) (string, error) {
|
||||||
|
res, err := conn.Run("uname -s; uname -m")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("detectRemoteArch: %w", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(res.Stdout), "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
return "", fmt.Errorf("detectRemoteArch: неожиданный вывод uname: %q", res.Stdout)
|
||||||
|
}
|
||||||
|
goos := normalizeOS(lines[0])
|
||||||
|
goarch := normalizeArch(lines[1])
|
||||||
|
return goos + "/" + goarch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOS(uname string) string {
|
||||||
|
switch strings.TrimSpace(uname) {
|
||||||
|
case "Linux":
|
||||||
|
return "linux"
|
||||||
|
case "Darwin":
|
||||||
|
return "darwin"
|
||||||
|
default:
|
||||||
|
return strings.ToLower(strings.TrimSpace(uname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeArch(uname string) string {
|
||||||
|
switch strings.TrimSpace(uname) {
|
||||||
|
case "x86_64", "amd64":
|
||||||
|
return "amd64"
|
||||||
|
case "aarch64", "arm64":
|
||||||
|
return "arm64"
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(uname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBaseDir определяет директорию на хосте для доставленных модулей —
|
||||||
|
// под домашней директорией подключившегося пользователя, а не под /opt,
|
||||||
|
// который требует root. Если по какой-то причине $HOME недоступен (пустой
|
||||||
|
// вывод), откатываемся на /tmp — доступно всегда, но переживает только
|
||||||
|
// текущую сессию ОС на некоторых системах (tmpfs с очисткой при
|
||||||
|
// перезагрузке), так что это осознанно резервный, а не основной путь.
|
||||||
|
func resolveBaseDir(conn *ssh.Conn) (string, error) {
|
||||||
|
res, err := conn.Run("echo $HOME")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolveBaseDir: %w", err)
|
||||||
|
}
|
||||||
|
home := strings.TrimSpace(res.Stdout)
|
||||||
|
if home == "" || home == "$HOME" {
|
||||||
|
return "/tmp/goherence-modules", nil
|
||||||
|
}
|
||||||
|
return home + "/.goherence/modules", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// remotePath — куда на хосте кладётся конкретная версия бинарника модуля.
|
||||||
|
// Версия зашита в путь, так что старые версии просто остаются на диске
|
||||||
|
// как есть (без отдельной чистки в первой версии) — это сознательно
|
||||||
|
// простое решение вместо retention-политики, которой пока никто не просил.
|
||||||
|
func remotePath(baseDir string, m *Module) string {
|
||||||
|
return fmt.Sprintf("%s/%s-%s", baseDir, m.Manifest.Name, m.Manifest.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDeployed проверяет через маркер-файл, лежит ли на хосте уже
|
||||||
|
// актуальная версия модуля, и копирует бинарник только если нужно.
|
||||||
|
// Все шаги, которые могут завершиться ошибкой на удалённой стороне
|
||||||
|
// (mkdir, test), явно проверяют exit code — раньше здесь проверялась
|
||||||
|
// только транспортная ошибка, и permission denied на mkdir тихо
|
||||||
|
// протекал в WriteFile, где всплывал уже нечитаемым EOF.
|
||||||
|
func ensureDeployed(conn *ssh.Conn, m *Module) (string, error) {
|
||||||
|
arch, err := detectRemoteArch(conn)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
localBin, ok := m.BinPath[arch]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("модуль %s: нет собранного бинарника под архитектуру %s "+
|
||||||
|
"(доступны: %v) — проверь кросс-компиляцию", m.Manifest.Name, arch, keysOf(m.BinPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir, err := resolveBaseDir(conn)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
target := remotePath(baseDir, m)
|
||||||
|
|
||||||
|
res, err := conn.Run("test -x " + shQuote(target))
|
||||||
|
if err == nil && res.ExitCode == 0 {
|
||||||
|
return target, nil // нужная версия уже на хосте — ничего не копируем
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(localBin)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("читаю локальный бинарник %s: %w", localBin, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirRes, err := conn.Run("mkdir -p " + shQuote(baseDir))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("mkdir -p %s: %w", baseDir, err)
|
||||||
|
}
|
||||||
|
if mkdirRes.ExitCode != 0 {
|
||||||
|
return "", fmt.Errorf("mkdir -p %s: exit %d: %s", baseDir, mkdirRes.ExitCode, strings.TrimSpace(mkdirRes.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.WriteFile(target, data, "0755"); err != nil {
|
||||||
|
return "", fmt.Errorf("доставка модуля %s на хост: %w", m.Manifest.Name, err)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func shQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
52
internal/module/external/loader.go
vendored
Normal file
52
internal/module/external/loader.go
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadDir сканирует директорию modules.d/ — по одной поддиректории на
|
||||||
|
// модуль, каждая с manifest.json и бинарниками под все заявленные
|
||||||
|
// архитектуры. Отсутствие бинарника под конкретную архитектуру — не
|
||||||
|
// фатально на этапе загрузки (модуль может использоваться только для
|
||||||
|
// хостов другой архитектуры), но резолвится в ошибку в момент деплоя.
|
||||||
|
func LoadDir(dir string) ([]*Module, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil // modules.d/ отсутствует — это нормально, внешних модулей просто нет
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("читаю %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var modules []*Module
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moduleDir := filepath.Join(dir, e.Name())
|
||||||
|
manifestPath := filepath.Join(moduleDir, "manifest.json")
|
||||||
|
if _, err := os.Stat(manifestPath); err != nil {
|
||||||
|
continue // директория без manifest.json — не модуль, пропускаем молча
|
||||||
|
}
|
||||||
|
m, err := LoadManifest(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("модуль в %s: %w", moduleDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binPaths := map[string]string{}
|
||||||
|
for _, arch := range m.Architectures {
|
||||||
|
p := binaryPath(moduleDir, m.Name, arch)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
binPaths[arch] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules = append(modules, &Module{
|
||||||
|
Manifest: *m,
|
||||||
|
BinPath: binPaths,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return modules, nil
|
||||||
|
}
|
||||||
58
internal/module/external/manifest.go
vendored
Normal file
58
internal/module/external/manifest.go
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Package external реализует расширяемость модулей без пересборки
|
||||||
|
// бинарника goherence: сторонний модуль — любой исполняемый файл,
|
||||||
|
// который читает JSON (module.Input) из stdin и пишет JSON (module.Result)
|
||||||
|
// в stdout. Контракт совпадает с тем, как устроены сами модули Ansible —
|
||||||
|
// осознанное решение, чтобы язык реализации модуля был не важен.
|
||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest — метаданные одного внешнего модуля, лежат в modules.d/<name>/manifest.json.
|
||||||
|
type Manifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Architectures []string `json:"architectures"` // "linux/amd64", "linux/arm64"
|
||||||
|
Checksums map[string]string `json:"checksums"` // arch → sha256 (без префикса)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadManifest читает и валидирует manifest.json.
|
||||||
|
func LoadManifest(path string) (*Manifest, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("читаю manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("парсинг manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
if m.Name == "" || m.Version == "" {
|
||||||
|
return nil, fmt.Errorf("manifest.json: name и version обязательны")
|
||||||
|
}
|
||||||
|
if len(m.Architectures) == 0 {
|
||||||
|
return nil, fmt.Errorf("manifest.json: architectures не может быть пустым")
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// binaryPath — путь к локальному (control node) бинарнику модуля для
|
||||||
|
// конкретной архитектуры. Соглашение об именовании: <moduleDir>/<name>-<goos>-<goarch>,
|
||||||
|
// например modules.d/hello/hello-linux-amd64 для "linux/amd64".
|
||||||
|
func binaryPath(moduleDir, name, arch string) string {
|
||||||
|
goos, goarch := splitArch(arch)
|
||||||
|
return filepath.Join(moduleDir, fmt.Sprintf("%s-%s-%s", name, goos, goarch))
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitArch(arch string) (goos, goarch string) {
|
||||||
|
for i := 0; i < len(arch); i++ {
|
||||||
|
if arch[i] == '/' {
|
||||||
|
return arch[:i], arch[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "linux", arch
|
||||||
|
}
|
||||||
58
internal/module/external/module.go
vendored
Normal file
58
internal/module/external/module.go
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/module"
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module — обёртка над внешним (сторонним) модулем: реализует тот же
|
||||||
|
// интерфейс module.Module, что и builtin-модули, но вместо чистого Go-кода
|
||||||
|
// доставляет и запускает на хосте subprocess, обмениваясь с ним JSON
|
||||||
|
// по stdin/stdout. Executor не отличает Module от builtin — он получает
|
||||||
|
// его из того же Registry, резолвя по имени.
|
||||||
|
type Module struct {
|
||||||
|
Manifest Manifest
|
||||||
|
BinPath map[string]string // arch ("linux/amd64") → путь к локальному бинарнику
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Name() string { return m.Manifest.Name }
|
||||||
|
|
||||||
|
func (m *Module) Run(ctx context.Context, in module.Input, conn *ssh.Conn) (module.Result, error) {
|
||||||
|
if m.Manifest.SchemaVersion != module.SchemaVersion {
|
||||||
|
return module.Result{}, fmt.Errorf(
|
||||||
|
"модуль %s: schema_version %d не совпадает с ожидаемой %d — "+
|
||||||
|
"обнови модуль или goherence", m.Manifest.Name, m.Manifest.SchemaVersion, module.SchemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteBin, err := ensureDeployed(conn, m)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inputJSON, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf("сериализация input для %s: %w", m.Manifest.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := conn.RunWithInput(remoteBin, inputJSON)
|
||||||
|
if err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf("запуск внешнего модуля %s: %w", m.Manifest.Name, err)
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
return module.Result{}, fmt.Errorf(
|
||||||
|
"внешний модуль %s завершился с кодом %d, stderr: %s",
|
||||||
|
m.Manifest.Name, res.ExitCode, res.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out module.Result
|
||||||
|
if err := json.Unmarshal([]byte(res.Stdout), &out); err != nil {
|
||||||
|
return module.Result{}, fmt.Errorf(
|
||||||
|
"внешний модуль %s вернул невалидный JSON: %w (stdout: %s)",
|
||||||
|
m.Manifest.Name, err, res.Stdout)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
97
internal/module/module.go
Normal file
97
internal/module/module.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// Package module определяет единственный контракт, которому подчиняются
|
||||||
|
// и встроенные (builtin, чистый Go), и внешние (external, subprocess+JSON)
|
||||||
|
// модули. Executor не знает разницы между ними — он просто резолвит
|
||||||
|
// имя модуля через Registry и вызывает Run.
|
||||||
|
package module
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/vladimir/goherence/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input — то, что модуль получает на вход. Для builtin-модулей это просто
|
||||||
|
// структура в памяти; для external-модулей ровно эти же поля сериализуются
|
||||||
|
// в JSON и уходят в stdin subprocess'а — контракт единый и явный.
|
||||||
|
type Input struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Args map[string]interface{} `json:"args"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Become bool `json:"become"`
|
||||||
|
CheckMode bool `json:"check_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result — то, что модуль возвращает после выполнения.
|
||||||
|
type Result struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Changed bool `json:"changed"`
|
||||||
|
Failed bool `json:"failed"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Diff interface{} `json:"diff,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaVersion — текущая версия контракта Input/Result. Меняется только
|
||||||
|
// при несовместимых изменениях формата; внешние модули должны проверять
|
||||||
|
// это поле и явно отказываться работать с версией, которую не понимают.
|
||||||
|
const SchemaVersion = 1
|
||||||
|
|
||||||
|
// Module — контракт, общий для builtin-функций и обёрток над subprocess.
|
||||||
|
type Module interface {
|
||||||
|
Name() string
|
||||||
|
Run(ctx context.Context, in Input, conn *ssh.Conn) (Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry хранит все известные модули и резолвит их по имени.
|
||||||
|
// Порядок поиска: сперва builtin (быстрее, без overhead процесса),
|
||||||
|
// затем external — так что локальный модуль с тем же именем, что и
|
||||||
|
// встроенный, никогда не переопределяет его молча.
|
||||||
|
type Registry struct {
|
||||||
|
builtin map[string]Module
|
||||||
|
external map[string]Module // заполняется отдельно, см. internal/module/external
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
builtin: map[string]Module{},
|
||||||
|
external: map[string]Module{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) RegisterBuiltin(m Module) {
|
||||||
|
r.builtin[m.Name()] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) RegisterExternal(m Module) {
|
||||||
|
r.external[m.Name()] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve возвращает модуль по имени. Принимает как короткие имена
|
||||||
|
// (`shell`), так и полные (`ansible.builtin.shell`) — второй компонент
|
||||||
|
// после последней точки трактуется как основное имя.
|
||||||
|
func (r *Registry) Resolve(name string) (Module, error) {
|
||||||
|
short := shortName(name)
|
||||||
|
if m, ok := r.builtin[short]; ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m, ok := r.external[short]; ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return nil, ErrModuleNotFound{Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortName(name string) string {
|
||||||
|
for i := len(name) - 1; i >= 0; i-- {
|
||||||
|
if name[i] == '.' {
|
||||||
|
return name[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrModuleNotFound — отдельный тип ошибки, чтобы executor мог отличить
|
||||||
|
// "модуль не найден" от прочих ошибок выполнения и дать понятную подсказку.
|
||||||
|
type ErrModuleNotFound struct{ Name string }
|
||||||
|
|
||||||
|
func (e ErrModuleNotFound) Error() string {
|
||||||
|
return "модуль не найден: " + e.Name
|
||||||
|
}
|
||||||
204
internal/parser/inventory.go
Normal file
204
internal/parser/inventory.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rawGroup — форма группы прямо как она лежит в YAML-инвентаре.
|
||||||
|
type rawGroup struct {
|
||||||
|
Hosts map[string]map[string]interface{} `yaml:"hosts,omitempty"`
|
||||||
|
Vars map[string]interface{} `yaml:"vars,omitempty"`
|
||||||
|
Children map[string]rawGroup `yaml:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadInventory читает YAML-инвентарь (в стиле `all: children: ...`)
|
||||||
|
// и разворачивает его в плоскую карту групп с явным списком children.
|
||||||
|
func LoadInventory(path string) (*Inventory, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("читаю инвентарь: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var root map[string]rawGroup
|
||||||
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||||
|
return nil, fmt.Errorf("парсинг инвентаря: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inv := &Inventory{
|
||||||
|
Groups: map[string]*Group{},
|
||||||
|
Dir: filepath.Dir(path),
|
||||||
|
}
|
||||||
|
for name, rg := range root {
|
||||||
|
flatten(inv, name, rg)
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flatten рекурсивно разворачивает rawGroup (с вложенными children)
|
||||||
|
// в плоский inv.Groups, попутно строя список Children для каждой группы —
|
||||||
|
// это то дерево, по которому потом считается порядок наложения group_vars.
|
||||||
|
func flatten(inv *Inventory, name string, rg rawGroup) {
|
||||||
|
group, ok := inv.Groups[name]
|
||||||
|
if !ok {
|
||||||
|
group = &Group{Name: name, Hosts: map[string]*Host{}, Vars: map[string]interface{}{}}
|
||||||
|
inv.Groups[name] = group
|
||||||
|
}
|
||||||
|
for k, v := range rg.Vars {
|
||||||
|
group.Vars[k] = v
|
||||||
|
}
|
||||||
|
for hostName, hostVars := range rg.Hosts {
|
||||||
|
h, ok := group.Hosts[hostName]
|
||||||
|
if !ok {
|
||||||
|
h = &Host{Name: hostName, Vars: map[string]interface{}{}}
|
||||||
|
group.Hosts[hostName] = h
|
||||||
|
}
|
||||||
|
for k, v := range hostVars {
|
||||||
|
h.Vars[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for childName, childGroup := range rg.Children {
|
||||||
|
group.Children = append(group.Children, childName)
|
||||||
|
flatten(inv, childName, childGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupsOf возвращает имена всех групп, в которые входит хост,
|
||||||
|
// включая родительские группы через children (транзитивно).
|
||||||
|
func (inv *Inventory) GroupsOf(hostName string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for name, g := range inv.Groups {
|
||||||
|
if _, ok := g.Hosts[hostName]; ok {
|
||||||
|
markAncestors(inv, name, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for name := range seen {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// markAncestors помечает саму группу и всех её "родителей" (группы,
|
||||||
|
// у которых она указана в children) как содержащие данный хост.
|
||||||
|
func markAncestors(inv *Inventory, group string, seen map[string]bool) {
|
||||||
|
if seen[group] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[group] = true
|
||||||
|
for name, g := range inv.Groups {
|
||||||
|
for _, child := range g.Children {
|
||||||
|
if child == group {
|
||||||
|
markAncestors(inv, name, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// depth — расстояние группы от "all" по дереву children (all = 0).
|
||||||
|
func (inv *Inventory) depth(group string) int {
|
||||||
|
if group == "all" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
best := -1
|
||||||
|
for name, g := range inv.Groups {
|
||||||
|
for _, child := range g.Children {
|
||||||
|
if child == group {
|
||||||
|
d := inv.depth(name) + 1
|
||||||
|
if best == -1 || d < best {
|
||||||
|
best = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == -1 {
|
||||||
|
return 0 // группа без родителя — считаем что она на уровне all
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupChain возвращает группы хоста в порядке применения group_vars:
|
||||||
|
// от самых общих (all, глубина 0) к самым специфичным; на одном уровне
|
||||||
|
// глубины — по алфавиту, чтобы результат был детерминирован.
|
||||||
|
func (inv *Inventory) GroupChain(hostName string) []string {
|
||||||
|
groups := inv.GroupsOf(hostName)
|
||||||
|
sort.Slice(groups, func(i, j int) bool {
|
||||||
|
di, dj := inv.depth(groups[i]), inv.depth(groups[j])
|
||||||
|
if di != dj {
|
||||||
|
return di < dj
|
||||||
|
}
|
||||||
|
return groups[i] < groups[j]
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupVars отдаёт переменные группы из самого инвентаря, смешанные
|
||||||
|
// с файлами group_vars/<name>.yml и group_vars/<name>/*.yml с диска.
|
||||||
|
func (inv *Inventory) GroupVars(name string) map[string]interface{} {
|
||||||
|
out := map[string]interface{}{}
|
||||||
|
if g, ok := inv.Groups[name]; ok {
|
||||||
|
for k, v := range g.Vars {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range inv.loadVarsFromDisk("group_vars", name) {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostVars — аналогично GroupVars, но для конкретного хоста
|
||||||
|
// (host_vars/<name>.yml или host_vars/<name>/*.yml).
|
||||||
|
func (inv *Inventory) HostVars(name string) map[string]interface{} {
|
||||||
|
out := map[string]interface{}{}
|
||||||
|
for _, g := range inv.Groups {
|
||||||
|
if h, ok := g.Hosts[name]; ok {
|
||||||
|
for k, v := range h.Vars {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range inv.loadVarsFromDisk("host_vars", name) {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadVarsFromDisk грузит либо <kind>/<name>.yml, либо все файлы
|
||||||
|
// в директории <kind>/<name>/*.yml по алфавиту, мерджа их по порядку.
|
||||||
|
func (inv *Inventory) loadVarsFromDisk(kind, name string) map[string]interface{} {
|
||||||
|
out := map[string]interface{}{}
|
||||||
|
|
||||||
|
singleFile := filepath.Join(inv.Dir, kind, name+".yml")
|
||||||
|
if vars, err := loadVarsFile(singleFile); err == nil {
|
||||||
|
for k, v := range vars {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(inv.Dir, kind, name)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".yml") {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, n := range names {
|
||||||
|
if vars, err := loadVarsFile(filepath.Join(dir, n)); err == nil {
|
||||||
|
for k, v := range vars {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
209
internal/parser/playbook.go
Normal file
209
internal/parser/playbook.go
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// известные служебные ключи таска — всё остальное в YAML-мапе
|
||||||
|
// считается именем модуля + его аргументами.
|
||||||
|
var taskMetaKeys = map[string]bool{
|
||||||
|
"name": true, "when": true, "loop": true, "tags": true,
|
||||||
|
"register": true, "notify": true, "allow_shell": true,
|
||||||
|
"after": true, "before": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML у Task разбирает произвольную YAML-мапу: известные поля
|
||||||
|
// (name/when/loop/...) уходят в соответствующие структурные поля,
|
||||||
|
// единственный оставшийся неизвестный ключ — это имя модуля, а его
|
||||||
|
// значение — аргументы модуля.
|
||||||
|
func (t *Task) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
raw := map[string]interface{}{}
|
||||||
|
if err := node.Decode(&raw); err != nil {
|
||||||
|
return fmt.Errorf("decode task: %w", err)
|
||||||
|
}
|
||||||
|
t.Raw = raw
|
||||||
|
|
||||||
|
if v, ok := raw["name"].(string); ok {
|
||||||
|
t.Name = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["when"].(string); ok {
|
||||||
|
t.When = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["loop"]; ok {
|
||||||
|
t.Loop = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["register"].(string); ok {
|
||||||
|
t.Register = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["allow_shell"].(bool); ok {
|
||||||
|
t.AllowShell = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["tags"]; ok {
|
||||||
|
t.Tags = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := raw["notify"]; ok {
|
||||||
|
t.Notify = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := raw["after"]; ok {
|
||||||
|
t.After = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := raw["before"]; ok {
|
||||||
|
t.Before = toStringSlice(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleName, moduleArgs, err := extractModule(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.Module = moduleName
|
||||||
|
t.Args = moduleArgs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractModule находит единственный ключ, не входящий в taskMetaKeys,
|
||||||
|
// и трактует его как имя модуля. Если такой ключ не один — это ошибка
|
||||||
|
// плейбука (двусмысленность, какой модуль на самом деле выполняется).
|
||||||
|
func extractModule(raw map[string]interface{}) (string, map[string]interface{}, error) {
|
||||||
|
var moduleName string
|
||||||
|
found := 0
|
||||||
|
for k := range raw {
|
||||||
|
if taskMetaKeys[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moduleName = k
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
if found == 0 {
|
||||||
|
return "", nil, fmt.Errorf("таск без модуля: %v", raw)
|
||||||
|
}
|
||||||
|
if found > 1 {
|
||||||
|
return "", nil, fmt.Errorf("таск с несколькими модулями сразу — неоднозначно: %v", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := raw[moduleName].(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
return moduleName, v, nil
|
||||||
|
case string:
|
||||||
|
// короткая форма: `shell: "apt-get update"` — заворачиваем в единственный
|
||||||
|
// аргумент "cmd", которого ждут shell/command модули.
|
||||||
|
return moduleName, map[string]interface{}{"cmd": v}, nil
|
||||||
|
case nil:
|
||||||
|
return moduleName, map[string]interface{}{}, nil
|
||||||
|
default:
|
||||||
|
return "", nil, fmt.Errorf("модуль %q: неожиданный тип аргументов %T", moduleName, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringSlice(v interface{}) []string {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
out := make([]string, 0, len(vv))
|
||||||
|
for _, item := range vv {
|
||||||
|
out = append(out, fmt.Sprintf("%v", item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case string:
|
||||||
|
return []string{vv}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPlaybook читает playbook.yml, парсит все play и подгружает роли,
|
||||||
|
// на которые есть ссылки в `roles:`, из директории `roles/` рядом с плейбуком.
|
||||||
|
func LoadPlaybook(path string) (*Playbook, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("читаю плейбук: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var plays []Play
|
||||||
|
if err := yaml.Unmarshal(data, &plays); err != nil {
|
||||||
|
return nil, fmt.Errorf("парсинг плейбука: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pb := &Playbook{
|
||||||
|
Plays: plays,
|
||||||
|
Roles: map[string]*Role{},
|
||||||
|
Dir: filepath.Dir(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
rolesDir := filepath.Join(pb.Dir, "roles")
|
||||||
|
for _, play := range plays {
|
||||||
|
for _, roleName := range play.Roles {
|
||||||
|
if _, ok := pb.Roles[roleName]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role, err := loadRole(rolesDir, roleName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("роль %q: %w", roleName, err)
|
||||||
|
}
|
||||||
|
pb.Roles[roleName] = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRole читает директорийную структуру одной роли:
|
||||||
|
// tasks/main.yml, defaults/main.yml, vars/main.yml, handlers/main.yml.
|
||||||
|
// Отсутствующие файлы — не ошибка, просто пустые значения.
|
||||||
|
func loadRole(rolesDir, name string) (*Role, error) {
|
||||||
|
dir := filepath.Join(rolesDir, name)
|
||||||
|
role := &Role{Name: name, Dir: dir}
|
||||||
|
|
||||||
|
tasks, err := loadTaskFile(filepath.Join(dir, "tasks", "main.yml"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
role.Tasks = tasks
|
||||||
|
|
||||||
|
handlers, err := loadTaskFile(filepath.Join(dir, "handlers", "main.yml"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
role.Handlers = handlers
|
||||||
|
|
||||||
|
role.Defaults, err = loadVarsFile(filepath.Join(dir, "defaults", "main.yml"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
role.Vars, err = loadVarsFile(filepath.Join(dir, "vars", "main.yml"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTaskFile(path string) ([]Task, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tasks []Task
|
||||||
|
if err := yaml.Unmarshal(data, &tasks); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadVarsFile(path string) (map[string]interface{}, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vars := map[string]interface{}{}
|
||||||
|
if err := yaml.Unmarshal(data, &vars); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return vars, nil
|
||||||
|
}
|
||||||
75
internal/parser/types.go
Normal file
75
internal/parser/types.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// Package parser описывает структуры плейбука, инвентаря и ролей
|
||||||
|
// и умеет загружать их из YAML. Никакой логики выполнения тут нет —
|
||||||
|
// только данные и их загрузка с диска.
|
||||||
|
package parser
|
||||||
|
|
||||||
|
// Task — один шаг плейбука или роли.
|
||||||
|
type Task struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Module string `yaml:"-"` // заполняется при разборе (имя ключа модуля)
|
||||||
|
Args map[string]interface{} `yaml:"-"` // аргументы этого модуля
|
||||||
|
When string `yaml:"when,omitempty"`
|
||||||
|
Loop interface{} `yaml:"loop,omitempty"`
|
||||||
|
Tags []string `yaml:"tags,omitempty"`
|
||||||
|
Register string `yaml:"register,omitempty"`
|
||||||
|
Notify []string `yaml:"notify,omitempty"`
|
||||||
|
AllowShell bool `yaml:"allow_shell,omitempty"`
|
||||||
|
|
||||||
|
// After/Before — явные зависимости по имени таска в пределах того же
|
||||||
|
// списка (play.Tasks или role.Tasks), как require/before в Puppet.
|
||||||
|
// Без них порядок остаётся тем же, что в файле (см. internal/executor
|
||||||
|
// graph.go — стабильная топологическая сортировка не переставляет
|
||||||
|
// таски без объявленных зависимостей).
|
||||||
|
After []string `yaml:"after,omitempty"`
|
||||||
|
Before []string `yaml:"before,omitempty"`
|
||||||
|
|
||||||
|
// Raw хранит исходную YAML-map таска — из неё вычленяется Module/Args
|
||||||
|
// на этапе UnmarshalYAML, см. parser.go.
|
||||||
|
Raw map[string]interface{} `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play — один play плейбука: на каких хостах, какими переменными, какие роли/таски.
|
||||||
|
type Play struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Hosts string `yaml:"hosts"`
|
||||||
|
Vars map[string]interface{} `yaml:"vars,omitempty"`
|
||||||
|
Roles []string `yaml:"roles,omitempty"`
|
||||||
|
Tasks []Task `yaml:"tasks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role — загруженная с диска роль: taskи + defaults/vars/handlers/templates.
|
||||||
|
type Role struct {
|
||||||
|
Name string
|
||||||
|
Tasks []Task
|
||||||
|
Defaults map[string]interface{}
|
||||||
|
Vars map[string]interface{}
|
||||||
|
Handlers []Task
|
||||||
|
Dir string // корневая директория роли, нужна для поиска templates/files
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playbook — верхнеуровневая единица: список play + резолвленные роли.
|
||||||
|
type Playbook struct {
|
||||||
|
Plays []Play
|
||||||
|
Roles map[string]*Role // имя роли → загруженная роль
|
||||||
|
Dir string // директория, где лежит сам playbook.yml (для relative include)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host — один хост инвентаря.
|
||||||
|
type Host struct {
|
||||||
|
Name string
|
||||||
|
Vars map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group — группа хостов, может содержать дочерние группы (children).
|
||||||
|
type Group struct {
|
||||||
|
Name string
|
||||||
|
Hosts map[string]*Host
|
||||||
|
Vars map[string]interface{}
|
||||||
|
Children []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory — весь инвентарь: группы + пути к group_vars/host_vars на диске.
|
||||||
|
type Inventory struct {
|
||||||
|
Groups map[string]*Group
|
||||||
|
Dir string // директория инвентаря, для поиска group_vars/host_vars
|
||||||
|
}
|
||||||
268
internal/ssh/client.go
Normal file
268
internal/ssh/client.go
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
// Package ssh — тонкая обвязка над golang.org/x/crypto/ssh: подключение
|
||||||
|
// по ключу или паролю, выполнение команд, become/sudo, запись файлов
|
||||||
|
// на удалённый хост. Никакой модульной логики тут нет — это только
|
||||||
|
// транспорт, которым пользуются builtin- и external-модули.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conn — открытое SSH-соединение с одним хостом.
|
||||||
|
type Conn struct {
|
||||||
|
client *ssh.Client
|
||||||
|
Host string
|
||||||
|
Become bool // выполнять команды через sudo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config описывает, как подключаться к хосту.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int // по умолчанию 22
|
||||||
|
User string
|
||||||
|
PrivateKeyPath string // путь к приватному ключу, если пусто — пробуем ssh-agent
|
||||||
|
Password string // используется только если PrivateKeyPath пуст
|
||||||
|
KnownHostsPath string // пусто — проверка отключена (см. предупреждение ниже)
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial открывает соединение по Config. Если KnownHostsPath не задан,
|
||||||
|
// используется ssh.InsecureIgnoreHostKey — это осознанный компромисс
|
||||||
|
// для первой версии, но должен быть заменён на реальную проверку
|
||||||
|
// перед использованием где-либо, кроме локальных тестов.
|
||||||
|
func Dial(cfg Config) (*Conn, error) {
|
||||||
|
auth, err := buildAuth(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hostKeyCallback := ssh.InsecureIgnoreHostKey()
|
||||||
|
if cfg.KnownHostsPath != "" {
|
||||||
|
cb, err := knownhosts.New(cfg.KnownHostsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("known_hosts: %w", err)
|
||||||
|
}
|
||||||
|
hostKeyCallback = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
port := cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCfg := &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Auth: auth,
|
||||||
|
HostKeyCallback: hostKeyCallback,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
|
||||||
|
client, err := ssh.Dial("tcp", addr, clientCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
return &Conn{client: client, Host: cfg.Host}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAuth(cfg Config) ([]ssh.AuthMethod, error) {
|
||||||
|
if cfg.PrivateKeyPath != "" {
|
||||||
|
keyPath, err := expandHome(cfg.PrivateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("читаю приватный ключ: %w", err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("парсинг приватного ключа: %w", err)
|
||||||
|
}
|
||||||
|
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||||
|
}
|
||||||
|
if cfg.Password != "" {
|
||||||
|
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("не задан ни PrivateKeyPath, ни Password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandHome разворачивает ведущий "~" в путь к домашней директории —
|
||||||
|
// ни os.ReadFile, ни что-либо в стандартной библиотеке не делает этого
|
||||||
|
// само по себе, а путь к ключу в конфиге/флагах CLI обычно пишут с "~".
|
||||||
|
func expandHome(path string) (string, error) {
|
||||||
|
if !strings.HasPrefix(path, "~") {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("не удалось определить домашнюю директорию для %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return home + strings.TrimPrefix(path, "~"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecResult — итог выполнения команды на хосте.
|
||||||
|
type ExecResult struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
ExitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run выполняет команду на хосте. Если c.Become — оборачивает в sudo -n.
|
||||||
|
func (c *Conn) Run(cmd string) (ExecResult, error) {
|
||||||
|
if c.Become {
|
||||||
|
cmd = "sudo -n -- " + cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{}, fmt.Errorf("новая ssh-сессия: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
session.Stdout = &stdout
|
||||||
|
session.Stderr = &stderr
|
||||||
|
|
||||||
|
err = session.Run(cmd)
|
||||||
|
exitCode := 0
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitStatus()
|
||||||
|
} else {
|
||||||
|
return ExecResult{}, fmt.Errorf("выполнение команды: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecResult{
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
ExitCode: exitCode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile записывает content в remotePath с правами mode. Реализовано
|
||||||
|
// через `cat > file` по тому же SSH-каналу — без отдельного SFTP-подключения,
|
||||||
|
// чтобы не тянуть ещё одну зависимость в первой версии. Для больших файлов
|
||||||
|
// и бинарной доставки внешних модулей это стоит заменить на настоящий SFTP
|
||||||
|
// (см. internal/module/external — там уже отдельный контракт).
|
||||||
|
func (c *Conn) WriteFile(remotePath string, content []byte, mode string) error {
|
||||||
|
cmd := fmt.Sprintf("cat > %s && chmod %s %s", shellQuote(remotePath), mode, shellQuote(remotePath))
|
||||||
|
if c.Become {
|
||||||
|
cmd = "sudo -n -- sh -c " + shellQuote(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("новая ssh-сессия: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
session.Stderr = &stderr
|
||||||
|
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := session.Start(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, writeErr := stdin.Write(content)
|
||||||
|
stdin.Close()
|
||||||
|
waitErr := session.Wait()
|
||||||
|
|
||||||
|
// сперва разбираем exit status/stderr удалённой команды — это
|
||||||
|
// содержательная причина (например "Permission denied"), тогда как
|
||||||
|
// ошибка самого stdin.Write (обычно EOF) — лишь её следствие и
|
||||||
|
// без exit-кода ничего не объясняет пользователю.
|
||||||
|
if waitErr != nil {
|
||||||
|
if exitErr, ok := waitErr.(*ssh.ExitError); ok {
|
||||||
|
return fmt.Errorf("запись %s: команда завершилась с кодом %d: %s",
|
||||||
|
remotePath, exitErr.ExitStatus(), strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("запись %s: %w (stderr: %s)", remotePath, waitErr, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
if writeErr != nil {
|
||||||
|
return fmt.Errorf("запись %s: ошибка передачи данных: %w (stderr: %s)",
|
||||||
|
remotePath, writeErr, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile читает содержимое удалённого файла целиком.
|
||||||
|
func (c *Conn) ReadFile(remotePath string) ([]byte, error) {
|
||||||
|
res, err := c.Run(fmt.Sprintf("cat %s", shellQuote(remotePath)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
return nil, fmt.Errorf("cat %s: exit %d: %s", remotePath, res.ExitCode, res.Stderr)
|
||||||
|
}
|
||||||
|
return []byte(res.Stdout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithInput выполняет команду, передавая content в её stdin, и
|
||||||
|
// возвращает результат целиком. Используется external-модулями для
|
||||||
|
// обмена JSON по контракту stdin/stdout (см. internal/module/external).
|
||||||
|
func (c *Conn) RunWithInput(cmd string, content []byte) (ExecResult, error) {
|
||||||
|
if c.Become {
|
||||||
|
cmd = "sudo -n -- " + cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{}, fmt.Errorf("новая ssh-сессия: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
session.Stdout = &stdout
|
||||||
|
session.Stderr = &stderr
|
||||||
|
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{}, err
|
||||||
|
}
|
||||||
|
if err := session.Start(cmd); err != nil {
|
||||||
|
return ExecResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := stdin.Write(content); err != nil {
|
||||||
|
return ExecResult{}, err
|
||||||
|
}
|
||||||
|
stdin.Close()
|
||||||
|
|
||||||
|
err = session.Wait()
|
||||||
|
exitCode := 0
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitStatus()
|
||||||
|
} else {
|
||||||
|
return ExecResult{}, fmt.Errorf("выполнение команды: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) Close() error {
|
||||||
|
return c.client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellQuote — минимальное экранирование пути для подстановки в shell-команду.
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + string(bytes.ReplaceAll([]byte(s), []byte("'"), []byte(`'\''`))) + "'"
|
||||||
|
}
|
||||||
14
internal/template/custom.go
Normal file
14
internal/template/custom.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package template
|
||||||
|
|
||||||
|
import "text/template"
|
||||||
|
|
||||||
|
// CustomFuncs — функции, которых нет в sprig, но есть в конкретных
|
||||||
|
// Ansible-фильтрах, которыми пользуется команда (например ansible.utils.ipaddr).
|
||||||
|
// Пусто в первой версии — заполняется по мере миграции реальных .j2-шаблонов
|
||||||
|
// и обнаружения фильтров без прямого аналога.
|
||||||
|
func CustomFuncs() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
// пример на будущее:
|
||||||
|
// "ipaddr": func(cidr string) (string, error) { ... },
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/template/render.go
Normal file
31
internal/template/render.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Package template рендерит шаблоны файлов (аналог модуля `template` из
|
||||||
|
// Ansible) через стандартный text/template с добавленными функциями
|
||||||
|
// sprig — свой парсер, а не сторонняя реализация Jinja2, чтобы не зависеть
|
||||||
|
// от чужой грамматики.
|
||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/Masterminds/sprig/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render рендерит src (содержимое шаблона) с переменными vars.
|
||||||
|
// name используется только для сообщений об ошибках парсинга.
|
||||||
|
func Render(name, src string, vars map[string]interface{}) (string, error) {
|
||||||
|
tmpl, err := template.New(name).
|
||||||
|
Funcs(sprig.FuncMap()).
|
||||||
|
Funcs(CustomFuncs()).
|
||||||
|
Option("missingkey=error"). // опечатка в имени переменной — ошибка, а не пустая строка
|
||||||
|
Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := tmpl.Execute(&buf, vars); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
58
internal/vars/merge.go
Normal file
58
internal/vars/merge.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Package vars реализует единственную ответственность: для заданного
|
||||||
|
// хоста построить финальный набор переменных, накатывая слои строго
|
||||||
|
// в порядке приоритета Ansible (упрощённом до того, что реально нужно).
|
||||||
|
package vars
|
||||||
|
|
||||||
|
import "github.com/vladimir/goherence/internal/parser"
|
||||||
|
|
||||||
|
// Resolve строит финальные переменные для (хост, роль, play), от самого
|
||||||
|
// низкого приоритета к самому высокому. Порядок совпадает с комментарием
|
||||||
|
// в архитектурном документе — намеренно не абстрагирован дальше, чтобы
|
||||||
|
// последовательность накатывания оставалась видна с первого взгляда.
|
||||||
|
// facts — собранные internal/facts данные о хосте (os_family и
|
||||||
|
// т.п.); в реальном Ansible факты имеют высокий приоритет, но ниже
|
||||||
|
// явных play/role vars и extra-vars — этот порядок здесь и соблюдён.
|
||||||
|
func Resolve(
|
||||||
|
host string,
|
||||||
|
inv *parser.Inventory,
|
||||||
|
play *parser.Play,
|
||||||
|
role *parser.Role,
|
||||||
|
facts map[string]interface{},
|
||||||
|
extraVars map[string]interface{},
|
||||||
|
) map[string]interface{} {
|
||||||
|
result := map[string]interface{}{}
|
||||||
|
|
||||||
|
if role != nil {
|
||||||
|
merge(result, role.Defaults) // 1. role defaults — самый низкий приоритет
|
||||||
|
}
|
||||||
|
merge(result, inv.GroupVars("all")) // 2. group_vars/all
|
||||||
|
for _, g := range inv.GroupChain(host) {
|
||||||
|
if g == "all" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merge(result, inv.GroupVars(g)) // 3. group_vars/<group>, родители раньше потомков
|
||||||
|
}
|
||||||
|
merge(result, inv.HostVars(host)) // 4. host_vars/<host>
|
||||||
|
merge(result, facts) // 4.5. факты хоста (os_family и т.п.)
|
||||||
|
if play != nil {
|
||||||
|
merge(result, play.Vars) // 5. play vars
|
||||||
|
}
|
||||||
|
if role != nil {
|
||||||
|
merge(result, role.Vars) // 6. role vars/main.yml
|
||||||
|
}
|
||||||
|
// 7. set_fact / registered vars — накатываются executor'ом во время
|
||||||
|
// выполнения, а не здесь: Resolve вызывается один раз до старта таска.
|
||||||
|
merge(result, extraVars) // 8. extra-vars — самый высокий приоритет, всегда последний
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge — намеренно плоская перезапись по ключу (dst[k] = src[k]), БЕЗ
|
||||||
|
// рекурсивного deep-merge вложенных map. Ровно так себя ведёт и сам
|
||||||
|
// Ansible по умолчанию (hash_behaviour=replace) — это осознанное решение
|
||||||
|
// для совместимости ожиданий, а не недосмотр.
|
||||||
|
func merge(dst, src map[string]interface{}) {
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
modules.d/hello/hello-linux-amd64
Executable file
BIN
modules.d/hello/hello-linux-amd64
Executable file
Binary file not shown.
BIN
modules.d/hello/hello-linux-arm64
Executable file
BIN
modules.d/hello/hello-linux-arm64
Executable file
Binary file not shown.
10
modules.d/hello/manifest.json
Normal file
10
modules.d/hello/manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "hello",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"schema_version": 1,
|
||||||
|
"architectures": ["linux/amd64", "linux/arm64"],
|
||||||
|
"checksums": {
|
||||||
|
"linux/amd64": "7555da5b213603e4c727ad2525ac3cf4a6c84bc58c2cfb6339fbf07bc13781a6",
|
||||||
|
"linux/arm64": "46d24aba72ac29ae15de1878417f3b2e676fcc1d1d89c5a1e70df3d66aa78e16"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue