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

26 KiB
Raw Permalink Blame History

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 решение, а не тихий обход.

Порядок реализации (примерные вехи)

  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 нужны только когда появится первый реальный сторонний модуль.