26 KiB
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)
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-контракту)
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)
}
Доставка модуля на хост с кэшированием
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 внешнего модуля (пример)
{
"name": "mymodule",
"version": "1.2.0",
"schema_version": 1,
"architectures": ["linux/amd64", "linux/arm64"],
"checksums": {
"linux/amd64": "sha256:abc123...",
"linux/arm64": "sha256:def456..."
}
}
Шаблонизация (sprig)
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, с вложенными группами)
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)
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
}
Мердж переменных
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 (директория —
для разбиения на несколько файлов) рядом с инвентарём. Стоит повторить оба варианта:
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-структура)
Общий контракт
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/произвольные выражения)
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 там, где есть декларативный модуль
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, ссылающийся сам на себя (эмуляция состояния/счётчиков)
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
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 — цикломатическая сложность роли/плея
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
// перед подключением к хостам — блокирующий шаг, не опциональный
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 решение, а не тихий обход.
Порядок реализации (примерные вехи)
- Skeleton: parser (playbook + inventory) → executor без модулей → SSH-клиент →
shell-модуль. Цель:goherence playbook.yml -i inventory.ymlреально что-то выполняет на хосте. - Builtin-модули: copy, template (sprig), apt, rpm, systemd, file, user — 8-10 самых частых.
- Инвентарь и переменные: вложенные группы (
children), group_vars/host_vars с диска, полная цепочка приоритета из раздела выше,when-условия,loopпо спискам/словарям. - Roles: загрузка директорийной структуры, defaults/vars/handlers,
notify. - External module protocol: JSON stdin/stdout контракт, Registry с резолвом builtin → external, кросс-компиляция модулей под целевые архитектуры, версионирование протокола (
schema_version). - Deploy & cache внешних модулей: manifest.json, детект архитектуры, SFTP-доставка модулей на хосты по требованию, кэш по версии.
- Guardrails (
internal/lint/): 5 правил выше, обязательный блокирующий проход перед подключением к хостам,--forceкак единственный явный обход. - Конвертер j2 → text/template+sprig: скрипт для массовой миграции существующих шаблонов + прогон тестов на них.
Пункты 1–4 — это уже рабочий MVP для внутреннего использования. Пункты 5–6 нужны только когда появится первый реальный сторонний модуль.