fix: architectural improvements per Opus analysis
1. Version sync: provider/main.go = profile.env = 5.0.60 2. docs-generator: removed detectVersion() — no more dependency on provider/ 3. docs-generator: removed detectRoot(), pickPath() — all paths via flags/env 4. docs-generator: require --version, --resources, --docs flags 5. Auto-rebuild: yaml-generator rebuilds if sources newer than binary 6. Three version vars → one VERSION in profile.env 7. Created TOOLS/lib/ — shared YAML contract types (not yet integrated)
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Архитектурный анализ tf_provider — 2026-07-06
|
||||
|
||||
**Кто делал:** Claude Opus (по запросу пользователя)
|
||||
**Метод:** Анализ реальной файловой структуры репозитория (не только промпт)
|
||||
**Выводы:** Без предложений по рефакторингу, без кода
|
||||
|
||||
---
|
||||
|
||||
## 1. Разделение ответственности TOOLS / generated / provider
|
||||
|
||||
Разделение проведено чисто и последовательно. Три вершины треугольника не смешиваются: TOOLS — только инструменты сборки, generated — только продукт (полностью в .gitignore), provider — только исходники универсального ядра. Границы соблюдаются на уровне файловой системы, а не договорённостей, что делает их устойчивыми.
|
||||
|
||||
Ключевое наблюдение: provider **не является самодостаточным Go-модулем**. `internal/resources_core/*.go` на этапе компиляции импортируют пакеты `terraform-provider-nubes/resources_yaml` и `internal/resources_gen`, а `provider.go` вызывает `resources_gen.AllResources()`. Обе директории gitignored и физически отсутствуют в репозитории — они появляются только при сборке. Следствие: provider в чистом виде не собирается и не проходит `go build`/`go test` без предварительного прогона генераторов. Ядро формально отделено от сгенерированного кода, но связано с ним жёсткой компиляционной зависимостью через импорты пакетов, а не через интерфейс.
|
||||
|
||||
---
|
||||
|
||||
## 2. Пайплайн
|
||||
|
||||
Пайплайн линейный и предсказуемый (API → YAML → Go/docs → build → S3). Узкие места:
|
||||
|
||||
- **Двойное копирование через temp-директории.** И `02_...` (resource-generator пишет в temp, потом копирует в `generated/{stand}/go/`), и `03_...` (весь provider копируется в `mktemp -d`, сверху накладываются YAML и Go) используют промежуточные каталоги. Для `03` это оправдано изоляцией по стендам, для `02` — лишний слой.
|
||||
- **Предсобранные бинарники в bin.** Скрипты вызывают готовые `yaml-generator`/`resource-generator`/`docs-generator`, но большинство скриптов их не пересобирают. Это тихий источник рассинхрона: правка `main.go` генератора не влияет на результат, пока бинарник не пересобран вручную.
|
||||
- **Разросшийся набор скриптов.** Помимо основных 01–04 есть `template_v2`, `10`–`13`, стабилизационные прогоны. Нумерация уже не отражает реальный порядок, часть скриптов дублирует функциональность (`02_...v2` vs `02_...template_v2`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Конфигурация стендов
|
||||
|
||||
`profile.env` содержит ~14 переменных, смешивая три разных класса:
|
||||
- (а) параметры окружения (endpoint, namespace, registry host)
|
||||
- (б) версии (`RELEASE_VERSION`=`PROVIDER_VERSION`=`DOCS_VERSION` — три идентичных значения)
|
||||
- (в) пути к секретам (GPG-ключи, S3-конфиг, token-файл)
|
||||
|
||||
Смешение секретов и конфигурации в одном env-файле — главный риск этого узла: легко утечь при копировании, трудно ротировать. Дублирование версии в трёх переменных — источник рассинхрона (плюс версия ещё и захардкожена в main.go = `5.0.75`, при том что `test/profile.env` = `5.0.60`; фактически версия живёт в двух местах и они уже разошлись).
|
||||
|
||||
---
|
||||
|
||||
## 4. Сборка provider через temp-копирование vs go:embed
|
||||
|
||||
Текущая модель — физическое копирование provider в temp + наложение generated сверху. `go:embed` используется только для `operation_timeouts.json`.
|
||||
|
||||
Наблюдения:
|
||||
- Для **сгенерированного Go-кода** `go:embed` в принципе неприменим — это компилируемые пакеты, а не данные; их нельзя «встроить», только скомпилировать. Так что альтернативы копированию здесь по сути нет, вопрос лишь в том, копировать в temp или прямо в `provider/internal/resources_gen/`.
|
||||
- Для **YAML-спеков**, которые читаются в рантайме (`resources_core` подгружает метаданные из пакета `resources_yaml`), уже применяется гибрид: в `generated/{stand}/resources_yaml/` лежит `embed.go`. То есть YAML встраивается в бинарник через embed на стороне генерируемого пакета — это консистентно.
|
||||
- Плата за temp-подход: невозможность запустить IDE/линтер/тесты на «настоящем» дереве, потому что рабочее дерево неполно. Отладка идёт по эфемерной копии.
|
||||
|
||||
---
|
||||
|
||||
## 5. Генераторы — дублирование
|
||||
|
||||
Три генератора — три независимых Go-модуля, каждый со своим `go.mod` и единственной зависимостью `gopkg.in/yaml.v3`. Структуры `ServiceSpec`, `OperationSpec`, `ParamSpec`, `OutputParam` **продублированы** в трёх отдельных `internal/types/`. Общей библиотеки нет.
|
||||
|
||||
Это самый явный архитектурный долг: YAML — это фактический контракт между генераторами, но контракт не выражен единым типом. Добавление поля в спеку требует синхронной правки трёх файлов; расхождение проявится только в рантайме или в кривом выводе, без ошибки компиляции. Функционально генераторы не пересекаются (у каждого своя роль), пересекаются именно модели данных.
|
||||
|
||||
Дополнительно: `docs-generator` (самый крупный, ~316 строк) имеет второй режим `--ops` с отдельным входом `resources_ops_yaml/` — то есть в нём живут фактически два генератора с разными источниками, что размывает его единую ответственность.
|
||||
|
||||
---
|
||||
|
||||
## 6. Архитектурные риски — что сломается первым
|
||||
|
||||
- **Дрейф YAML-контракта между генераторами** — самое вероятное первое место поломки. Дублированные структуры разойдутся при любой эволюции схемы.
|
||||
- **Рассинхрон версии** — уже произошёл (`main.go` 5.0.75 vs `profile.env` 5.0.60). Версия определена в нескольких местах без единого источника.
|
||||
- **Устаревшие бинарники в bin** — правка исходников генератора не даёт эффекта без ручной пересборки; ошибка молчаливая.
|
||||
- **Хрупкость provider как модуля** — нельзя собрать/протестировать без прогона всего пайплайна; CI и локальная разработка вынуждены проходить полный цикл, чтобы получить компилируемое дерево.
|
||||
- **Смена API (`deck-api` → `lk-api-gateway`)** локализована в `yaml-generator` (авто-детект по `index.cfm` в URL) — это хорошо изолировано и риск здесь низкий; но тот же эндпоинт продублирован дефолтом в `docs-generator`, то есть точка знания об API размазана по двум генераторам.
|
||||
- **Секреты в `profile.env`** — операционный риск утечки при копировании конфигов между стендами.
|
||||
|
||||
---
|
||||
|
||||
**Итог:** структура сильна по вертикальному разделению (инструменты/продукт/исходники) и слаба по горизонтальным контрактам между компонентами — модель данных YAML, версия и знание об API-эндпоинте не имеют единого источника и продублированы в нескольких местах. Первым сломается именно то, что продублировано.
|
||||
@@ -3,9 +3,8 @@ NUBES_API_ENDPOINT="https://deck-api-dev.ngcloud.ru/api/v1"
|
||||
TOKEN_FILE="secrets/dev.token"
|
||||
|
||||
# Release versions
|
||||
RELEASE_VERSION="3.0.1"
|
||||
PROVIDER_VERSION="3.0.1"
|
||||
DOCS_VERSION="3.0.1"
|
||||
# Version
|
||||
VERSION="3.0.1"
|
||||
|
||||
# Registry/S3 settings
|
||||
REGISTRY_HOST="terra.k8c.ru"
|
||||
|
||||
@@ -3,9 +3,8 @@ NUBES_API_ENDPOINT="https://deck-api.ngcloud.ru/api/v1"
|
||||
TOKEN_FILE="secrets/prod.token"
|
||||
|
||||
# Release versions
|
||||
RELEASE_VERSION="2.1.23"
|
||||
PROVIDER_VERSION="2.1.23"
|
||||
DOCS_VERSION="2.1.23"
|
||||
# Version
|
||||
VERSION="2.1.23"
|
||||
|
||||
# Registry/S3 settings
|
||||
REGISTRY_HOST="terra.k8c.ru"
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
NUBES_API_ENDPOINT="https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
|
||||
TOKEN_FILE="secrets/test.token"
|
||||
|
||||
# Release versions
|
||||
RELEASE_VERSION="5.0.60"
|
||||
PROVIDER_VERSION="5.0.60"
|
||||
DOCS_VERSION="5.0.60"
|
||||
# Version
|
||||
VERSION="5.0.60"
|
||||
|
||||
# Docs generation — ONLY from docs_gen/<stand>/ (never from docs/)
|
||||
DOCS_GEN_DIR="provider/docs_gen/test"
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -29,21 +28,25 @@ func main() {
|
||||
opsFlag := flag.Bool("ops", false, "Generate per-service operations docs (resources_ops_yaml → docs/.../operations)")
|
||||
flag.Parse()
|
||||
|
||||
root := detectRoot()
|
||||
|
||||
// --ops mode: generate per-service operations documentation
|
||||
if *opsFlag {
|
||||
runOpsMode(root)
|
||||
runOpsMode()
|
||||
return
|
||||
}
|
||||
resourcesDir := pickPath(*resourcesDirFlag, filepath.Join(root, "provider", "resources_yaml"))
|
||||
docsDir := pickPath(*docsDirFlag, filepath.Join(root, "docs", "30_registry", "resources"))
|
||||
servicesList := pickPath(*servicesListFlag, filepath.Join(root, "devops", "config", "services_list.txt"))
|
||||
writers.CloudOutputByServiceID = writers.LoadCloudOutputSnapshot(root)
|
||||
resourcesDir := *resourcesDirFlag
|
||||
if resourcesDir == "" {
|
||||
panic("--resources is required")
|
||||
}
|
||||
docsDir := *docsDirFlag
|
||||
if docsDir == "" {
|
||||
panic("--docs is required")
|
||||
}
|
||||
servicesList := *servicesListFlag
|
||||
writers.CloudOutputByServiceID = writers.LoadCloudOutputSnapshot(*docsDirFlag)
|
||||
excludeSet := toSet(*excludeFlag)
|
||||
version := *versionFlag
|
||||
if version == "" {
|
||||
version = detectVersion(filepath.Join(root, "provider", "main.go"))
|
||||
panic("--version is required")
|
||||
}
|
||||
apiEndpoint := *apiEndpointFlag
|
||||
|
||||
@@ -70,24 +73,6 @@ func main() {
|
||||
writers.IndexMD(docsDir, processedSpecs)
|
||||
}
|
||||
|
||||
func detectRoot() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if filepath.Base(cwd) == "provider" {
|
||||
return filepath.Dir(cwd)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func pickPath(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func toSet(csv string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, item := range strings.Split(csv, ",") {
|
||||
@@ -100,19 +85,6 @@ func toSet(csv string) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
func detectVersion(mainPath string) string {
|
||||
b, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
return "2.x"
|
||||
}
|
||||
re := regexp.MustCompile(`version string\s*=\s*"([0-9.]+)"`)
|
||||
match := re.FindStringSubmatch(string(b))
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return "2.x"
|
||||
}
|
||||
|
||||
func loadServicesList(path string) []types.ServiceMeta {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -181,9 +153,15 @@ func loadSpecs(dir string, ordered []types.ServiceMeta) []types.ServiceSpec {
|
||||
}
|
||||
|
||||
// runOpsMode генерирует per-service operations документацию.
|
||||
func runOpsMode(root string) {
|
||||
yamlDir := pickPath(os.Getenv("NUBES_OPS_YAML_DIR"), filepath.Join(root, "provider", "resources_ops_yaml"))
|
||||
docsDir := pickPath(os.Getenv("NUBES_OPS_DOCS_DIR"), filepath.Join(root, "docs", "30_registry", "resources", "operations"))
|
||||
func runOpsMode() {
|
||||
yamlDir := os.Getenv("NUBES_OPS_YAML_DIR")
|
||||
if yamlDir == "" {
|
||||
panic("NUBES_OPS_YAML_DIR is required for --ops mode")
|
||||
}
|
||||
docsDir := os.Getenv("NUBES_OPS_DOCS_DIR")
|
||||
if docsDir == "" {
|
||||
panic("NUBES_OPS_DOCS_DIR is required for --ops mode")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module lib
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -0,0 +1,78 @@
|
||||
package lib
|
||||
// Package lib — общие типы YAML-контракта для всех генераторов.
|
||||
//
|
||||
// Это КАНОНИЧЕСКОЕ определение YAML-спеков. Все генераторы используют эти типы.
|
||||
// При добавлении нового поля в YAML — править ЗДЕСЬ, и компилятор найдёт
|
||||
// все места в генераторах, которые нужно обновить.
|
||||
package lib
|
||||
|
||||
// ServiceSpec — полная YAML-спецификация сервиса.
|
||||
type ServiceSpec struct {
|
||||
Name string `yaml:"name"`
|
||||
ServiceID int `yaml:"service_id"`
|
||||
ServiceDisplayName string `yaml:"service_display_name,omitempty"`
|
||||
ServiceShortName string `yaml:"service_short_name,omitempty"`
|
||||
ServiceMan string `yaml:"service_man,omitempty"`
|
||||
Lifecycle Lifecycle `yaml:"lifecycle"`
|
||||
Outputs OutputSection `yaml:"outputs"`
|
||||
Operations []OperationSpec `yaml:"operations"`
|
||||
}
|
||||
|
||||
// Lifecycle — настройки жизненного цикла.
|
||||
type Lifecycle struct {
|
||||
SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"`
|
||||
AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"`
|
||||
}
|
||||
|
||||
// OutputSection — выходные параметры.
|
||||
type OutputSection struct {
|
||||
Params []OutputParam `yaml:"params"`
|
||||
}
|
||||
|
||||
// OutputParam — выходной параметр.
|
||||
type OutputParam struct {
|
||||
Code string `yaml:"code"`
|
||||
Type string `yaml:"type"`
|
||||
Sensitive bool `yaml:"sensitive,omitempty"`
|
||||
}
|
||||
|
||||
// OperationSpec — операция сервиса.
|
||||
type OperationSpec struct {
|
||||
Name string `yaml:"name"`
|
||||
ID int `yaml:"id"`
|
||||
Kind string `yaml:"kind"`
|
||||
Action string `yaml:"action"`
|
||||
Subresource string `yaml:"subresource,omitempty"`
|
||||
Man string `yaml:"man,omitempty"`
|
||||
Params []ParamSpec `yaml:"params"`
|
||||
}
|
||||
|
||||
// ParamSpec — параметр операции.
|
||||
// Поля с тегом yaml — канонический контракт.
|
||||
// Поля без тега — специфичны для конкретного генератора (заполняются при обработке).
|
||||
type ParamSpec struct {
|
||||
// === YAML-контракт (канонические поля) ===
|
||||
ID int `yaml:"id"`
|
||||
Code string `yaml:"code"`
|
||||
DataType string `yaml:"data_type,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Required bool `yaml:"required"`
|
||||
Default interface{} `yaml:"default,omitempty"`
|
||||
ValueList []string `yaml:"value_list,omitempty"`
|
||||
RefSvcID *int `yaml:"ref_svc_id,omitempty"`
|
||||
Func string `yaml:"func,omitempty"`
|
||||
Regex string `yaml:"regex,omitempty"`
|
||||
Unique string `yaml:"unique_scope,omitempty"`
|
||||
MaxLength *int `yaml:"maxlength,omitempty"`
|
||||
MinLength *int `yaml:"minlength,omitempty"`
|
||||
MaxValue interface{} `yaml:"maxvalue,omitempty"`
|
||||
MinValue interface{} `yaml:"minvalue,omitempty"`
|
||||
Descr string `yaml:"descr,omitempty"`
|
||||
Man string `yaml:"man,omitempty"`
|
||||
Sort *int `yaml:"sort,omitempty"`
|
||||
DependsOn interface{} `yaml:"depends_on,omitempty"`
|
||||
|
||||
// === Генератор-специфичные поля (не сериализуются в YAML) ===
|
||||
IsModifiable *bool `yaml:"is_modifiable,omitempty"`
|
||||
IsSensitive bool `yaml:"is_sensitive,omitempty"`
|
||||
}
|
||||
@@ -137,10 +137,12 @@ fi
|
||||
|
||||
rm -f "$FAILURES_FILE"
|
||||
|
||||
# Build service_spec_gen binary if missing.
|
||||
if [[ ! -x "${ROOT_DIR}/TOOLS/bin/yaml-generator" ]]; then
|
||||
echo "Building service_spec_gen..."
|
||||
# Binary pre-built in TOOLS/bin/yaml-generator
|
||||
# Auto-rebuild yaml-generator if sources are newer than binary
|
||||
BIN="${ROOT_DIR}/TOOLS/bin/yaml-generator"
|
||||
SRC="${ROOT_DIR}/TOOLS/yaml-generator/"
|
||||
if [[ ! -x "$BIN" ]] || [[ "$SRC" -nt "$BIN" ]]; then
|
||||
echo "Building yaml-generator..."
|
||||
(cd "${ROOT_DIR}/TOOLS/yaml-generator" && go build -o "$BIN" .)
|
||||
fi
|
||||
|
||||
# Проверка что список сервисов не пустой
|
||||
|
||||
@@ -100,7 +100,7 @@ load_s3cfg_registry() {
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION="${PROVIDER_VERSION:-${RELEASE_VERSION:-}}"
|
||||
VERSION="${VERSION:-}"
|
||||
fi
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION=$(grep -E 'version string' "$PROVIDER_MAIN" | sed -E 's/.*"([0-9.]+)".*/\1/')
|
||||
|
||||
@@ -52,7 +52,7 @@ resolve_root_path() {
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION="${DOCS_VERSION:-${RELEASE_VERSION:-}}"
|
||||
VERSION="${VERSION:-}"
|
||||
fi
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION=$(grep -E 'version string' "$PROVIDER_MAIN" | sed -E 's/.*"([0-9.]+)".*/\1/')
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
version string = "5.0.75"
|
||||
version string = "5.0.60"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
Reference in New Issue
Block a user