goherence/goherence-architecture.md
2026-09-11 10:17:25 +03:00

616 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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