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