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