docs: пометить отменённый заход модификаторов как LEGACY + исправить ложные факты

- баннеры «ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО» на 4 файла HISTORY/OPUS/2026-09-22_modifier_* и docs/60_strategy/modifier_resources_ideology_and_specification.md
- vIPConfigure: replace-семантика, НЕ накопительная (по тесту docs/ORG_IP_MODIFIER_TEST_2026-09-22.md)
- обновлены ссылки на перенесённые материалы (docs/... -> NOTES/..., HOW_TO/...)
This commit is contained in:
Repinoid
2026-09-24 07:51:25 +03:00
parent d93ff66482
commit 2d8e435dd4
59 changed files with 23 additions and 6 deletions
+194
View File
@@ -0,0 +1,194 @@
<!-- ⛔ LEGACY: deck-api.ngcloud.ru ЗАКРЫВАЕТСЯ. Актуальный API: lk-api-gateway.ngcloud.ru/api/v1/svc -->
# Universal Rebuild — Архитектура и рабочая цепочка (актуально)
Документ для нового чата: описывает, как устроено «универсальное ядро», как формируются YAML‑спеки сервисов и как генерируются Go‑ресурсы. Учитывает ошибки/уроки из текущего чата.
---
## 0) Базовые правила работы
- Не менять существующий Go‑код без явного согласования.
- Новая логика — только новые функции/файлы (если не было явного разрешения на правку).
- Все операции с облаком — read‑only, если отдельно не разрешено создание/удаление.
- Для токенов: новый `access_token` сохранять в `/home/naeel/terra/HH-MM-SS.token`.
---
## 1) Архитектура (слои)
### 1.1 Универсальное ядро (core)
**Папка:** `universal_rebuild/internal/core`
- `client.go` — универсальный клиент API и общий flow операций.
- **Критерий завершения операции:** `dtFinish` (см. комментарии в коде). Логику запрещено менять без согласования.
### 1.2 Провайдер (provider)
**Папка:** `universal_rebuild/internal/provider`
- Конфигурация провайдера, получение токена, подключение ресурсов через реестр.
### 1.3 Сгенерированные ресурсы
**Папка:** `universal_rebuild/internal/resources_gen`
- Автогенерируемые ресурсы Terraform по YAML‑спецификациям.
- `registry.go` (генерируется) — регистрирует все ресурсы.
- `crud.go` — общие CRUD‑хелперы (создание/modify/delete). Этот файл **не генерируется**, его нужно сохранять.
### 1.4 YAML‑спеки ресурсов
**Папка:** `universal_rebuild/resources_yaml`
- YAML для каждого сервиса. Источник истины для генератора ресурсов.
- Формат включает `create.params`, `modify.params`, `lifecycle`.
### 1.5 Генераторы
- **YAML‑генератор без instanceUid**
- `universal_rebuild/tools/service_params_gen/main.go`
- Получает параметры сервиса напрямую через `/index.cfm?endpoint=...`.
- **Go‑генератор**
- `universal_rebuild/tools/gen/main.go`
- Читает YAML и генерирует ресурсы + `registry.go`.
### 1.6 Отдельные modifier-ресурсы
Для parent-level операций `modify`, которые должны выполняться отдельным шагом Terraform-цепочки, используется `kind: modifier`.
Пример:
```yaml
- name: modify
kind: modifier
action: modify
modifier: ip_space
params: []
```
Такой блок не попадает в обычный instance CRUD. Go-генератор создаёт отдельный ресурс с именем `nubes_<service>_<modifier>`. Ресурс принимает ID родительского инстанса и параметры операции, выполняет parent `modify` при Create/Update и читает актуальные значения из `state_params` при Read.
Для `vcOrg` используется modifier `ip_space` с параметром `vIPConfigure`; для `vcNsxt` используется modifier `network` с параметрами операции сетевой настройки. Nested API-параметры modifier-ресурсов передаются как JSON-строки, поэтому их Terraform-значения должны быть валидным JSON.
Удаление modifier пока является no-op: подтверждённого обратного payload для отмены выделенных IP или SNAT нет. Операции удаления родительского сервиса не являются rollback и намеренно не вызываются.
---
## 2) Как получить параметры сервиса (без instanceUid)
Источник описан в:
- `docs/40_analysis/har/discovery/service_parameters_fetch.md`
API‑цепочка:
1) `GET /api/v1/index.cfm?endpoint=/services/{svcId}`
- даёт список операций сервиса (`operations`)
2) `GET /api/v1/index.cfm?endpoint=/serviceOperation/{svcOperationId}`
- даёт `cfsParams` (id, code, type, required, default, valueList, refSvcId, func)
3) (опц.) `GET /api/v1/index.cfm?endpoint=/param-value-list/{svcOperationCfsParamId}`
**Почему так:** прямых эндпойнтов на список параметров по `service_id` нет. Параметры извлекаются из описаний операций.
---
## 3) YAML‑генерация (service_params_gen)
**Файл:** `universal_rebuild/tools/service_params_gen/main.go`
Входные переменные:
- `NUBES_API_TOKEN` (если не задан — берётся из `test_universal/terraform.tfvars`)
- `NUBES_API_ENDPOINT` (по умолчанию `https://deck-api.ngcloud.ru/api/v1/index.cfm`)
- `NUBES_SERVICE_ID` (обязателен)
- `NUBES_SERVICE_NAME` (опц.)
- `NUBES_OUTPUT` (опц.)
Пример:
```bash
cd /home/naeel/terra/universal_rebuild
NUBES_SERVICE_ID=1 NUBES_SERVICE_NAME=dummy go run ./tools/service_params_gen/main.go
```
Результат:
- `/home/naeel/terra/universal_rebuild/resources_yaml/dummy.yaml`
---
## 4) Go‑генерация (tools/gen)
**Файл:** `universal_rebuild/tools/gen/main.go`
Что делает:
- читает все YAML из `resources_yaml/`
- генерирует ресурсы в `internal/resources_gen/`
- генерирует `registry.go`
Команда:
```bash
cd /home/naeel/terra/universal_rebuild
go run ./tools/gen/main.go
```
---
## 5) Build
```bash
cd /home/naeel/terra/universal_rebuild
go build -o terraform-provider-nubes
```
---
## 6) Важные нюансы и ошибки (из опыта чата)
### 6.1 resourceRealm
- `resourceRealm` **должен задаваться пользователем в .tf**, если параметр required.
- Нельзя автоподставлять `serviceName` как realm: API отклонит (пример с Postgres).
### 6.2 Warnings о дубликатах
- Предупреждение о `RESOURCE WITH SAME NAME EXISTS` должно появляться только на create (когда state.ID отсутствует).
- Для managed ресурсов (ID уже есть) предупреждения быть не должно.
### 6.3 Deleted ресурсы
- Если `explainedStatus=deleted` или `isDeleted=true` → ресурс считается отсутствующим, state должен очищаться.
### 6.4 Имена параметров
- Генератор может создавать «разбитые» snake_case для CamelCase (например `resource_c_p_u`).
- Это ожидаемо, но если критично — нужен отдельный маппинг (по согласованию).
---
## 7) Проверенная цепочка (dummy)
1) YAML:
```bash
NUBES_SERVICE_ID=1 NUBES_SERVICE_NAME=dummy go run ./tools/service_params_gen/main.go
```
2) Go‑код:
```bash
go run ./tools/gen/main.go
```
3) Build:
```bash
go build -o terraform-provider-nubes
```
---
## 8) Что делать дальше
- Повторять пункты 3–5 для каждого сервиса:
- `NUBES_SERVICE_ID=13 NUBES_SERVICE_NAME=bucket`
- `NUBES_SERVICE_ID=90 NUBES_SERVICE_NAME=postgres`
- Проверять YAML на корректность required‑параметров, `resourceRealm` и defaults.
- Поднимать ресурсы в `.tf` и тестировать: create / modify / suspend / delete.
---
## 9) Где искать доп. материалы
- Архитектура (старые, но полезные):
- `docs/ai_universal_provider_gen.md`
- `docs/00_overview/ai_universal_provider_gen.md`
- API discovery:
- `docs/40_analysis/har/discovery/service_parameters_fetch.md`
---
**Короткая формула:**
`API (services → serviceOperation)` → `YAML` → `Go resources + registry` → `build` → `tests`.
---
## 10) Политика удаления (карантин)
- Многие инстансы **нельзя удалять сразу**: из-за возможных данных действует **2‑недельный карантин**.
- **Dummy** подпадает под эту политику.
- Для `nubes_dummy` при **destroy** или удалении из манифеста выполняется **`suspend`**, а не `delete`.
- При `apply`, если инстанс dummy в состоянии **suspend**, выполняется **`resume`**.
@@ -0,0 +1,760 @@
<!-- ⛔ LEGACY: deck-api.ngcloud.ru ЗАКРЫВАЕТСЯ. Актуальный API: lk-api-gateway.ngcloud.ru/api/v1/svc -->
# Полный анализ кодовой базы и план развития
**Дата:** 2026-03-13
**Проект:** Terraform Provider for Nubes Cloud
**Версия провайдера:** dev (legacy) / 5.0.18 (universal_rebuild)
**Go:** 1.24 / Terraform Plugin Framework: v1.16 (legacy), v1.8 (rebuild)
---
## Содержание
1. [Общая архитектура](#1-общая-архитектура)
2. [Инвентаризация кода](#2-инвентаризация-кода)
3. [Критические проблемы (P0)](#3-критические-проблемы-p0)
4. [Серьёзные проблемы (P1)](#4-серьёзные-проблемы-p1)
5. [Средний приоритет (P2)](#5-средний-приоритет-p2)
6. [Анализ по слоям](#6-анализ-по-слоям)
7. [Эволюция API-клиента](#7-эволюция-api-клиента)
8. [Генератор кода v2](#8-генератор-кода-v2)
9. [Соответствие provider_philosophy.md](#9-соответствие-provider_philosophymd)
10. [Тестирование](#10-тестирование)
11. [Безопасность](#11-безопасность)
12. [Дорожная карта (Roadmap)](#12-дорожная-карта-roadmap)
13. [Рекомендации по агенту/модели](#13-рекомендации-по-агентумодели)
---
## 1. Общая архитектура
### Два провайдера в одном репозитории
| Компонент | Каталог | Версия | Registry Address | Статус |
|-----------|---------|--------|------------------|--------|
| **Legacy Provider** | `/internal/`, `/main.go` | dev | `registry.terraform.io/nubes/nubes` | Ручной код, 13 ресурсов |
| **Universal Provider** | `/universal_rebuild/` | 5.0.18 | `registry.kube5s.ru <!-- ⛔ LEGACY: registry.kube5s.ru ЗАКРЫТ. Актуальный хост: tf-registry.containerk8s.services.ngcloud.ru --> <!-- ⛔ LEGACY: registry.kube5s.ru <!-- ⛔ LEGACY: registry.kube5s.ru ЗАКРЫТ. Актуальный хост: tf-registry.containerk8s.services.ngcloud.ru --> ЗАКРЫТ. Актуальный хост: tf-registry.containerk8s.services.ngcloud.ru -->/nubes/nubes` | Генерируемый, ~50 ресурсов |
### Архитектурные слои
```
┌─────────────────────────────────────────────────┐
│ TERRAFORM CLI / HCL │
├─────────────────────────────────────────────────┤
│ Provider Layer (provider.go) │
│ ├── Resource Registration │
│ ├── Auth (token / env / file) │
│ └── HTTP Client Init │
├─────────────────────────────────────────────────┤
│ Resource Layer │
│ ├── Manual (vm, edge, vdc, vapp, postgres…) │ ← legacy, internal/provider/
│ └── Generated (50+ services) │ ← universal_rebuild/internal/resources_gen/
├─────────────────────────────────────────────────┤
│ Core Layer │
│ ├── UniversalClient (API V6 flow) │
│ ├── Instance Lookup / State │
│ ├── Operation Runner / Polling │
│ └── Timeout Management │
├─────────────────────────────────────────────────┤
│ CRUD Layer (resources_core/) │
│ ├── CreateResource / UpdateResource / Delete │
│ ├── adoptExistingInstanceOnCreate() │
│ ├── State Refresh / Output Mapping │
│ └── Params Compare / Ref Resolution │
├─────────────────────────────────────────────────┤
│ Nubes Cloud API (deck-api.ngcloud.ru/api/v1) │
└─────────────────────────────────────────────────┘
```
### Конвейер генерации
```
API (live) YAML specs Go code + Docs
│ │ │
▼ ▼ ▼
01_generate_yamls.sh → resources_yaml/*.yaml → 02_generate_*.sh
(service_ops_gen) (gen_v2 + docs_template_gen_v2)
│
▼
03_build_and_upload.sh → S3
04_build_and_publish_docs.sh → S3
```
---
## 2. Инвентаризация кода
### Legacy Provider (`internal/`)
| Файл | LOC | Назначение | Качество |
|------|-----|------------|----------|
| `provider/provider.go` | 110 | Регистрация, auth, HTTP клиент | ⚠️ InsecureSkipVerify |
| `provider/client_impl.go` | ~350 | HTTP-клиент (NubesClient) | Без retry, без polling |
| `provider/vm_resource.go` | ~500 | VM lifecycle (лучший ресурс) | ✅ Полный CRUD + import |
| `provider/edge_resource.go` | ~400 | Edge gateway | ✅ Полный flow |
| `provider/vdc_resource.go` | ~300 | VDC с triggers | ✅ Multi-stage create |
| `provider/vapp_resource.go` | ~250 | vApp | ✅ Базовый CRUD |
| `provider/postgres_resource.go` | ~200 | Postgres | ⚠️ Только create/delete |
| `provider/s3bucket_resource.go` | ~200 | S3 bucket | ✅ CRUD |
| `provider/pgadmin_resource.go` | ~150 | PgAdmin | ⚠️ Ограниченный |
| `provider/org_resource.go` | ~100 | Organization | ⚠️ Минимальный |
| `provider/quickstart_resource.go` | ~300 | Full-stack helper | Специальный |
| `provider/tubulus_resource.go` | ~200 | AI/Gemini интеграция | Экспериментальный |
| `provider/validators.go` | ~150 | Валидаторы | ✅ Хорошо |
| `core/client.go` | 1065 | UniversalClient (V1–V6) | 🔴 Раздут, 6 версий |
| `core/instance_lookup.go` | 87 | Поиск инстансов | ⚠️ Нет exact match |
| `core/instance_ops.go` | 159 | Операции + polling | ⚠️ Fixed 5s interval |
| **Итого** | **~4500** | | |
### Universal Rebuild (`universal_rebuild/`)
| Каталог | Файлов | LOC | Назначение |
|---------|--------|-----|------------|
| `internal/core/` | 5 | ~1400 | UniversalClient V6, timeouts, refSvc |
| `internal/provider/` | 2 | ~300 | Provider setup, timeout embed |
| `internal/resources_core/` | 16 | ~2500 | CRUD, state refresh, diagnostics, params |
| `internal/resources_gen/` | ~100+ | ~15000+ | Сгенерированные ресурсы (50 сервисов) |
| `resources_yaml/` | ~50 | — | YAML-спецификации сервисов |
| `tools/gen_v2/` | 1 | ~2500 | Генератор Go-кода |
| `tools/docs_template_gen_v2/` | 1 | ~800 | Генератор документации |
| `tools/service_ops_gen/` | 1 | ~800 | API → YAML генератор |
| `tools/service_params_gen/` | 1 | ~600 | Параметрический генератор |
| **Итого** | **~180** | **~24000+** | |
### Generated Code Summary (`resources_gen/`)
| Тип ресурса | Кол-во | Примеры |
|-------------|--------|---------|
| Instance (CRUD) | ~50 | postgres, rabbitmq, kafka, k8s, vc_vm |
| Subresource (user/db) | ~20 | postgres_user, postgres_database |
| Action (restart/etc.) | ~10 | postgres_restart, postgres_recovery |
| Registry | 1 | registry.go (auto-сгенерированный список) |
---
## 3. Критические проблемы (P0)
### P0-1: InsecureSkipVerify=true в production
**Где:** `internal/provider/provider.go:103`
```go
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // ← MITM уязвимость
}
```
**Риск:** Атака "человек посередине" (MITM) — перехват API-токенов и данных.
**Решение:**
```go
// Новый атрибут провайдера:
"insecure": schema.BoolAttribute{
Optional: true,
Description: "Skip TLS certificate verification (dev only)",
},
// + env var NUBES_INSECURE
```
**Также проверить:** `universal_rebuild/internal/provider/provider.go` — аналогичная проблема.
---
### P0-2: Захардкоженный путь debug-лога
**Где:** `internal/core/client.go:18`
```go
f, err := os.OpenFile("/home/naeel/terra/debug_nubes.log", ...)
```
**Риск:** Сбой на любой другой машине. Потенциальная утечка данных в файл вне проекта.
**Решение:**
- Использовать `tflog` (terraform plugin logging) вместо файлового лога
- Или env var `NUBES_DEBUG_LOG` с fallback на `/tmp/nubes_debug.log`
---
### P0-3: Ноль автотестов
**Факт:** В репозитории не найдено ни одного `*_test.go` файла.
**Риск:**
- Регрессии при правках генератора
- Невозможно валидировать lifecycle-логику без ручной проверки
- Нет CI/CD confidence
**Решение:** См. раздел [10. Тестирование](#10-тестирование).
---
### P0-4: 6 версий CreateGenericInstance в одном файле
**Где:** `internal/core/client.go` — 1065 строк, 6 методов.
| Версия | Строки | Статус |
|--------|--------|--------|
| V1 `CreateGenericInstance` | 52-168 | Legacy, не используется |
| V2 `...Universal` | 195-334 | Legacy |
| V3 `...UniversalV2` | 361-504 | Legacy |
| V4 `...UniversalV3` | 531-676 | Legacy |
| V5 `...UniversalV4` | 703-840 | Legacy |
| V6 `...UniversalV5` | 867-1000 | Production |
**Риск:** Путаница — какой метод вызывать? Разная нормализация. Разные баги.
**Решение:**
- V1–V5 — пометить `// Deprecated: use CreateGenericInstanceUniversalV5`
- Убедиться, что все ресурсы используют V6/V5
- В перспективе — удалить мёртвый код (после аудита вызовов)
---
## 4. Серьёзные проблемы (P1)
### P1-1: Lifecycle-флаги — legacy vs. canonical
**Требование (provider_philosophy.md §7-9):**
- `adopt_existing_on_create` (default: `false`)
- `suspend_on_destroy` (default: `true`)
**Реальность в legacy:**
- `internal/generated/bolvan_resource_universal_lifecycle.go` использует `delete_mode` и `resume_if_exists`
- Это прямо запрещено в стратегии
**Реальность в universal_rebuild:**
- `resources_core/crud.go` использует `resumeIfExists` bool параметр
- Генератор `gen_v2` генерирует канонические флаги `suspend_on_destroy`, `adopt_existing_on_create`
- **Разрыв:** CRUD-слой принимает bool, но не полностью реализует decision matrix из §7
**Решение:**
1. Обновить `crud.go` — полная реализация status-matrix:
- `not created` → hard error
- `creating/pending/failed` → hard error
- `suspend` + `adopt=false` → hard error с диагностикой
- `running` + `adopt=true` → adopt (import)
- `running` + `adopt=false` → hard error "already exists"
2. Legacy bolvanka — отдельная задача, не трогать
---
### P1-2: Read() — стабы в сгенерированных ресурсах
**Проблема:** Многие сгенерированные ресурсы имеют пустой `Read()`.
**Последствия:**
- Terraform не видит state drift (облако изменилось, TF state устарел)
- `terraform plan` после `apply` показывает расхождения
- `terraform import` бесполезен без Read
**Уже решено в universal_rebuild?**
Да, `state_refresh.go:RefreshResourceState()` обеспечивает полный read-back. Но нужно убедиться, что все сгенерированные ресурсы этот метод ВЫЗЫВАЮТ в своём Read().
---
### P1-3: Нет retry/backoff для API-вызовов
**Где:** Все HTTP-вызовы через `doRequest()` — один попытка, без retry.
**Реальный сценарий:**
- API вернул 503 (maintenance) → terraform apply упал
- Сетевой timeout → terraform apply упал
- Rate limit (429) → terraform apply упал
**Решение:**
```go
// Добавить в core/client.go
func (c *UniversalClient) doRequestWithRetry(ctx context.Context, ...) (*http.Response, error) {
maxRetries := 3
backoff := 2 * time.Second
for attempt := 0; attempt <= maxRetries; attempt++ {
resp, err := c.doRequest(ctx, ...)
if err == nil && resp.StatusCode < 500 && resp.StatusCode != 429 {
return resp, nil
}
if attempt < maxRetries {
time.Sleep(backoff * time.Duration(1<<attempt)) // exponential
}
}
return lastResp, lastErr
}
```
---
### P1-4: Polling с фиксированным интервалом 5с
**Где:** `instance_ops.go:81` — `time.NewTicker(5 * time.Second)`
**Проблема:**
- Для быстрых операций (suspend ~10с) — 5с интервал нормально
- Для долгих (create VM ~5мин) — 5с создаёт лишние API-запросы
- Нет adaptive polling (увеличение интервала со временем)
**Решение:**
```go
// Adaptive polling: 3s → 5s → 10s → 15s → 30s (max)
intervals := []time.Duration{3*time.Second, 5*time.Second, 10*time.Second, 15*time.Second, 30*time.Second}
```
---
### P1-5: FindInstanceByDisplayName — неточный поиск
**Где:** `instance_lookup.go:29`
**Проблема:** Ищет подстрокой по `displayName`, нет exact match. Если есть "mydb" и "mydb-test", может вернуть неверный инстанс.
**Решение:** Добавить exact match фильтр после получения результатов:
```go
if instance.DisplayName == displayName { // exact match
return instance, nil
}
```
---
## 5. Средний приоритет (P2)
### P2-1: Дублирование client.go между legacy и universal_rebuild
Два файла `client.go`:
- `internal/core/client.go` (1065 строк, V1–V6)
- `universal_rebuild/internal/core/client.go` (1034 строк, V6 + refSvc)
**Решение:** Legacy provider постепенно заменяется universal_rebuild. Не рефакторить — просто фиксировать (freeze) legacy.
---
### P2-2: Update() не реализован в большинстве ресурсов
Сгенерированные ресурсы через `gen_v2` уже генерируют Update() если у сервиса есть `modify` операция. Ручные ресурсы (edge, vdc, org) — нет.
**Решение:** Для ручных ресурсов — оставить как есть (ForceNew), если modify не критичен. Для generated — уже работает.
---
### P2-3: Нет валидации YAML-спецификаций
Генератор `01_generate_yamls.sh` → YAML → `gen_v2` — нет промежуточной валидации YAML на корректность/полноту.
**Решение:** Добавить JSON Schema для YAML-спецификаций и валидировать перед генерацией.
---
### P2-4: Нормализация параметров — разные стратегии
| Версия | Стратегия normalization |
|--------|------------------------|
| V1-V2 | empty → empty |
| V3-V4 | empty → `{}` (map) / `[]` (array) |
| V5-V6 | TrimSpace + null-string + `\"\"` → empty |
**Решение:** Зафиксировать V6 behavior как единственный стандарт. Задокументировать.
---
### P2-5: Отсутствие structured logging
- `tflog` используется, но нет единого формата
- Нет trace ID / correlation ID для chain запросов
- Debug-лог в файл вместо terraform framework
**Решение:** Стандартизировать tflog с трейсами:
```go
tflog.Debug(ctx, "API request", map[string]interface{}{
"method": "POST",
"url": url,
"instance_uid": uid,
"operation": opName,
})
```
---
## 6. Анализ по слоям
### 6.1 Provider Layer
| Аспект | Legacy | Universal Rebuild |
|--------|--------|-------------------|
| Auth | token/env/file ✅ | token/env/file ✅ |
| TLS | InsecureSkipVerify 🔴 | InsecureSkipVerify 🔴 |
| HTTP Timeout | 300s | Configurable ✅ |
| Resources | 13 (ручные) | 50+ (генерируемые) |
| Schema | Hand-written | Generated from YAML ✅ |
### 6.2 Resource Layer
**Legacy ресурсы (ручные):**
- VM — лучший: полный CRUD, import, status polling, рабочие timeout'ы
- Edge — хороший: multi-step create, operation discovery
- VDC — хороший: triggers для пересоздания
- Postgres, PgAdmin, Org — минимальные, часть Methods не реализованы
**Universal ресурсы (generated):**
- Полный CRUD для instance если есть create/modify/suspend operations
- Subresource (user, database) с ForceNew если нет modify
- Action (restart, recovery) с trigger field `run_id`
- Lifecycle flags: `suspend_on_destroy`, `adopt_existing_on_create` ✅
- State refresh через `RefreshResourceState` ✅
### 6.3 Core Layer
**Сильные стороны:**
- Единый API flow (V6): create → init op → send params → validate → run → wait
- Timeout management с override per-service
- RefSvc resolution (linked services)
- Operation polling с dtFinish проверкой
**Слабые стороны:**
- Нет retry / circuit breaker
- Фиксированный polling interval
- 6 версий Create в legacy (мусор)
- Debug log в файл (hardcoded path)
### 6.4 CRUD Layer (`resources_core/`)
**Сильные стороны:**
- `adoptExistingInstanceOnCreate()` — базовая adopt-логика
- `RefreshResourceState` — generic state sync с type conversion
- `RequiredParamsMismatch()` — сравнение params при adopt
- `resource_diagnostics.go` — форматированные диагностики
**Слабые стороны:**
- Decision matrix из philosophy § 7 не полностью реализована
- Delete behavior: "state_only" по умолчанию если пустая строка — нарушает `suspend_on_destroy=true` default
- Нет обработки статусов `creating`, `pending`, `failed`
---
## 7. Эволюция API-клиента
```
V1 (simple)
│ + server defaults
V2 (Universal)
│ + name/code hints для normalization
V3 (UniversalV2)
│ + label field
V4 (UniversalV3)
│ + TrimSpace, null → empty
V5 (UniversalV4)
│ + debug file logging, `\"\"` handling
V6 (UniversalV5) ← PRODUCTION (legacy provider)
│
└─→ V6 Rebuilt ← PRODUCTION (universal_rebuild)
+ refSvc resolution
+ configurable timeouts
+ ensureInstanceCreated()
+ operation timeout per-service
```
**Рекомендация:**
- Legacy V1-V5 → пометить deprecated, не удалять (immutability policy)
- В перспективе: legacy provider замораживается, весь development идёт в `universal_rebuild`
---
## 8. Генератор кода v2
### Архитектура генератора
```
tools/gen_v2/generate_resources_v2.go (~2500 LOC)
│
├── loadSpecs() → Парсинг YAML per-service
│ ├── Instance operations → GenResource
│ ├── Subresource operations → GenSubresource
│ └── Action operations → GenAction
│
├── writeInstanceResource() → Go template → *_resource.go
├── writeSubresource() → Go template → *_subresource.go
├── writeActionResource() → Go template → *_action.go
└── writeRegistry() → registry.go (список всех ресурсов)
```
### Что генерирует
Для каждого сервиса от 1 до 3 файлов:
1. **Instance Resource** (`90_postgres_resource.go`):
- `Schema()` — из YAML params (create + modify)
- `Create()` → `resources_core.CreateResourceWithTimeout()`
- `Read()` → `resources_core.RefreshResourceState()`
- `Update()` → `resources_core.UpdateResourceWithTimeout()` (если есть modify)
- `Delete()` → `resources_core.DeleteResourceWithTimeout()`
- `ImportState()` → passthrough ID
2. **Subresource** (`90_postgres_user_resource.go`):
- ForceNew для всех params (если нет modify_user)
- Create → `RunOperationByCode("create_user")`
- Delete → `RunOperationByCode("delete_user")`
3. **Action** (`90_postgres_restart.go`):
- Trigger field `run_id` (PlanModifier: UseStateForUnknown)
- Create → `RunOperationByCode("restart")`
### Сильные стороны генератора
- ✅ Единый YAML → Go pipeline
- ✅ Canonical lifecycle flags (`suspend_on_destroy`, `adopt_existing_on_create`)
- ✅ RefSvc resolution для linked services
- ✅ ForceNew detection для create-only params
- ✅ Type mapping (string/bool/int64)
- ✅ Auto-registry generation
### Слабые стороны
- ❌ Не генерирует `*_test.go`
- ❌ Нет go fmt / go vet на generated output
- ❌ Нет YAML schema validation перед генерацией
- ❌ Нет diff-отчёта (что изменилось при регенерации)
- ⚠️ Template embedded inline (не отдельные `.tmpl` файлы) — сложно поддерживать при росте
---
## 9. Соответствие provider_philosophy.md
### Матрица статусов (§7)
| Статус инстанса | adopt=false | adopt=true | Реализовано? |
|-----------------|-------------|------------|--------------|
| Not found / Deleted | Create | Create | ✅ |
| Suspended | **Hard error** | Resume + Adopt | ⚠️ Частично |
| Running | **Hard error** | Adopt (import) | ⚠️ Частично |
| Not Created | **Hard error** | **Hard error** | ❌ Нет проверки |
| Creating/Pending | **Hard error** | **Hard error** | ❌ Нет проверки |
| Failed | **Hard error** | **Hard error** | ❌ Нет проверки |
### Destroy behavior (§7)
| Флаг | Действие | Реализовано? |
|------|----------|--------------|
| `suspend_on_destroy=true` (default) | Suspend | ✅ |
| `suspend_on_destroy=false` | State-only | ✅ |
### Diagnostics format (§8)
**Требуется:**
```
resource_name: mydb
service_id: 90
instance_uid: abc-123
status: running
status_raw: Running
flag: adopt_existing_on_create = false
decision: Error — instance already exists
action: Set adopt_existing_on_create = true or rename resource
```
**Реализовано:** `resource_diagnostics.go` реализует multi-line format, но не все поля и не все кейсы.
### GAP Analysis
| Требование | Статус | Файл |
|------------|--------|------|
| Canonical flags in schema | ✅ | gen_v2 templates |
| Decision matrix on Create | ⚠️ 60% | crud.go |
| Hard error on "Not Created" | ❌ | crud.go |
| Hard error on "Creating/Pending/Failed" | ❌ | crud.go |
| Multi-line diagnostics | ⚠️ 70% | resource_diagnostics.go |
| Param mismatch check on adopt | ✅ | required_params_compare.go |
| Plan messages with cloud status | ❌ | Not implemented |
---
## 10. Тестирование
### Текущее состояние
**Тестовых файлов:** 0
**Unit tests:** 0
**Integration tests:** 0
**Acceptance tests:** 0
### План тестирования
#### Фаза 1: Unit Tests для Core (приоритет — P0)
| Тест | Файл | Покрытие |
|------|------|----------|
| `TestNormalizeUniversalValue` | `core/client_test.go` | Нормализация всех типов |
| `TestIsInstanceDeleted` | `core/instance_lookup_test.go` | Все статусы |
| `TestIsStatusSuspended` | `core/crud_test.go` | Edge cases |
| `TestAdoptLogicMatrix` | `core/crud_test.go` | Все комбинации status × adopt flag |
| `TestParamsMismatch` | `core/params_compare_test.go` | Сравнение params |
| `TestRefreshResourceState` | `core/state_refresh_test.go` | Type conversion |
| `TestOperationTimeoutParsing` | `core/timeouts_test.go` | Config loading |
| `TestResolveRefSvcParam` | `core/refsvc_test.go` | UUID ↔ display_name |
**Оценка:** ~40 test cases, ~800 LOC
#### Фаза 2: Integration Tests (приоритет — P1)
| Тест | Описание |
|------|----------|
| `TestCreateAndDeleteInstance` | Full lifecycle на test stand |
| `TestAdoptExistingInstance` | Create → suspend → re-create with adopt=true |
| `TestModifyInstance` | Create → modify → verify params changed |
| `TestSubresourceLifecycle` | User create → delete |
| `TestActionExecution` | Restart trigger |
**Требуется:** Test stand (dev profile) + test service (dummy/bolvanka)
#### Фаза 3: Acceptance Tests (приоритет — P2)
```bash
TF_ACC=1 go test ./internal/... -v -run TestAcc
```
С реальным Terraform CLI: plan → apply → verify → destroy.
---
## 11. Безопасность
### Текущие уязвимости
| # | Уязвимость | OWASP | Severity | Где |
|---|-----------|-------|----------|-----|
| S1 | InsecureSkipVerify=true | A07:Crypto Failures | CRITICAL | provider.go:103 |
| S2 | Hardcoded debug log path | A05:Security Misconfig | HIGH | core/client.go:18 |
| S3 | Нет input validation на API responses | A03:Injection | MEDIUM | core/client.go |
| S4 | Token в памяти без rotation | A07:Auth Failures | MEDIUM | provider.go |
| S5 | Нет rate limiting | A04:Insecure Design | LOW | core/client.go |
| S6 | Secrets в репозитории (secrets/) | A05:Security Misconfig | HIGH | secrets/ |
### Рекомендации по безопасности
1. **S1:** `insecure` flag в provider schema (default: false), env var `NUBES_INSECURE`
2. **S2:** Удалить файловый debug log, использовать только `tflog`
3. **S3:** Валидировать JSON responses на ожидаемые поля
4. **S6:** Перенести secrets в vault / CI variables, добавить в `.gitignore`
---
## 12. Дорожная карта (Roadmap)
### Фаза 0: Стабилизация (текущая — P0 fixes)
| # | Задача | Объём | Зависимости |
|---|--------|-------|-------------|
| 0.1 | Сделать InsecureSkipVerify конфигурируемым | 25 LOC | — |
| 0.2 | Заменить hardcoded debug log на tflog/env var | 15 LOC | — |
| 0.3 | Пометить V1–V5 CreateGenericInstance deprecated | Комментарии | — |
| 0.4 | Unit tests для core layer (Фаза 1) | ~800 LOC | — |
| 0.5 | Добавить secrets/ в .gitignore | 1 строка | — |
### Фаза 1: Compliance с provider_philosophy.md
| # | Задача | Объём | Зависимости |
|---|--------|-------|-------------|
| 1.1 | Полная decision matrix в crud.go | ~100 LOC | 0.4 |
| 1.2 | Hard error для "Not Created", "Creating", "Failed" | ~50 LOC | 1.1 |
| 1.3 | Multi-line diagnostics для всех кейсов | ~100 LOC | 1.1 |
| 1.4 | Plan messages с cloud status | ~80 LOC | 1.1 |
| 1.5 | Integration test для adopt matrix | ~200 LOC | 1.1 |
### Фаза 2: Надёжность
| # | Задача | Объём | Зависимости |
|---|--------|-------|-------------|
| 2.1 | Retry с exponential backoff для API-вызовов | ~80 LOC | — |
| 2.2 | Adaptive polling intervals | ~40 LOC | — |
| 2.3 | Exact match в FindInstanceByDisplayName | ~10 LOC | — |
| 2.4 | YAML schema validation перед генерацией | ~200 LOC | — |
| 2.5 | go fmt + go vet в pipeline генерации | ~10 LOC | — |
### Фаза 3: Масштабирование
| # | Задача | Объём | Зависимости |
|---|--------|-------|-------------|
| 3.1 | Генерация *_test.go в gen_v2 | ~500 LOC | 2.4 |
| 3.2 | Diff-отчёт при регенерации | ~200 LOC | — |
| 3.3 | Acceptance tests (TF_ACC) | ~500 LOC | 1.5 |
| 3.4 | Structured logging с trace ID | ~150 LOC | — |
| 3.5 | CI pipeline (build → test → publish) | Config | 3.1, 3.3 |
### Фаза 4: Production Hardening
| # | Задача | Объём | Зависимости |
|---|--------|-------|-------------|
| 4.1 | Заморозить legacy provider (internal/) | Процесс | 3.5 |
| 4.2 | Миграция ручных ресурсов в universal_rebuild | Большая | 4.1 |
| 4.3 | Мониторинг generation stability | Infra | 3.5 |
| 4.4 | Нагрузочное тестирование (параллельный apply) | ~200 LOC | 3.3 |
---
## 13. Рекомендации по агенту/модели
### Выбор модели для разных задач
| Тип задачи | Рекомендуемый агент | Почему |
|-------------|---------------------|--------|
| **Архитектурные решения** | Claude Opus 4.6 (текущий) | Глубокий контекст, сложная логика |
| **Отладка сложных багов** | Claude Opus 4.6 | Лучше держит контекст, видит неочевидные связи |
| **Анализ codebase, code review** | Claude Opus 4.6 | Качество анализа выше |
| **Генерация Go-кода по шаблонам** | Claude Sonnet 4.6 | Достаточно для шаблонного кода, экономичнее |
| **Написание тестов** | Claude Sonnet 4.6 | Шаблонная работа |
| **Массовые правки в генераторе** | Claude Sonnet 4.6 | Достаточно контекста в одном файле |
| **Правки shell-скриптов** | Claude Sonnet 4.6 | Простые правки |
| **Документация** | Claude Sonnet 4.6 | Текстовая генерация |
| **Lifecycle state machine** | Claude Opus 4.6 | Сложные state transitions |
| **API reverse-engineering** | Claude Opus 4.6 | Нужен глубокий анализ HAR/JSON |
### Стратегия переключения
1. **Планирование и design review** → Opus 4.6
2. **Имплементация запланированного** → Sonnet 4.6
3. **Баг не воспроизводится / непонятная причина** → Opus 4.6
4. **Регенерация и routine CI** → Sonnet 4.6
### Практический совет
Для текущей фазы развития (стабилизация + compliance):
- **Opus 4.6** для задач 1.1–1.4 (lifecycle decision matrix — сложная логика)
- **Sonnet 4.6** для задач 0.1–0.5, 2.1–2.5, 3.1 (шаблонные правки, тесты)
---
## Приложение A: Ключевые файлы для изучения
| Приоритет | Файл | Зачем |
|-----------|------|-------|
| ★★★ | `docs/60_strategy/provider_philosophy.md` | Канон lifecycle-логики |
| ★★★ | `universal_rebuild/internal/resources_core/crud.go` | CRUD + adopt-логика |
| ★★★ | `universal_rebuild/internal/core/client.go` | API V6 flow |
| ★★☆ | `universal_rebuild/tools/gen_v2/generate_resources_v2.go` | Генератор |
| ★★☆ | `universal_rebuild/internal/resources_core/state_refresh.go` | State sync |
| ★★☆ | `universal_rebuild/internal/resources_core/resource_diagnostics.go` | Диагностики |
| ★☆☆ | `devops/ARCHITECTURE.md` | Build pipeline design |
| ★☆☆ | `internal/core/client.go` | Legacy reference (V1–V6) |
## Приложение B: Команды для быстрого старта
```bash
# Проверка компиляции (universal_rebuild)
cd universal_rebuild && go build ./...
# Проверка компиляции (legacy)
cd /home/naeel/remote_dev/terraform && go build ./...
# Генерация YAML из API
cd devops && bash 01_generate_yamls.sh
# Генерация Go-кода из YAML
cd devops && bash 02_generate_resources_and_docs_template_v2.sh
# Сборка провайдера
cd devops && bash 03_build_and_upload_provider.sh
# Публикация документации
cd devops && bash 04_build_and_publish_docs.sh
```
---
*Документ сформирован автоматически на основе полного анализа кодовой базы. Обновлять при существенных архитектурных изменениях.*
@@ -0,0 +1,63 @@
# Отчет о проблеме: Ошибка 500 при получении cfsParams для vc_vdc и план исправления
## 1. Проблема
При создании виртуального дата-центра `nubes_vc_vdc` (сервис 21 `vc_vdc`, операция создания 9) Terraform завершается с ошибкой клиента:
```text
не удалось получить детали операции: ошибка API 500: Invalid call of the function [getResourceRealmConfig], first Argument [resourceRealm] is of invalid type, Cannot cast Object type [Struct] to a value of type [string]: the function is located at [/app/api/v1/resources/instance_operation_cfs_param.cfc]
```
## 2. Анализ причины
1. **Место падения в провайдере**: `provider/internal/core/client.go` (строка 272 в `createInstanceWithContext`):
```go
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
if err != nil {
return "", fmt.Errorf("не удалось получить детали операции: %w", err)
}
```
2. **Поведение бэкенда Nubes (Lucee/ColdFusion)**:
- В файле `/app/api/v1/resources/instance_operation_cfs_param.cfc` при обработке запроса `?fields=cfsParams` для операции 9 вызывается функция `getResourceRealmConfig(resourceRealm)`.
- Для сервиса `vc_vdc` поле `resourceRealm` в БД DEV-окружения хранится как комплексный объект (`Struct`), а не скалярная строка (`string`).
- При попытке приведения типа `Struct -> string` бэкенд падает с HTTP 500.
3. **Сравнение с веб-интерфейсом (HAR/vdc.har)**:
- В официальном веб-интерфейсе ЛК запрос `GET /instanceOperations/{opUid}?fields=cfsParams` **вообще не выполняется**.
- Браузер выполняет строго следующий флоу:
1. `POST /instanceOperations` -> получает `instanceOperationUid`
2. `POST /instanceOperationCfsParams` -> отправляет каждое значение CFS-параметра
3. `GET /instanceOperations/{opUid}/validate-cfs` -> валидация бэкендом
4. `POST /instanceOperations/{opUid}/run` -> запуск операции в оркестраторе
5. Polling `GET /instanceOperations/{opUid}` -> ожидание статуса завершения
4. **Зачем провайдер делает шаг 2**:
- Шаг 2 в провайдере использовался исключительно для вызова `resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)` — чтобы узнать `refSvcId` параметров и попробовать отрезолвить имена в UUID.
- Для ресурса `nubes_vc_vdc` параметр `organization_uid` (CFS param 30, refSvc 19) **уже гарантированно отрезолвлен в UUID** до создания операции (на этапе `ModifyPlan` и в начале `Create`).
- Остальные параметры `vc_vdc` (провайдер сети, профиль Provider VDC, CPU, RAM, резервирование, storage_config) являются скалярами/числами/JSON-строками и не содержат `refSvcId`.
- Соответственно, данные запроса `?fields=cfsParams` для `vc_vdc` фактически не требуются.
## 3. План изменений в провайдере (что будем менять)
### Целевой файл: `provider/internal/core/client.go`
В функции `createInstanceWithContext` (и при необходимости в `runInstanceOperationUniversalByCodeWithTimeout` / `updateResourceWithTimeout`) заменяется жёсткое падение на условный graceful fallback:
```go
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
if err != nil {
// Проверяем, есть ли среди переданных строковых параметров не-UUID значения,
// требующие резолвинга через refSvcId.
// Если все строковые параметры уже UUID или числа/литералы — логируем предупреждение и продолжаем.
c.logWarn(ctx, "не удалось получить cfsParams для операции %s (%v), продолжаем отправку параметров", opUid, err)
} else {
var opDetails universalOpResponse
if err := json.Unmarshal(opDetailsResp, &opDetails); err == nil {
params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)
if err != nil {
return "", err
}
}
}
```
### Критерии безопасности:
1. Fallback условный: если бэкенд упал, но параметры уже валидны / являются UUID — выполнение продолжается.
2. Логируется явный warning с идентификатором операции `opUid` и телом ошибки.
3. Валидация значений параметров не теряется — её по-прежнему выполняет бэкенд на этапе `GET /validate-cfs`.
+62
View File
@@ -0,0 +1,62 @@
# Отчет о работе: Исправление ошибки 500 при модификации VM
## 1. Проблема
При попытке изменить ресурс `nubes_vm_instance` (например, изменить `vm_cpu`), Terraform получает ошибку 500 от API:
```
Status 500: {"ERROR":"Invalid call of the function [checkParam], 4th Argument [instanceOperationCfsParamUid] is of invalid type, Cannot cast String [] to a value of type [guid]","DETAIL":"the function is located at [/app/api/v1/resources/instance_operation_cfs_param_ls.cfc]"}
```
## 2. Анализ причины
Сообщение `Cannot cast String [] to a value of type [guid]` указывает на то, что бэкенд получает массив строк там, где ожидает одиночный GUID. Обычно это происходит в REST API, когда клиент отправляет несколько значений для одного ключа, или когда отправляется `POST` запрос для создания сущности, которая уже существует, и система дублирует параметр в список.
В отличие от создания (Create), операция модификации (Modify/Update) в Nubes API при инициализации (`GET /instanceOperations/...`) уже содержит текущие значения параметров (`CfsParams`).
Если мы безусловно используем метод `POST` для отправки параметров (как это было сделано в ресурсе Postgres), мы создаем дубликат параметра. По всей видимости, движок API (ColdFusion?) объединяет старое и новое значение в массив, что ломает валидацию `checkParam`.
## 3. Выполненные действия (Solution Attempt)
Я модифицировал файл `internal/provider/vm_resource.go`, полностью переписав функцию `submitVMOperationParams`.
**Суть изменений:**
1. **Динамический маппинг**: Перед отправкой параметров провайдер теперь запрашивает детали операции (`GetInstanceOperation`).
2. **Определение UID**: Мы строим карту существующих параметров: `ParamName -> { ID, UID, CurrentValue }`.
3. **Гибридная логика PUT/POST**:
* Если параметр **уже существует** в операции (есть `instanceOperationCfsParamUid`) -> Мы используем метод **`PUT`**.
* URL: `/instanceOperationCfsParams`
* Payload: включает `instanceOperationCfsParamUid`.
* Если параметр **новый** (нет UID) -> Мы используем метод **`POST`**.
* URL: `/instanceOperationCfsParams`
* Payload: включает только `instanceOperationUid` и `svcOperationCfsParamId`.
### Фрагмент кода (internal/provider/vm_resource.go):
```go
if info.Uid != "" {
// Update existing parameter -> PUT
method = "PUT"
payload = map[string]interface{}{
"instanceOperationCfsParamUid": info.Uid,
"svcOperationCfsParamId": info.Id,
"instanceOperationUid": operationUid,
"paramValue": np.Value,
}
} else {
// Create new parameter -> POST
method = "POST"
// ...
}
```
## 4. Текущий статус
Код компилируется и выполняется. Был обновлен провайдер (`terraform-provider-nubes`).
Однако, при последнем запуске (`terraform apply`) ошибка 500 воспроизвелась снова с тем же текстом.
Это означает, что либо:
1. Логика `PUT` тоже вызывает дублирование (возможно, API не поддерживает PUT для этого эндпоинта так, как мы ожидаем).
2. Или ошибка `instanceOperationCfsParamUid is String[]` возникает из-за того, что я передаю этот UID в теле JSON, но API может ожидать его в URL или query params (хотя для PUT принято в теле или URL).
## 5. Планируемые дальнейшие шаги (остановлены)
Для полного решения требовалось бы:
1. Проверить работу `PUT` через curl с разными форматами payload.
2. Попробовать передавать UID параметра в рамках URL (REST-style): `/instanceOperationCfsParams/{uid}`.
**Статус:** Работа остановлена по требованию пользователя. Код зафиксирован в текущем состоянии (Гибридный PUT/POST).
@@ -0,0 +1,217 @@
# Forensic Analysis: API Model to Ordinary YAML
## Цель
Исследовать существующую цепочку:
```text
API model
↓
ordinary generator
↓
API YAML
```
Цель этапа — получить подтверждённую картину движения данных и установить, что сохраняется, преобразуется или теряется до формирования API YAML.
Этот документ предназначен для передачи Opus перед анализом.
## Строгие ограничения
На этом этапе запрещено:
- изменять файлы;
- писать код;
- менять ordinary generator;
- проектировать `ModifierSpec`;
- проектировать `modifiers.yaml`;
- проектировать `delete_rule`;
- проектировать output layout или orchestration;
- обсуждать inverse и dependency ordering;
- придумывать API identifiers;
- придумывать `parameter_path`;
- придумывать HTTP method или payload structure;
- считать API YAML полным источником данных без доказательства.
Если факт невозможно установить, его нужно обозначить как `unknown` или как требующий проверки фактическим API. Нельзя закрывать неизвестность архитектурным предположением.
## Что исследовать
### 1. Исходная API-модель
Установить:
- где находится canonical API model;
- в каком формате она представлена;
- как представлены services и operations;
- как представлены параметры;
- как представлены nested objects и arrays;
- как представлены типы параметров;
- существует ли стабильный operation ID;
- существует ли стабильный parameter ID;
- какие данные доступны до запуска ordinary generator.
### 2. Ordinary generator
Установить:
- какой input получает generator;
- где он читает API-модель;
- какие внутренние структуры строит;
- какие преобразования выполняет;
- какие поля нормализует или переименовывает;
- какие поля вычисляет;
- какие поля отбрасывает;
- где формируется API YAML;
- где именно могут происходить потери данных.
Обязательно различать:
```text
данные отсутствуют уже в API
```
и:
```text
данные присутствуют в API, но теряются ordinary generator
```
### 3. API YAML
Установить:
- какие поля сохраняются;
- какие поля представлены иначе, чем в API-модели;
- сохраняются ли nested objects и arrays;
- сохраняются ли типы;
- сохраняются ли operation identity и parameter identity;
- сохраняются ли HTTP method и path, если они есть в исходной модели;
- какие данные доступны будущему modifier layer;
- какие данные потенциально доступны только в исходной API-модели.
## Обязательные трассировки
### `vc_org`
Отдельно проследить `vIPConfigure` и `count`:
```text
API model
→ generator input
→ internal generator representation
→ generator transformation
→ API YAML
```
Для каждого этапа указать:
- присутствует ли `vIPConfigure`;
- присутствует ли `count`;
- в каком типе они представлены;
- в какой структуре находятся;
- изменяются ли их имена или типы;
- теряются ли они;
- если теряются, в какой точке.
Не считать заранее известной структуру `vIPConfigure` или `count`.
### `vc_nsxt`
Отдельно проследить `ipSpaceName` и связанные параметры по той же цепочке:
```text
API model
→ generator input
→ internal generator representation
→ generator transformation
→ API YAML
```
Установить:
- где появляется `ipSpaceName`;
- к какой operation или структуре он относится;
- в каком типе представлен;
- является ли обычным полем, nested field или частью массива;
- какие связанные параметры находятся рядом;
- сохраняется ли он в API YAML;
- изменяются ли его значение или тип;
- теряются ли связанные поля.
## Требования к доказательности
Каждый вывод разделять на:
- **Подтверждённый факт** — непосредственно виден из кода, структуры данных, фактического API input, generator input или API YAML.
- **Неизвестное** — информация отсутствует или неоднозначна.
- **Предположение** — не использовать как основание для архитектуры; только явно перечислять как неподтверждённое.
Для каждого важного вывода указывать конкретное основание: файл, функцию, структуру, входной документ или фрагмент YAML/JSON. Если точное место невозможно назвать, это указать как ограничение анализа.
## Формат итогового отчёта
Отчёт должен содержать только следующие разделы:
1. **Подтверждённые факты**
2. **Исходная API-модель**
3. **Как ordinary generator читает модель**
4. **Как формируется API YAML**
5. **Цепочка данных для `vc_org.vIPConfigure.count`**
6. **Цепочка данных для `vc_nsxt.ipSpaceName` и связанных параметров**
7. **Что сохраняется**
8. **Что преобразуется или нормализуется**
9. **Что теряется**
10. **Где происходят потери**
11. **Какие данные доступны в API YAML**
12. **Какие данные доступны только в API-модели**
13. **Неизвестные места**
14. **Что необходимо проверить фактическим API**
## Критерий завершения
Forensic analysis завершён только тогда, когда для каждого потенциально необходимого modifier-элемента можно проследить происхождение:
```text
API model
→ generator input
→ internal representation
→ transformation
→ API YAML
```
без неизвестных промежуточных преобразований.
Для `vc_org` и `vc_nsxt` должны быть подтверждены:
```text
operation identity
parameter identity
parameter structure
parameter type
API model representation
generator representation
YAML representation
transformation
loss or absence
```
Если на существенном этапе остаётся `???`, исследование не завершено. Этот пункт нужно зафиксировать как `unknown`, а не проектировать решение.
## Главный принцип
Сначала:
```text
исследовать
↓
зафиксировать факты
↓
зафиксировать неизвестное
↓
отличить отсутствие данных от потери данных
```
Только после отдельного согласования forensic report можно переходить к проектированию `ModifierSpec`, `modifiers.yaml`, `delete_rule`, modifier generator, output boundary и orchestration.
На текущем этапе никаких решений по этим компонентам принимать нельзя.
@@ -0,0 +1,234 @@
# Forensic Analysis: API Model to Ordinary YAML
**Анализ от Gemini 3.8 flash.**
Исследование проведено строго в границах требований [docs/FORENSIC_ANALYSIS_BRIEF_2026-09-23.md](FORENSIC_ANALYSIS_BRIEF_2026-09-23.md).
---
## 1. Подтверждённые факты
- **Цепочка генерации YAML:** Инструмент `yaml-generator` ([TOOLS/yaml-generator/main.go](../TOOLS/yaml-generator/main.go)) опрашивает live HTTP REST Gateway API Nubes по сети и сериализует результат в YAML-файлы спецификаций (`generated/{stand}/resources_yaml/{service_id}_{name}.yaml`), используя структуры контракта [TOOLS/lib/types.go](../TOOLS/lib/types.go).
- **Спецификации сервисов в репозитории:** Канонические сгенерированные спеки сервисов физически размещены в [generated/dev/resources_yaml/](../generated/dev/resources_yaml/). В частности, [19_vc_org.yaml](../generated/dev/resources_yaml/19_vc_org.yaml) и [22_vc_nsxt.yaml](../generated/dev/resources_yaml/22_vc_nsxt.yaml).
- **В `vc_org.yaml`:**
- Операция `modify` имеет числовой ID `207`, `kind: instance`, `action: modify`.
- Параметр `vIPConfigure` присутствует как параметр операции `modify` с числовым ID `662`, типом `data_type: array-map-fixed`, `required: true`.
- Поле `count` присутствует внутри `sub_params` параметра `vIPConfigure` с числовым ID `40`, `data_type: integer > 0`, `required: true`, `is_modifiable: false`.
- Поле `name` присутствует внутри `sub_params` параметра `vIPConfigure` с числовым ID `39`, `data_type: string`, `required: true`, `is_modifiable: false`.
- **В `vc_nsxt.yaml`:**
- Операция `modify` имеет числовой ID `111`, `kind: instance`, `action: modify`.
- Параметр `ipSpaceName` присутствует в операции `modify` с числовым ID `372`, `data_type: string`, `required: false`, `sort: 50`.
- С ним рядом в операции `modify` присутствуют:
- `needEnableAVI` (ID `368`, `data_type: boolean`, `required: false`, `value_list: ["false", "true"]`);
- `virtualServicesCount` (ID `369`, `data_type: integer > 0`, `required: false`, `minvalue: 1`, `maxvalue: 4`);
- `qosProfile` (ID `856`, `data_type: string`, `required: false`);
- `routedNetConfiguration` (ID `1112`, `data_type: map-fixed`, `required: true`, с вложенными подполями `ipAddrPool`, `mainDns`, `secondDns`).
- **Генератор ресурсов (Ordinary Resource Generator):**
- Расположен в [TOOLS/resource-generator/main.go](../TOOLS/resource-generator/main.go).
- При `op.Kind == "instance"` (что установлено для `modify` в [generated/dev/resources_yaml/19_vc_org.yaml](../generated/dev/resources_yaml/19_vc_org.yaml) и [generated/dev/resources_yaml/22_vc_nsxt.yaml](../generated/dev/resources_yaml/22_vc_nsxt.yaml)) генератор объединяет параметры `create` и `modify` в схему одного общего ресурса `nubes_vc_org` / `nubes_vc_nsxt`.
- Параметры, отсутствующие в операции `create`, но присутствующие в операции `modify` (как `vIPConfigure` в `vc_org`), не включаются в жизненный цикл `create`, а при отсутствии отдельной разметки `kind: modifier` в YAML генератор не создаёт под них отдельного ресурса модификатора.
---
## 2. Исходная API-модель
- **Источник и формат:** Модель получается HTTP-клиентом ([TOOLS/yaml-generator/internal/client/client.go](../TOOLS/yaml-generator/internal/client/client.go#L44-L62)) по протоколу HTTP GET в формате JSON.
- **Эндпоинты API:**
- Метаданные сервиса и список операций: `GET /services/{svcId}` (возвращает `types.ServiceResponse`).
- Метаданные конкретной операции: `GET /instanceOperations/default/{svcOperationId}` (возвращает `types.ServiceOperationResponse`).
- **Идентификаторы операций и параметров:**
- Операция идентифицируется стабильным числовым `svcOperationId` (int) и строковым именем `operation` (например, `"modify"`, `"create"`).
- Параметры идентифицируются стабильным числовым `svcOperationCfsParamId` (int) и строковым кодом `svcOperationCfsParam` (например, `"vIPConfigure"`, `"ipSpaceName"`).
- **Вложенные объекты и массивы (`dataDescriptor`):**
- В ответе эндпоинта `/instanceOperations/default/{id}` сложная структура передаётся в поле `dataDescriptor: map[string]CfsSubParam`.
- Каждое подполе имеет свой числовой `svcOperationCfsSubparamId`, строковый ключ (код подполя), `dataType`, `isRequired`, `isModifiableDefinition`, `defaultValue`, `valueList` (строка через запятую или массив).
---
## 3. Как ordinary generator читает модель
- **Входной поток:**
`yaml-generator` вызывает `cli.GetService(svc.ID)` и перебирает список `info.Operations`.
- **Чтение операций и параметров:**
Для каждой операции вызывается `cli.GetServiceOperation(op.SvcOperationID)` ([TOOLS/yaml-generator/internal/client/client.go](../TOOLS/yaml-generator/internal/client/client.go#L182-L240)).
- **Преобразования и нормализация:**
- Имена сервисов и операций нормализуются в snake_case функцией `normalize.Identifier` ([TOOLS/yaml-generator/internal/normalize/normalize.go](../TOOLS/yaml-generator/internal/normalize/normalize.go#L17-L50)).
- Классификация операции: функция `classifyOperation` делит операции на `instance` (для `create`, `modify`, `delete`, `suspend`, `resume`), `subresource` (если есть символ подчеркивания) или `action`.
- Разворачивание `dataDescriptor`: генератор обходит `map[string]CfsSubParam`, преобразует подполя в срез `types.ParamSpec` и детерминированно сортирует по `subParams[i].ID`.
- Сортировка верхнеуровневых параметров по `params[i].ID`.
- Сортировка операций по имени и ID.
- **Что отбрасывается / не сохраняется в YAML:**
- Конкретные HTTP method и URL-пути эндпоинтов API платформы (они зашиты в код клиента, в спек YAML не пишутся).
- Вспомогательные поля `CfsParam`, не имеющие тега `yaml:` в [TOOLS/lib/types.go](../TOOLS/lib/types.go#L70-L101), если они не сериализуются или приходят пустыми: `IsModifiable`, `IsSensitive` (указаны с `omitempty`). Поле `dataDescriptor` как мапа отбрасывается — сохраняется преобразованный срез `sub_params`.
---
## 4. Как формируется API YAML
- Сериализация структуры `types.ServiceSpec` в YAML выполняется через библиотеку `gopkg.in/yaml.v3` ([TOOLS/yaml-generator/main.go](../TOOLS/yaml-generator/main.go#L107-L114)).
- В результирующий файл пишутся:
- Метаданные сервиса (`name`, `service_id`, `service_display_name`, `service_short_name`, `service_man`).
- Стандартная секция `lifecycle` и `outputs`.
- Секция `operations` со списком операций и всеми их параметрами (`id`, `code`, `data_type`, `required`, `default`, `value_list`, `descr`, `man`, `sort`, `sub_params`).
---
## 5. Цепочка данных для `vc_org.vIPConfigure.count`
1. **API Model:**
- Эндпоинт `/instanceOperations/default/207` возвращает параметр с `svcOperationCfsParamId: 662`, `svcOperationCfsParam: "vIPConfigure"`, `dataType: "array-map-fixed"`.
- Внутри него поле `dataDescriptor` содержит ключ `"count"`:
- `svcOperationCfsSubparamId: 40`,
- `dataType: "integer > 0"`,
- `isRequired: true`,
- `defaultValue: ""`,
- `descr: "Пример: \`1\`"`.
2. **Generator Input:**
- Читается в структуру `types.CfsParam` с мапой `DataDescriptor map[string]CfsSubParam` ([TOOLS/yaml-generator/internal/types/types.go](../TOOLS/yaml-generator/internal/types/types.go#L49-L72)).
3. **Internal Generator Representation:**
- В [TOOLS/yaml-generator/internal/client/client.go](../TOOLS/yaml-generator/internal/client/client.go#L210-L232) мапа `dataDescriptor` разворачивается в `types.ParamSpec.SubParams`. Поле `count` становится элементом среза `SubParams` с `ID: 40`, `Code: "count"`, `DataType: "integer > 0"`.
4. **Generator Transformation:**
- Сортируется по ID (`sort.Slice(subParams, ...)`).
5. **API YAML:**
- Записывается в [generated/dev/resources_yaml/19_vc_org.yaml](../generated/dev/resources_yaml/19_vc_org.yaml#L131-L150):
```yaml
- id: 662
code: vIPConfigure
data_type: array-map-fixed
required: true
sort: 10
sub_params:
- id: 39
code: name
data_type: string
required: true
default: ""
is_modifiable: false
- id: 40
code: count
data_type: integer > 0
required: true
default: ""
descr: 'Пример: `1`'
is_modifiable: false
```
- **Потерь в YAML нет:** `vIPConfigure` и `count` полностью и без искажений сохранены в каноническом YAML спецификации.
---
## 6. Цепочка данных для `vc_nsxt.ipSpaceName` и связанных параметров
1. **API Model:**
- Эндпоинт `/instanceOperations/default/111` возвращает операцию `modify` сервиса 22.
- Параметр `ipSpaceName` возвращается с:
- `svcOperationCfsParamId: 372`,
- `svcOperationCfsParam: "ipSpaceName"`,
- `dataType: "string"`,
- `isRequired: false`,
- `descr: "Имя ip Space для внешнего IP"`,
- `man: "Необходимо указывать, если включён параметр \`Выделить VIP для SNAT\`"`,
- `sort: 50`.
- В HAR-дампах ([HAR/edge_.har](../HAR/edge_.har#L7985)) на живом инстансе в рантайме возвращается `valueList: ["no-needed", ...]`.
2. **Generator Input:**
- Читается в структуру `types.CfsParam` ([TOOLS/yaml-generator/internal/types/types.go](../TOOLS/yaml-generator/internal/types/types.go#L49-L72)).
3. **Internal Generator Representation:**
- Преобразуется в `types.ParamSpec` со значениями `ID: 372`, `Code: "ipSpaceName"`, `DataType: "string"`.
4. **Generator Transformation:**
- Нормализуются defaults и value_list через `normalizeDefault` и `normalizeValueList`.
5. **API YAML:**
- Записывается в [generated/dev/resources_yaml/22_vc_nsxt.yaml](../generated/dev/resources_yaml/22_vc_nsxt.yaml#L149-L155):
```yaml
- id: 372
code: ipSpaceName
data_type: string
required: false
descr: Имя ip Space для внешнего IP
man: Необходимо указывать, если включён параметр `Выделить VIP для SNAT`
sort: 50
```
- Рядом в той же операции сохранены: `needEnableAVI` (ID 368), `virtualServicesCount` (ID 369), `qosProfile` (ID 856), `routedNetConfiguration` (ID 1112 с sub_params).
- **Особенность по `value_list`:** в статическом `22_vc_nsxt.yaml` поле `value_list` для `ipSpaceName` отсутствует (`null` в ответе static-дефолтов эндпоинта `/instanceOperations/default/111`), хотя в рантайме на конкретном инстансе `valueList` динамически содержит `["no-needed", ...]`.
---
## 7. Что сохраняется
- Полная идентичность сущностей: `service_id`, `svcOperationId` (как `id` операции), `svcOperationCfsParamId` (как `id` параметра), `svcOperationCfsSubparamId` (как `id` в `sub_params`).
- Строковые коды: `operation`, коды параметров (`code`).
- Исходные типы платформы: `dataType` (`string`, `boolean`, `integer > 0`, `array-map-fixed`, `map-fixed`).
- Вся структура вложенности (`dataDescriptor` → `sub_params`).
- Флаги `required`, валидационные regex, min/max, описания (`descr`, `man`), порядок (`sort`).
---
## 8. Что преобразуется или нормализуется
- Имена операций и сервисов приводятся к ASCII snake_case через `normalize.Identifier`.
- `dataDescriptor` из мапы ключей преобразуется в упорядоченный срез `sub_params` с сортировкой по числовому `id`.
- Значения `valueList` и `default` приводятся к строковым представлениям (убираются пробелы, пустые значения приводятся к `nil`).
---
## 9. Что теряется
- HTTP-метод и путь обращения к API (в YAML отсутствуют; генератор считает их внешним знанием рантайма).
- Динамические значения списков выбора (`valueList`): эндпоинт дефолтов `/instanceOperations/default/{id}` возвращает пустой `valueList` для полей, зависящих от конкретного тенанта/инстанса (например, доступные `ipSpaceName` для конкретного VDC/Org).
---
## 10. Где происходят потери
- Потери HTTP-метаданных (метод, URL) происходят на этапе маршалинга структуры `types.ServiceSpec` в YAML ([TOOLS/yaml-generator/main.go](../TOOLS/yaml-generator/main.go#L94-L105)), так как они изначально отсутствуют в контракте [TOOLS/lib/types.go](../TOOLS/lib/types.go).
- Отсутствие runtime `valueList` обусловлено вызовом шаблонного эндпоинта `/instanceOperations/default/{id}` вместо запроса контекста живого инстанса.
---
## 11. Какие данные доступны в API YAML
- Полный перечень всех сервисов, операций (`create`, `modify`, `delete`, `suspend`, `resume`, сабресурсов) и их параметров.
- Точные типы платформы (`data_type`) и иерархия подполей (`sub_params`).
- Идентификаторы `id` (CFS param IDs) и символические коды (`code`).
- Метаданные валидации (обязательность, регулярные выражения, ограничения диапазонов).
---
## 12. Какие данные доступны только в API-модели
- Динамические списки допустимых значений (`valueList`), вычисляемые бэкендом для конкретного состояния конкретного инстанса (например, список реально существующих ipSpaces организации при вызове modify на Edge).
- Внутренние служебные поля платформы CFS, отфильтрованные моделью генератора (`contractId`, `contragentId`, `nestedRefData`, `config`, `statePath`, `expression`).
---
## 13. Неизвестные места
- Неизвестно, возвращает ли платформа Nubes какую-либо схему валидации для эндпоинтов отката/деаллокации (например, принимает ли `vc_org.modify` пустой массив `vIPConfigure: []` для полного снятия или требует только уменьшения `count: 0`), так как в дефолтной модели операции 207 описан только общий формат `vIPConfigure`.
---
## 14. Что необходимо проверить фактическим API
- Поведение `vc_org` (операция 207) при передаче `vIPConfigure: []` против `vIPConfigure: [{"name": "...", "count": 0}]` при попытке полной деаллокации IP-пространства.
- Поведение `vc_nsxt` (операция 111) при передаче `ipSpaceName: "no-needed"` на различных окружениях (Dev/Test/Prod).
---
## Мнение Opus: финальное ревью отчёта
Отчёт признан годным и принят как вход для дальнейшего архитектурного этапа.
### Достаточно для дальнейшей работы
- Цепочка `API model → yaml-generator → API YAML` прослежена по коду, а не по догадкам: `classifyOperation`, `params.Merge`, разворачивание `dataDescriptor` в `sub_params`.
- Обе обязательные трассировки (`vc_org.vIPConfigure.count`, `vc_nsxt.ipSpaceName`) доведены до YAML с подтверждением «потерь нет».
- Зафиксирован ключевой факт: динамический `valueList` есть только в рантайме живого инстанса, а в API YAML его нет. Это прямое ограничение для будущего modifier-слоя.
- Неизвестное поведение payload деаллокации (`vIPConfigure: []` против `count: 0`) помечено как `unknown`, а не закрыто предположением.
### Следствия перед архитектурным этапом
- Под ярлыком «Ordinary Resource Generator» в отчёте упоминаются два разных инструмента: `yaml-generator` пишет спеки, а `resource-generator` создаёт Go-ресурсы. Для проектирования `ModifierSpec` это две разные точки вмешательства.
- `vIPConfigure` и `ipSpaceName` присутствуют только в `modify` и отсутствуют в `create`. В текущей схеме они сливаются в общий ресурс и не имеют отдельного жизненного цикла. Это корень задачи модификаторов.
- Runtime-`valueList` придётся получать не из дефолтного эндпоинта, а из контекста инстанса. Это вопрос рантайма провайдера, а не генератора.
### Итоговая оценка
Ошибок, которые ломали бы выводы отчёта, не выявлено. Отчёт можно использовать как подтверждённую основу для дальнейшего проектирования.
@@ -0,0 +1,69 @@
# HAR-разбор: SNAT / ipSpace / модификации (dev)
Дата: 2026-09-22. Источник: `/home/naeel/TF/tf_provider/HAR/*.har` (записи UI на dev-стенде, 2026-09-20).
Релевантные файлы: `edge_.har` (SNAT/Edge), `ipSpace0.har`, `org_enough_.har`, `org_not_enough_.har`, `org0.har`.
## Поток modify в реальном API
1. `POST /api/v1/svc/instanceOperations` — `{"instanceUid":"...","operation":"modify"}` → возвращает `instanceOperationUid`.
2. `POST /api/v1/svc/instanceOperationCfsParams` — по одному запросу на параметр:
`{"paramValue":"...","instanceOperationUid":"...","svcOperationCfsParamId":NNN}`.
3. `GET /svc/instanceOperations/{id}/validate-cfs`
4. `POST /svc/instanceOperations/{id}/run`
5. Поллинг `GET /svc/instanceOperations/{id}`.
## Найденные payload-и
| Операция | param id | код | значение из HAR |
|---|---|---|---|
| edge modify | 368 | `needEnableAVI` | `false` / `true` |
| edge modify | 369 | `virtualServicesCount` | `1` / `2` |
| edge modify | 856 | `qosProfile` | `QoS-100Mbit` |
| edge modify | **372** | **`ipSpaceName`** | **`no-needed`** / `""` |
| edge modify | 1112 | `routedNetConfiguration` | `{"mainDns":"81.22.46.22","secondDns":"185.247.187.77","ipAddrPool":"10.10.102.0/24"}` |
| org modify | **662** | **`vIPConfigure`** | `[{"name":"internet-ipv4-v1","count":"3"}]` |
## Ответы на открытые вопросы
1. **Тумблера «Выделить VIP для SNAT» в API НЕТ.** SNAT управляется целиком через `ipSpaceName` (param 372).
Его `valueList` (из метаданных в HAR): `no-needed, internet-antiddos-v1, internet-no-antiddos-v1, ...` — то есть `no-needed` это легальное значение «SNAT не нужен».
- Включить SNAT: `ipSpaceName = <имя ipSpace из org>`.
- Выключить: `ipSpaceName = "no-needed"`.
2. ✅ **Каноническое «SNAT выключен» = `no-needed`.** Подтверждено: в UI (Edge → Modify → поле «ip Space для VIP», параметр `ipSpaceName`) текущее значение показывается как `no-needed`. Reverse для SNAT = `modify` с `ipSpaceName="no-needed"` → delete SNAT-модификатора можно реализовать не как no-op. (`""` из `ipSpace0.har` — не каноническое, а промежуточное состояние.)
3. 🟡 **Де-аллокация IP в org — попытка зафиксирована (`org2.har`, 2026-09-22):** UI отправил `modify` с
`vIPConfigure=[{"name":"internet-ipv4-v1","count":"2"}]` (count уменьшен с 3 до 2).
HTTP-ошибки НЕТ, но операция осталась в `isPending:true` — не выполнилась (согласуется с ограничением ниже).
**Вывод:** payload де-аллокации = ТА ЖЕ структура `vIPConfigure`, только меньше `count` (не отдельная операция).
Точная семантика «удалить совсем» (`count=0` или опустить элемент) не подтверждена.
🔴 **Ограничение (подтверждено):** уменьшить/удалить ipSpace в `vcOrg` **нельзя, пока существуют дочерние инстансы** (VDC/Edge/кластер).
Следствие: reverse возможен только ПОСЛЕ уничтожения детей → порядок destroy критичен:
`кластер → SNAT-модификатор (no-needed) → org IP de-alloc → edge → vdc → org`.
Чтобы снять payload «удалить совсем», нужен чистый org без детей (или плановый teardown).
## Побочные факты
- У `ipSpaceName` (372) в API есть `valueList`, но в нашем YAML его **нет** → проверить, тянет ли генератор `valueList` (возможно, он динамический: имена ipSpace конкретной org).
- Имя ipSpace в живом примере — `internet-ipv4-v1` (не произвольное).
- `qosProfile` (856) UI всегда шлёт как `QoS-100Mbit`.
- `routedNetConfiguration` передаётся JSON-строкой.
- В состоянии org: `"vip":{"no-needed":{},"internet-ipv4-v1":{"count":4}}` — `no-needed` фигурирует и в стейте.
## Наблюдения на возможно сломанном Edge (2026-09-22, nsx_WZ03709-saas-wmfop5be)
⚠️ ВАЖНО: этот Edge, судя по всему, в сломанном состоянии (devops-проблема).
Ошибки ниже **НЕ считать универсальными правилами API** — перепроверить на здоровом Edge.
1. `modify` 14:40:52 → «ipSpace '' не найден на https://sandbox.nubes.ru» — при пустом `ipSpaceName` бэкенд отклонил запрос. ❓ Возможно, следствие сломанного Edge, не правило.
2. `modify` 14:43:44 → «Insufficient rule blocks» при попытке снять «Включить ALB». ❓ Возможно, застрявшие VS/SE Group, не правило.
3. `delete` (2 раза) → FORBIDDEN «Cannot delete SE Group assignment … since there are Virtual Services». ❓ Возможно, застрявшие VS, не правило.
**Что остаётся надёжным (из API-метаданных, НЕ из этих ошибок):**
- `valueList` у `ipSpaceName` содержит `no-needed` (+ имена ipSpace) — из описания параметра.
- UI показывает `no-needed` как текущее значение при выключенном SNAT.
## Что ещё нужно выяснить из UI (открытые вопросы)
1. 🔴 **Де-аллокация IP в org — пока НЕ снять:** UI/бэкенд не даёт удалить ipSpace, пока есть дочерние инстансы (подтверждено 2026-09-22). Нужен чистый org или плановый teardown. Гипотеза payload — `vIPConfigure=[]` (unverified).
2. ✅ **Имя ipSpace — выбор ИЗ СПИСКА** (подтверждено UI). Свободного ввода нет → список динамический (текущие ipSpace org + `no-needed`).
Следствие для провайдера: `ip_space_name` в SNAT-модификаторе должен браться из **computed-вывода org-модификатора**, а не быть свободной строкой.
3. 🟡 Полное удаление ipSpace и поведение при destroy Edge с включённым SNAT — на будущее (блокировано п.1).
@@ -0,0 +1,122 @@
# Ответ Opus: анализ решения IaC-развёртывания Штурвала (модификаторы + скрытые зависимости)
**Дата:** 2026-09-23
**Связанный промпт:** `docs/prompts/prompt_for_opus_iac_shturval_modify.md`
**Связанный анализ:** `docs/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md`
**Статус:** документирование ответа. Конкретный план НЕ составляется.
> ⚠️ **ВАЖНАЯ ПОПРАВКА (2026-09-23, позже).** Ответ Опуса ниже строился на НЕВЕРНОЙ посылке «`vIPConfigure` — накопительный API». Это опровергнуто тестом `docs/ORG_IP_MODIFIER_TEST_2026-09-22.md`: `vIPConfigure` ведёт себя как **replace-состояние** — идемпотентно (1→1), работает в обе стороны (вверх/вниз/до 0), `count` читается из `state.params`. Соответственно «блокеры» (a) Read счётчика и (b) адресное освобождение **сняты как ложные**. Остаётся только (c) Read цепочки `providerVdc → providerGateway → ipSpace`. НЕ использовать прежнюю формулировку «накопительный API несовместим с декларативной моделью» как источник истины.
---
## 1. Суть ответа (главный вывод)
Форма IaC-ресурсов фиксируется **уже сейчас**, потому что она диктуется моделью Terraform (декларативность, идемпотентность, inverse), а не спеками платформы.
НО есть **три блокера от платформы**, без которых идемпотентность и Delete принципиально недостижимы на стороне провайдера.
---
## 2. Ответ Opus по пунктам
### Пункт 1 — накопительный `vIPConfigure` → идемпотентный ресурс
- Ресурс отдельный (`nubes_org_vip_allocation`) с `depends_on` на оргу, НЕ операция внутри орги.
- **Ключ идемпотентности — желаемое состояние, а не дельта.** Юзер задаёт целевой `count` на `name`; провайдер сам считает `target − current` и модифицирует только разницу.
- **Create:** Read текущего числа vIP → выделить `target − current`. Если API не отдаёт «сколько уже есть» — нужен серверный счётчик/тег, иначе идемпотентность недостижима.
- **Read:** читать родителя (оргу), извлекать фактическое число IP по `name` в state. Если API не различает «кем/зачем выделено» — Read вернёт общий пул, drift неизбежен.
- **Update:** та же дельта-логика (target изменился → доначислить/освободить).
- **Delete (inverse):** `modify` с обратным знаком до `count=0` по этому `name`. Требует адресного освобождения конкретных IP. Если освобождение — тоже накопительный modify без адресации, inverse корректно сделать нельзя.
**Риск (ОПРОВЕРГНУТ позже):** это утверждение строилось на ложной посылке «накопительный API». Факт: `vIPConfigure` — replace-состояние, дельта `target − current` по факту не нужна — достаточно слать целевой `count`, платформа сама выставляет его (идемпотентно). См. `ORG_IP_MODIFIER_TEST_2026-09-22.md`.
### Пункт 2 — `ipSpaceName` (цепочка providerVdc → providerGateway → ipSpace)
- Это **выводимое значение из инфраструктуры, НЕ пользовательский ввод** → data-source, а не аргумент ресурса.
- Правильно: `data "nubes_ip_space" { org/vdc = ... }`, который проходит цепочку providerVdc → providerGateway → ipSpace и возвращает `name`. Ресурс берёт значение по ссылке.
- **Граница «данные vs логика»:** в реестре хранить **тип поля и его источник** (что это computed-from-parent, а не user-input). Сама цепочка обхода — логика data-source, не данные реестра.
- НЕ вычислять из state родителя вручную в ресурсе (скрытая связанность, ломается при >1 T0). Data-source явно выражает зависимость в графе tf.
- Пока платформа «подкладывает» значение сама — data-source должен уметь то же читать. Если API этой цепочки нет на чтение — **блокер**.
### Пункт 3 — не завязываться на «один T0»
- Закладывать **явный селектор шлюза** уже сейчас: `provider_gateway` / `t0_id` как аргумент (или ключ data-source), даже если сегодня один и выводится автоматически (optional + computed default).
- vIP-аллокация и SNAT привязывать к **конкретному gateway id**, а не к «дефолтному в орге».
- **Что сломается при >1 T0, если не заложить:** `ipSpaceName` станет неоднозначным (несколько ipSpace), vIP-аллокация не будет знать, к какому шлюзу. Придётся менять схему (добавлять обязательный селектор) → breaking change.
- **Заложив optional-селектор сейчас:** при росте T0 меняется только default-резолвинг, схема остаётся совместимой.
### Пункт 4 — ждать спеки или фиксировать форму сейчас
- **Форму ресурсов можно и нужно фиксировать сейчас** — она диктуется моделью Terraform, а не спеками.
- **Не блокер (делаем сейчас):** раздельные ресурсы + `depends_on`; целевое состояние вместо дельты; селектор шлюза; data-source для `ipSpaceName`; inverse через обратный modify.
- **Блокер (нужно от платформы) — только один подтверждённый:**
- c) API чтения цепочки providerVdc → providerGateway → ipSpace (иначе data-source невозможен).
- **Сняты как ложные (опровергнуты тестом 2026-09-22):**
- a) чтение текущего числа vIP — УЖЕ работает через `state.params.vIPConfigure`;
- b) адресное освобождение IP — УЖЕ работает: `count` меньше/`0` задаётся тем же `modify`, в обе стороны.
- **Вывод:** проектируем форму сейчас, блокер только (c). Новые спеки повлияют на **резолвинг значений**, не на форму ресурсов — если форма построена на «целевое состояние + селектор + data-source».
### Пункт 5 — минимально-инвазивный порядок внедрения
**От платформы (до кодинга ресурсов) — обязательно:**
- Read цепочки → `ipSpaceName` (для data-source).
- Подтверждение, что генератор умеет строить схему из объединения `create`+`modify` полей (иначе `vIPConfigure`/`ipSpaceName` вообще не попадут в схему).
> ⚠️ Read счётчика vIP и адресное освобождение — НЕ блокеры (уже подтверждено тестом). Исключены.
**На стороне провайдера — можно сейчас, не дожидаясь:**
- Раздельные ресурсы vip-allocation / nsxt-snat с `depends_on`.
- Логика «target − current = дельта» (заглушка current, пока нет Read).
- Data-source-скелет для `ipSpaceName` (с TODO на реальный обход цепочки).
- Optional+computed селектор шлюза.
- Inverse-контракт (Delete = обратный modify до нуля).
**Главный неустранимый на нашей стороне блокер:** ❌ СНЯТ — строился на ложной посылке «накопительный API». Реальный остаточный блокер — только (c) чтение цепочки providerVdc → providerGateway → ipSpace (для data-source `ipSpaceName`).
---
## 3. Что нового vs то, что уже собирались делать
### Совпадает со старыми планами (НЕ новое)
- Отдельный ресурс под модификацию + `depends_on` — было (`PLAN_modifier_redesign.md`, «resource association»).
- Inverse через обратный modify (`count→0`) — было (`inverse_rollback_analysis_2026-09-23.md`).
- Идемпотентность (skip run, если live уже целевое) — было.
- «Не ждать спеки для формы, а фиксировать сейчас» — по сути было.
### Реально новое у Опуса
1. **«Целевое состояние, а не дельта»** — строгий принцип: юзер задаёт целевой `count`, провайдер сам считает `target − current`. Старые планы просто «досылали заданные поля», не формализовали желаемое состояние.
2. **`ipSpaceName` — data-source, не аргумент ресурса** — сдвиг от «юзер вписывает значение» к «computed-from-parent». Раньше виделось как ввод.
3. **Селектор шлюза (`t0_id`/`provider_gateway`) как optional+computed сейчас** — в старых планах про «один T0» вообще не было (пришло только из реплики Виталия).
4. **Чёткая граница «данные vs логика»** — в реестре только «тип поля + что computed-from-parent», цепочка обхода — логика data-source.
5. **Блокеры от платформы** — из трёх заявленных Опуса два (Read счётчика, адресное освобождение) **ложны** (опровергнуты тестом), остаётся один реальный: Read цепочки providerVdc→providerGateway→ipSpace.
### Главное отличие одной фразой
Старые планы отвечали на «**как сделать модификатор в tf**». Опус отвечает на «**как сделать его идемпотентным и IaC-честным**» — но его центральный вывод «ядро проблемы в платформе (накопительный API)» **оказался ошибочным**, т.к. исходная посылка «накопительный» неверна (см. поправку в шапке). Реальный остаток — только `ipSpaceName` (цепочка providerVdc→providerGateway→ipSpace) и селектор шлюза.
---
## 4. Спорный/непроверенный момент — РАЗРЕШЁН
Посылка Опуса «накопительный `vIPConfigure` без Read-счётчика и адресного освобождения несовместим с декларативной моделью» **опровергнута** тестом `docs/ORG_IP_MODIFIER_TEST_2026-09-22.md`:
- повторный `modify` с тем же `count` не аккумулирует IP (1→1) → идемпотентно;
- `count` меняется в обе стороны (2→1→0) через тот же `modify` → «адресное освобождение» не нужно, достаточно задать меньший/нулевой `count`;
- `count=0` принимается (несмотря на `minvalue:1` в схеме), элемент ipSpace остаётся в `state.params`;
- Read уже есть: `state.params.vIPConfigure = [{"name":"internet-ipv4-v1","count":N}]`.
Единственный реально непроверенный момент: полное удаление ipSpace (`[]` / отсутствие элемента) — тест этого не покрывал. Для IaC-задачи «обнулить» достаточно, полное удаление — опционально.
---
## 5. Резюме (с поправкой)
- Форма ресурсов — проектируем сейчас, она не зависит от спеков.
- `vIPConfigure` — **НЕ блокер**: идемпотентно, обе стороны, `count` читается/задаётся из `state.params` (тест 2026-09-22).
- Единственный подтверждённый блокер: **Read цепочки `providerVdc → providerGateway → ipSpace`** для data-source `ipSpaceName` (c).
- Непроверено: полное удаление ipSpace (`[]`), но для задачи достаточно `count=0`.
- Конкретный план внедрения пока НЕ составляется (по решению пользователя).
**Следующий возможный шаг (только по запросу):** проверить (c) чтение цепочки ipSpace живым API.
@@ -0,0 +1,35 @@
# Проверка модификатора vc_org ip_space (modify 207) — 2026-09-22
Организация: `NarodOrg` (`9890a8a0-040b-4d56-8018-c31519c35a30`), realm `sandbox.nubes.ru`.
Стенд: `DEV_STAND/FullPipe`, провайдер `nubes-dev` 2.0.9.
## Что делали
1. Переименовали `org_ips.tf` → `terraform apply` — ошибок нет (ресурс ушёл из state; `Delete` модификатора — no-op).
2. Вернули файл → `apply` — ошибок нет, **число IP осталось 1** (повторный `modify` с тем же `count=1` НЕ задвоил).
3. `org_ip_count = 2` → `apply` — стало 2.
4. `org_ip_count = 1` → `apply` — стало 1.
5. `org_ip_count = 0` → `apply` — стало 0.
## Подтверждено по API
`GET /instances/9890a8a0-040b-4d56-8018-c31519c35a30`:
- `state.params.vIPConfigure = [{"name":"internet-ipv4-v1","count":0}]`
- последняя операция `modify` — `isSuccessful: true`, `isPending: false`, `isInProgress: false`.
## Выводы (обновляют прежние гипотезы)
1. **modify работает в обе стороны** (count вверх/вниз/до 0) на здоровой орге, даже при живых дочерних инстансах (VDC/Edge).
2. **modify идемпотентен** — повторный `modify` с тем же `count` не аккумулирует IP (1 → 1).
3. **`count=0` принимается**, хотя в схеме операции 207 у `count` стоит `minvalue: 1` / `integer > 0` — валидация не отвергает 0. `count=0` = ноль выделенных IP (элемент ipSpace остаётся в state).
4. **`Delete` модификатора — no-op** подтверждён (шаг 1), но это восполнимо: повторный `apply` с нужным `count` корректно восстанавливает состояние.
## Опровергнуто
- Утверждение из `docs/HAR_SNAT_MODIFY_FINDINGS.md` «уменьшить/удалить ipSpace нельзя, пока существуют дочерние инстансы» — **не подтвердилось на здоровой орге** (в HAR был сломанный Edge; это и было помечено как неподтверждённое наблюдение).
- Опасение из Opus-ревью о «двойном выделении при replace/destroy→apply» — в части повторного `modify` с тем же `count` **не воспроизвелось** (идемпотентно).
## Открытый вопрос
- Полное удаление ipSpace (пустой массив `[]` / отсутствие элемента) не тестировалось — `count=0` оставляет элемент `{"name":"internet-ipv4-v1","count":0}` в state.
@@ -0,0 +1,169 @@
# Штурвал через IaC: анализ проблемы `modify` и скрытых платформенных зависимостей
**Дата:** 2026-09-23
**Контекст:** дискуссия в Telegram про запуск цепочки Штурвал полностью через Terraform.
---
## 1. Исходная задача
Клиенту нужен IaC (Infrastructure as Code): вся инфраструктура описывается одним конфигом, команда `terraform apply` разворачивает её целиком, `plan`/`destroy` дают полную картину. Никаких обязательных ручных шагов посередине.
Цепочка для стенда Штурвал:
```text
vcOrg -> create
vcVdc -> create
vcNsxt -> create
------------------------------
vcOrg -> modify (аллоцировать внешние IP в организацию)
vcNsxt -> modify (включить SNAT, указать внешний IP из vcOrg)
------------------------------
k8sShturval -> create
```
Ключевой конфликт: `create` у Terraform работает штатно, а операции `modify` в текущем провайдере никак не выражаются — Terraform не умеет «создать ресурс, а через несколько шагов поменять в нём же параметр».
---
## 2. Почему `modify` не выражается в текущем провайдере
### 2.1. Генератор строит схему только из `create`
Провайдер генерируется из YAML-спеков (`generated/{stand}/resources_yaml/*.yaml`). Схема ресурса (какие поля можно писать в `.tf`) строится **только из операции `create`**. Параметры, которые есть только в `modify`, в схему не попадают.
Подтверждено по файлам:
- `generated/dev/resources_yaml/19_vc_org.yaml`:
- `create` (id 136) → только `resourceRealm` (418), `organizationType` (556), `orgSuffix` (1125);
- `vIPConfigure` (выделение внешних IP) есть **только** в `modify` (id 207), с sub-полями `name` (39) и `count` (40).
- `generated/dev/resources_yaml/22_vc_nsxt.yaml`:
- `create` (id 10) → `vdcUid`, `needEnableAVI` (340), `virtualServicesCount` (341), `qosProfile` (825), `routedNetConfiguration` (1110) и др.;
- `ipSpaceName` (372) есть **только** в `modify` (id 111).
Вывод: `vIPConfigure` (vc_org) и `ipSpaceName` (vc_nsxt) живут только в `modify`, в схеме tf-ресурсов их нет. Поэтому «прописать параметр в tf и сделать apply» падает ещё на `plan` (атрибут не известен провайдеру).
### 2.2. Эти параметры — не «настройки», а отложенные действия
- `vIPConfigure=[{name,count}]` — задаёт желаемое число внешних IP целиком. **Не накопительная** (повторный вызов с тем же `count` не аккумулирует, подтверждено `docs/ORG_IP_MODIFIER_TEST_2026-09-22.md`), работает в обе стороны (вверх/вниз/до `count=0`). Это **декларативное значение** в смысле «желаемое количество IP по данному ipSpace».
- `ipSpaceName` — включение SNAT на конкретный ipSpace, который возникает **только после** того, как на орге выделены IP.
- Эти операции требуют порядка (org.modify → затем nsxt.modify) и зависят от живого состояния инстанса, а не от дефолтов формы.
### 2.3. Схема в state ≠ реальное состояние
Вписывать недостающие параметры «насильно» в tfstate нельзя и бесполезно:
1. Terraform валидирует атрибуты по **схеме провайдера**, а не по state — неизвестный атрибут будет отброшен/вызовет ошибку.
2. Записывать в state «SNAT включён», когда этого нет на площадке, — значит получить ложный `plan` (чистый) при сломанной инфраструктуре.
3. Ручная правка tfstate/`state push` ломает целостность (серийник, конфликты на следующем apply).
Работает только косвенно: `terraform_data`/`null_resource` + `local-exec` → в state попадает **факт** «операция выполнена» (маркер с `triggers`), но не **состояние** SNAT/IP. Порядок задаётся через `depends_on`, но дрейф по самим параметрам `plan` не видит.
---
## 3. Каноничное решение: отдельный ресурс (resource association)
Это принятая в Terraform практика — «resource association / separate resource». Классические примеры:
- `aws_security_group` + `aws_security_group_rule`
- `aws_vpc` + `aws_route_table_association`
- `google_project` + `google_project_iam_member`
Базовый ресурс создаётся отдельно, а донастройка/привязка — отдельным ресурсом с `depends_on`. Граф сам выстраивает порядок, `destroy` разворачивает его корректно.
### 3.1. Прецедент из Cloud Director (VCD)
В репозитории лежит сторонний шаблон — `/home/naeel/TF/tf_provider/!/` (network.tf.tmpl, vmware_org.tf, vdc.tf), показывающий, как та же цепочка делается провайдером VMware Cloud Director:
- `resource "vcd_nsxt_alb_settings"` — включение ALB, `count = var.alb_enable ? 1 : 0`, `depends_on = [vcd_nsxt_edgegateway...]`;
- `resource "vcd_nsxt_alb_edgegateway_service_engine_group"` — выделение SE, `reserved_virtual_services = var.alb_segroup_count`;
- `resource "vcd_network_routed_v2"` — routed-сеть, `edge_gateway_id`, `dns1/dns2/static_ip_pool`;
- `resource "vcd_ip_space_custom_quota"` — квота IP на **оргу**, `depends_on = [edge]`.
Приём «включить/выключить» = `count`. Обратная операция (выключить ALB / снять квоту) получается **удалением ресурса** — inverse логика не нужна.
Маппинг на наши сервисы:
| Nubes API | Канон VCD |
|---|---|
| `needEnableAVI` | `vcd_nsxt_alb_settings` (+ `count`) |
| `virtualServicesCount` | `reserved_virtual_services` в SE-группе |
| `routedNetConfiguration` (mainDns/secondDns/ipAddrPool) | `vcd_network_routed_v2` (`dns1/dns2/static_ip_pool`) |
| `vIPConfigure` (IP на оргу) | `vcd_ip_space_custom_quota` (на оргу) |
### 3.2. Чем наш случай сложнее канона
В классическом паттерне ребёнок — **отдельный объект API** со своим CRUD (правило, association, attachment). Его можно создать/прочитать/удалить.
У нас отдельного объекта нет — есть **операция `modify` над родителем**. Поэтому требуются:
1. `Read` — не свой объект, а чтение состояния родителя;
2. `Delete` — не удаление, а **обратный modify** (inverse);
3. `Create/Update` — вызов той же операции с параметрами;
4. идемпотентность (не дёргать `run`, если live уже целевое) и live-сверку.
Именно поэтому «просто завести поля из modify в схему» не работает — нужна полноценная механика, а не одна правка.
---
## 4. Более глубокая проблема: скрытые платформенные зависимости
Это главное из всей дискуссии (реплики Виталия Зайцева, 18:30–18:34).
### 4.1. `ipSpaceName` нельзя ввести вручную — он выводится
```text
имя ipSpace → зависит от providerGateway
providerGateway → зависит от providerVdc
providerVdc → никто не знает изначально
```
Пользователь **не может** заполнить `ipSpaceName`, потому что это значение выводится из внутренней топологии (providerVdc → providerGateway → ipSpace), а не из того, что он видел в ЛК. Это не «поле, которое забыли отдать через API», а **вычисляемое от скрытых зависимостей** значение.
### 4.2. Текущий костыль платформы
«При создании орги/vdc/edge, если организация ничего не знает про недостающие параметры — они подкладываются». То есть одноразовая подстановка при создании пустой орги, чтобы избавить пользователя от «мучительных приседаний» в ЛК.
### 4.3. Ограничение модели: один T0
«Другая проблема — что будет, если в облаке появится больше 1 T0». Пока принято допущение на уровне кода: **в организации всё одно подключение**. Решение осознанно отложено («пара лет спокойствия»), но для IaC это риск: текущее решение завязано на «в орге всегда один провайдер-шлюз».
### 4.4. Ожидание изменений спеков
«Мне надо увидеть, как спеки поменяются, чтобы понять, что исправлять… Надеюсь, появится сначала в sandbox.nubes.ru, а не в ngcloud». То есть платформа меняется, форма ресурсов зависит от **новых спеков**, и строить модификатор сейчас = работать по устаревшим спекам.
---
## 5. Итог: где правда
1. **Ручной ЛК и скрипт не подходят** — клиент требует IaC (Георгий прав). Это не «костыль против красоты», это невыполнение требования.
2. **Отдельный ресурс под модификацию — необходимое, но не достаточное условие.** Он закрывает «как expressить modify», но не закрывает «откуда юзер возьмёт значения».
3. **Главная блокировка — не Terraform, а платформа.** `ipSpaceName` (и подобные) выводятся из `providerVdc → providerGateway → ipSpace`, которые юзер не знает. Пока платформа не отдаёт эти значения в спеках (или провайдер не резолвит их data-источником), честный IaC не собрать — ни модификаторами, ни скриптом, ни руками.
4. **«Ломается агностичность» — верно, но это не порок, а цена.** Ресурсы-модификаторы доменные и «ручные», как в VCD. Без них IaC невозможен, прецедент — перед глазами (`!/network.tf.tmpl`).
5. **Состояние дел:** платформа в движении (ждут новые спеки). Правильная последовательность — дождаться, что придёт в спеках (snandbox), а затем решать форму ресурса; не строить по старым спекам.
---
## 6. Возможные пути (по убыванию «честности» перед IaC)
| Вариант | Что делает | Вердикт |
|---|---|---|
| **A. Полноценные ресурсы-модификаторы + data-источники** | отдельный tf-ресурс на modify + data-source, резолвящий `ipSpace`. Полный IaC. | правильно, но только после новых спеков |
| **B. Data-source через `http`/`external` + `jsondecode`** | DevOps сам дёргает API и подставляет динамические списки, без правки провайдера | рабочая «дожималка», не полный IaC |
| **C. `terraform_data`/`null_resource` + `local-exec`** | модификации скриптом, факт в state, порядок через `depends_on` | полумера, состояние SNAT/IP вне state |
| **D. Прессеты/дефолтное окружение** | готовый набор компонентов, экспорт через провайдер | снижает боль на старте, IaC не заменяет |
| **E. Ручной ЛК / скрипт вне tf** | модификации руками | не подходит (требование клиента) |
---
## 7. Моё мнение
**Коротко:** для настоящего IaC нужны обе вещи одновременно — **отдельный ресурс под `modify`** и **механизм получения динамических значений** (`ipSpace` и пр.). Пока платформа не отдаёт второе через API/спеки, все «быстрые» способы (скрипт, руками, пресеты) закрывают только симптом, а не требование клиента.
**Рекомендация:** не городить модификатор сейчас по устаревшим спекам. Дождаться изменений спеков (сначала sandbox), параллельно — обсчитать два blockers: (1) как провайдер будет резолвить `providerVdc → providerGateway → ipSpace` без ручного ввода; (2) допущение «один T0». После этого проектировать форму ресурсов.
**Что точно не делать:** вписывать параметры «насильно» в tfstate; ждать, что «прописал поле в tf → apply» заработает без правки провайдера. (`vIPConfigure` при этом НЕ накопительный — см. §2.2, тест 2026-09-22.)
@@ -0,0 +1,194 @@
# Анализ 43 YAML-файлов из нового API Gateway
> Дата: 2026-07-02
> Источник: `devops/profiles/test/generated/resources_yaml/`
> Gateway: `https://lk-api-gateway-test.ngcloud.ru/api/v1/svc`
---
## Идеология построения сервисов
### Два класса сервисов
| Класс | Признак | Примеры | Кол-во |
|---|---|---|---|
| **Простые (flat)** | Плоские параметры: `resourceRealm`, `resourceCPU`, `domain`, ... | s3bucket, vc_vm, dnszone, harbor, gitea, rabbitmq, ... | **31** |
| **Сложные (map-fixed)** | Структурированные JSON-блоки: `clusterConfiguration`, `startupConfiguration`, ... | postgres, redis, mariadb, clickhouse, kafka, nextcloud, flask, lucee, nodejs, valoTenant, dummy, template | **12** |
**Закономерность**: map-fixed используют сервисы, разворачиваемые в Kubernetes (БД, приложения). Простые flat — всё остальное (VM, сеть, DNS, S3).
---
## Типы данных (435 параметров всего)
| Тип | Кол-во | Где |
|---|---|---|
| `string` | 146 | Имена, домены, UUID |
| `integer > 0` | 101 | CPU, memory, disk |
| `map-fixed` | 93 | K8s-сервисы (12 шт.) |
| `boolean` | 21 | Флаги |
| `array-map-fixed` | 17 | Массивы объектов (7 сервисов) |
| `uuid` | 14 | ref-параметры |
| `json` | 11 | Произвольный JSON |
| `map` | 14 | Только в outputs |
| `yaml` | 2 | Редко |
| `array` | 1 | Редко |
| `integer >= 0` | 2 | Числовые, допускающие 0 |
---
## Обязательность
- **required: 340** (78%)
- **optional: 95** (22%)
- **default: 120** параметров имеют значение по умолчанию
Большинство параметров обязательные, но многие с дефолтами.
---
## Типы операций
| Kind | Кол-во | Паттерн |
|---|---|---|
| `instance` | 164 | create/delete + modify/suspend/resume (у 27 из 43) |
| `action` | 32 | reconcile (20), redeploy (7), restart (3), recovery (2) |
| `subresource` | 31 | user (16), database (6), topic (3), backup (2), vdc (2) |
---
## Subresource-паттерн
Самый частый — `user` (16 сервисов). Создаётся отдельный `nubes_{service}_user` ресурс с параметрами `username` + `role`. Аналогично `database` (6 сервисов) с `dbName` + `dbOwner`.
| subresource | Кол-во | Параметры |
|---|---|---|
| `user` | 16 | `username` (string, regex), `role` (string, value_list) |
| `database` | 6 | `dbName` (string, regex), `dbOwner` (string) |
| `topic` | 3 | Kafka |
| `backup` | 2 | S3, PostgreSQL |
| `vdc` | 2 | vcOrg |
| `sub_user` | 2 | openwhisk |
---
## Топ параметров create
| code | Кол-во | Назначение |
|---|---|---|
| `resourceRealm` | 19 | Платформа/K8s-кластер |
| `resourceMemory` | 11 | Память |
| `resourceCPU` | 11 | CPU |
| `startupConfiguration` | 10 | map-fixed (K8s) |
| `clusterConfiguration` | 10 | map-fixed (K8s) |
| `accessConfiguration` | 10 | map-fixed (K8s) |
| `resourceInstances` | 10 | Кол-во реплик |
| `domain` | 9 | Домен |
| `resourceDisk` | 8 | Диск |
| `ipSpaceName` | 4 | IP-space |
| `appConfiguration` | 4 | Конфигурация приложения |
| `jsonEnv` | 4 | Переменные окружения |
| `backupConfiguration` | 3 | Бэкапы |
| `autoscaleConfiguration` | 3 | Автоскейлинг |
| `ipSpaceNameMaster` | 3 | IP для master |
| `vdcUid` | 3 | Ссылка на VDC |
| `vappName` | 2 | Имя vApp |
| `storageConfig` | 2 | Конфигурация хранилища |
---
## Action-операции
| action | Кол-во | У каких сервисов |
|---|---|---|
| `reconcile` | 20 | Универсальная синхронизация (почти все) |
| `redeploy` | 7 | flask, nodejs, lucee, nifi, superset, nextcloud, gitea |
| `restart` | 3 | postgres, mariadb, redis |
| `recovery` | 2 | postgres, clickhouse |
---
## Жизненный цикл
- **27 из 43** сервисов поддерживают `suspend`
- **27 из 43** сервисов поддерживают `resume`
- Все кто имеет suspend — имеют и resume (и наоборот)
---
## Валидация параметров
| Механизм | Кол-во | Пример |
|---|---|---|
| `regex` | 29 | `dbName: ^[A-Za-z0-9]+$` |
| `value_list` | 38 | `role: [app_user, ddl_user]` |
| `min/max value/length` | 87/74 | Числовые и строковые ограничения |
| `default` | 120 | `deleteS3Bucket: true` |
| `is_modifiable` | 106 (24%) | Можно менять после создания |
| `is_sensitive` | 2 | `vault_secrets` |
---
## Сервисы с map-fixed (12)
`mariadb`, `clickhouse`, `valo_tenant`, `k8s_sthutrval_cluster`, `dummy`, `template`, `nextcloud`, `flask`, `postgres`, `redis`, `lucee`, `nodejs`
## Сервисы с array-map-fixed (7)
`mariadb`, `clickhouse`, `k8s_sthutrval_cluster`, `vc_org`, `dummy`, `vc_vdc`, `postgres`
---
## Сервисы без операций (4)
`openwhisk`, `gitea_complex`, `vmpostgre`, `template` — только заглушки/заготовки.
---
## Полный список сервисов и их операций
```
100_openwhisk | (нет операций)
110_dnszone | instance
111_dnsrecord | instance, action
112_tenant | instance
113_vc_complex | instance
114_gitea_complex | (нет операций)
115_mariadb | instance, subresource, action
116_kafka | instance, subresource
117_nifi | instance
119_akhq | instance
120_clickhouse | instance, subresource, action
12_s3 | instance, subresource, action
13_s3bucket | instance
149_valo_tenant | instance
150_k8s_sthutrval_cluster | instance, subresource, action
19_vc_org | instance, subresource, action
1_dummy | instance, action
20_vc_org_saas | instance
21_vc_vdc | instance, action
22_vc_nsxt | instance, action
23_vc_vm | instance
25_vcexternalip | instance
26_vapp | instance, action
27_vc_vm_v2 | instance
28_vc_vm_v3 | instance, action
29_vc_vdc_group | instance, subresource, action
2_template | instance, action
32_vmpostgre | (нет операций)
50_nextcloud | instance, subresource, action
81_superset | instance
82_harbor | instance
88_k8s_ziti_controller | instance
89_flask | instance, action
90_postgres | instance, subresource, action
91_redis | instance, action
92_mongodb | instance, subresource
93_rabbitmq | instance
94_lucee | instance, action
95_nodejs | instance, action
96_pgadmin | instance, action
97_nodered | instance
98_http | instance, action
99_gitea | instance
```
@@ -0,0 +1,53 @@
# Inverse-откат модификаторов: анализ ответа Опуса — 2026-09-23
Источник: prompt_for_opus_inverse_architecture.md → ответ Опуса (принят, анализ ниже).
## Принятые решения (по Опусу)
1. **Модель delete_params** → заменить плоский `{Code, Value}` на `{Code, Mode, Value?}`:
- `Mode: static` — значение из `Value` (дефолт, обратная совместимость).
- `Mode: zero_count` — обнулить integer-поля в элементах array-map-fixed, взяв live.
2. **Баланс данные/логика**: форма преобразования выводится из `dataType`
(boolean→"false", array-map-fixed→zero integer); сентинелы-значения — ТОЛЬКО данные в реестре.
3. **Маркер поля**: явный `zero_fields:["count"]` (или флаг на sub_param), а НЕ «обнулить все integer»
(риск: порт/приоритет/индекс в том же object).
4. **Порядок destroy** — обратный порядок создания из `depends_on`.
## Мои замечания к ответу (что Опуc недоговорил)
- **A. Граф уже правильный.** Факт: `edge_net` (SNAT) зависит от `org_ips` (IP), `org_ips` — от `edge`.
Обратный порядок destroy: `edge_net → org_ips → edge → vdc` уже корректен.
Рекомендация Опуса «сделать ip_space зависимым от edge_net» — перепутана направлением; граф уже такой.
- **B. Источник live для zero_count в Delete не указан.** `Delete` модификатора имеет только TF `state`,
а live `vIPConfigure` надо читать через `GetInstanceStateParams` в рантайме Delete.
- **C. Отличие sentinel от имени в valueList не разобрано** (valueList без разметки sentinel в данных API).
- **D. Идемпотентность zero_count при повторном destroy не поднята** (count=0 → снова 0: no-op?).
## Открытые вопросы (второй раунд к Опусу) — ЗАКРЫТЫ
1. **Источник live для zero_count в Delete** → `GetInstanceStateParams` (не TF state). ✅
2. **Sentinel в valueList** → явный `off_value:"no-needed"` в реестре (данные, не логика). ✅
3. **Идемпотентность zero_count** → пропускать `run`, если live уже `count=0` (применимо и к static). ✅
4. **count=0** → канонический inverse; полное удаление ipSpace = отдельный опциональный `Mode:remove` (не подменять zero_count). ✅
## ИТОГ — финальная модель inverse
`delete_params: []{ Code, Mode, Value?, zero_fields?, off_value? }`
- `Mode: static` — обратное значение = `Value` (boolean→"false"; string→off_value).
- `Mode: zero_count` — взять live array-map-fixed, обнулить поля из `zero_fields:["count"]`.
- `Mode: remove` (опц., не для FullPipe) — полное удаление элемента.
Рантайм: Delete → `GetInstanceStateParams` → построить inverse → если live уже целевое → no-op (skip run) → иначе `modify`.
Реестр (yaml-generator main.go):
- `vc_org.ip_space`: `inverse`, param `vIPConfigure` `Mode:zero_count, zero_fields:[count]`.
- `vc_nsxt.network`: `inverse`, params `needEnableAVI`=static `"false"`, `ipSpaceName`=static off_value `"no-needed"`.
Порядок destroy (уже корректен в .tf): `edge_net → org_ips → edge → vdc`.
## Факты (не менять, проверено)
- `count=0` принимается API (minvalue:1 в схеме — не отвергает), идемпотентно (ORG_IP_MODIFIER_TEST_2026-09-22).
- SNAT off = `ipSpaceName="no-needed"` (sentinel в valueList, HAR_SNAT_MODIFY_FINDINGS).
- `needEnableAVI` boolean → inverse `"false"`.
- vc_org.ip_space: `error` → нужен `inverse` (zero_count). vc_nsxt.network: inverse+static `needEnableAVI=false`, добавить `ipSpaceName="no-needed"`.
+264
View File
@@ -0,0 +1,264 @@
# Ответ Opus 4.8: код-ревью и оценка архитектуры (3 задачи roadmap)
Дата: 2026-09-22 | Вопрос: `docs/prompt_for_opus_review.md` (коммит `7eab45e`)
Ответ получен строго по контексту промпта (файлы не открывались).
---
Задание — код-ревью по контексту (файлы открывать запрещено). Отвечаю сжато по пунктам.
## 1. Покрывает ли модель 3 задачи
| Задача | Вердикт | Причина |
|---|---|---|
| vcOrg modify (динамич. IP) | 🟡 | Покрыта, если аллокация = modify-параметр (кол-во/список). Проблема — тип массива (array-map-fixed = JSON-строка) и семантика «растёт по мере необходимости» |
| vcNsxt modify (SNAT + IP из пула vcOrg) | 🔴 | Текущая модель НЕ покрывает: refSvc ссылается на инстанс/uid, а нужен конкретный элемент списка IP. Нужна новая связка (computed-атрибут + ссылка) |
| k8sShturval create | 🟢 | Стандартный CRUD-create, покрыт. Риски — только long-create и типы параметров |
## 2. Как моделировать пул IP vcOrg
- **(б) отдельный ресурс/подресурс на IP** — ✗ противоречит архитектуре: «один сервис = один ресурс», подресурсы/data source не генерируются. Потребует ломки генератора.
- **(а) атрибут-массив на nubes_vc_org** — ✅ согласуется. Но НЕ через array-map-fixed (JSON-строка: нет семантического diff, ломается порядок, нельзя адресовать элемент).
- **Рекомендация:** декларативное желаемое состояние — либо `int64 desired_count` (modify), либо реальный `ListNestedAttribute`; аллоцированные IP отдавать как **типизированный computed** (list объектов), а не плоскую JSON-мапу. Императивная семантика «добавь ещё один» несовместима с декларативным Terraform.
## 3. Как vcNsxt получает IP из vcOrg
| Вариант | Оценка |
|---|---|
| (а) refSvc-параметр | 🔴 не может указать на конкретный элемент списка, только на uid инстанса |
| (б) computed-атрибут vcOrg + ссылка `nubes_vc_org.x.<attr>` | 🟢 лучший фит. IP в nsxt — обычная string, пользователь берёт конкретный элемент |
| (в) data source | ✗ не генерируется |
**Рекомендация — (б).** Условие: computed-вывод vcOrg должен быть **индексируемым типизированным** (`allocated_ips[0]`), не спрятанным в JSON-строке `state_out_flat` — иначе граф зависимостей Terraform не построит ребро и придётся ручной `depends_on`.
## 4. Риски текущего кода для этих потоков
- 🔴 **modify с массивами:** array-map-fixed = JSON-строка → нет семантического diff, смена порядка API даёт ложные modify, «дорастить пул» = полная замена списка (опасно, возможен ре-аллок).
- 🟡 **create-only guard:** если параметры SNAT-IP / аллокации помечены не как `is_modifiable` → ModifyPlan заблокирует задачи 1 и 2. Проверить флаги в YAML.
- 🟡 **read-back:** если API возвращает IP в нестабильном порядке → вечный drift на vcOrg.
- 🟡 **порядок плана:** зависимость nsxt→vcOrg держится, только если ссылаемый IP — first-class computed (unknown на plan). Внутри JSON-строки Terraform зависимость не отследит → apply-time ошибки.
## 5. k8sShturval create — что критично
- refSvc к vdc/org резолвится (display name ИЛИ uid).
- 🔴 long-create: create-op должен **ждать готовности** (poll ready-state), а не только «принято»; следить за context timeout.
- типы параметров: map-fixed (nested) vs array-map-fixed (JSON), корректность required/default.
- read-back state_params после create для computed-полей.
## 6. Итог — рекомендации по приоритету
1. 🔴 Добавить в vcOrg **типизированный индексируемый computed** для аллоцированных IP (list объектов, не JSON-мапа) — разблокирует задачу 2 через ссылку.
2. 🔴 Моделировать аллокацию как **декларативное желаемое состояние** (int64 count или ListNested) с реконсиляцией в modify; параметры пометить `is_modifiable`.
3. 🟡 Заменить array-map-fixed (JSON-строка) на **ListNestedAttribute** — ради адресуемости элементов, семантического diff и стабильного порядка.
4. 🟡 Гарантировать граф зависимостей: ссылаемый IP — отдельный computed-атрибут, не поле JSON, чтобы не требовать `depends_on`.
5. 🟢 k8sShturval: добавить ожидание готовности (poll) в long-create и проверить резолв refSvc.
**Ключевой вывод:** задачи 1 и 3 укладываются в текущую модель с доработками генератора; **задача 2 требует новой абстракции** — типизированного computed-вывода на vcOrg (вариант 3б), т.к. refSvc принципиально не адресует элемент списка.
---
## Раунд 2 — уточняющие вопросы и пересмотр
После проверки кода выяснилось, что промпт раунда 1 описывал модель неполно. Opus получил уточнённый контекст и 7 доп. вопросов.
### Уточнение контекста (передано Opus)
- Генератор уже поддерживает 4 вида ресурсов: instance (`nubes_<svc>`), subresource, action (только redeploy), modifier (`nubes_<svc>_<modifier>`).
- Modifier-ресурсы для задач 1 и 2 УЖЕ СГЕНЕРИРОВАНЫ:
- `nubes_vc_org_ip_space` (vc_org, modify 207): `vc_org_id` (required), `v_ip_configure` (required, StringAttribute = JSON-строка). Computed-выводов НЕТ.
- `nubes_vc_nsxt_network` (vc_nsxt, modify 111): `vc_nsxt_id` (required), `need_enable_avi` (bool), `virtual_services_count` (int64), `ip_space_name` (string optional), `qos_profile` (string), `routed_net_configuration` (required, JSON-строка). Computed-выводов НЕТ.
- Маппинг: `map-fixed` → SingleNestedAttribute; `array-map-fixed` → StringAttribute (JSON-строка).
- Завершение операции — поллинг до `dtFinish`. Delete modifier — no-op.
- Связка NSXT↔Org идёт **по имени ipSpace**: `vc_org.modify` задаёт `vIPConfigure[].name`, `vc_nsxt.modify` принимает `ipSpaceName` = это имя. Конкретный IP нигде не адресуется.
### Ответ Opus (пересмотр)
## 1. Верен ли прошлый вердикт про «новую абстракцию»
🟢 **Прошлый вывод отменяется.** Он строился на неполной модели. modifier-ресурсы уже существуют → задача 2 реализуема сегодня двумя модификаторами (`nubes_vc_org_ip_space` → `nubes_vc_nsxt_network`), связанными по имени ipSpace. Никакой новой абстракции и ломки генератора не требуется.
## 2. Минимальная доработка генератора для ссылки вместо строки
Добавить **один computed-вывод** на `nubes_vc_org_ip_space` — эхо `v_ip_configure[].name` (имя ipSpace). Тогда:
```
ip_space_name = nubes_vc_org_ip_space.x.ip_space_name
```
Это даёт ребро графа + защиту от опечатки. Адресовать IP не нужно — связка по имени. Опционально сверх минимума: валидация, что имя существует в org (refSvc-подобная проверка `ip_space_name` по vIPConfigure целевого org).
## 3. SNAT: свободная строка или computed обязателен
- Функционально **свободная строка + `depends_on` работает** (связка по имени, IP не адресуется). 🟡 Риски: опечатка, нет автоматического ребра графа без ручного depends_on, нет drift-детекта.
- **Экспортировать имя (string), НЕ список IP.** Список IP не нужен — привязка идёт по имени ipSpace, не по конкретному адресу.
- Вердикт: не строго обязателен для функции, но настоятельно рекомендован для корректности графа. Минимум — computed `ip_space_name` (string).
## 4. v_ip_configure как JSON-строка — реально ли бьёт
🟢 **В этом сценарии практически безвредно.** Значение фактически write-only (computed read-back нет), потребителю нужно только имя, которое пользователь сам задал. Поэлементное сравнение/адресация не требуются → прошлый 🔴 понижается.
Остаточный 🟡: спурьёзный diff при переформатировании JSON пользователем; если count растёт со временем — семантика «полная замена списка» без гранулярного diff может ре-триггерить modify/ре-аллокацию. Но без read-back и при связке по имени это не блокер.
## 5. Delete modifier = no-op
🟡 **No-op допустим** для association/one-shot модификатора, если обратного payload нет — это известный паттерн.
**Реальные последствия destroy:** Terraform удалит запись из state, но бэкенд НЕ освободит IP и НЕ отключит SNAT → утечка ресурсов + расхождение state↔бэкенд.
- Если бэкенд поддерживает обратную операцию (де-аллокация / disable SNAT) — no-op это реальный пробел 🔴, надо подключить reverse-op.
- НЕДОСТАТОЧНО ДАННЫХ: есть ли у vc_org/vc_nsxt обратный payload. Если его действительно нет — no-op единственный вариант, но обязательно задокументировать поведение destroy.
## 6. Нужен ли ещё «int64 desired_count на инстансе»
🟢 **Нет, прошлый п.2 отменяется.** modifier — правильная единица для чередующейся зависимости (org аллоцирует → nsxt потребляет → org доаллоцирует). Счётчик на инстанс-ресурсе связал бы жизненный цикл пула с жизненным циклом инстанса и сломал бы взаимное чередование org↔nsxt. **Отдельный modifier строго лучше.**
## 7. k8sShturval create — что остаётся критичным
Помимо резолва refSvc (display name ИЛИ uid) и корректности required/default в map-fixed/array-map-fixed:
- 🟡 **read-back computed после create** (kubeconfig/vault-выводы) — что реально возвращается в state.
- 🟡 **обработка failure-состояния в поллинге**: dtFinish должен различать «готово успешно» и «завершилось с ошибкой», иначе провал маскируется под успех. НЕДОСТАТОЧНО ДАННЫХ по логике ошибки в poll-цикле.
- 🟢 timeout-бюджет long-create vs интервал поллинга.
---
**Итог пересмотра:** обе задачи 1 и 2 **укладываются в существующий modifier-паттерн без новых абстракций**. Единственная стоящая доработка — добавить computed-экспорт `ip_space_name` на org-модификатор (п.2/3), чтобы заменить свободную строку ссылкой и построить граф зависимостей. Проблема JSON-строки в этом сценарии не критична (п.4). Главный остаточный риск — no-op delete и утечка IP/SNAT при destroy (п.5), требует проверки наличия обратного payload.
---
## Раунд 3 — дрейф сгенерированного кода и защитный механизм
Отдельная тема (не roadmap): расхождение `generated/{stand}/go` ↔ `provider/internal/resources_gen`, как предотвратить и сделать видимым.
### Контекст, переданный Opus
Go-провайдер Terraform. Ресурсы генерируются из YAML-спеков генератором.
Цепочка: YAML → generated/{stand}/go/*.go → (копирование) → provider/internal/resources_gen/*.go → сборка бинаря.
Факты:
- generated/ и provider/internal/resources_gen/ внесены в .gitignore → дрейф между ними НЕ виден в git status/diff.
- Реальный дрейф уже случился: копия в provider/ устарела (сгенерирована до двух последних фиксов генератора).
- Есть два скрипта сборки: (а) правильный — копирует свежий generated во временный каталог provider и собирает оттуда; (б) рисковый — собирает напрямую из provider/ (берёт потенциально устаревшую локальную копию).
Вопросы: 1) коммитить автоген или .gitignore+проверка; 2) два пути сборки — корень ли; 3) что предотвращает дрейф, а не детектит; 4) убрать вторую копию и генерировать при сборке; 5) как гарантированно «видно сразу при изменении кода».
### Ответ Opus
## 1. Коммитить сгенерированный код или .gitignore + проверка дрейфа
🟢 **Коммитить сгенерированный код** (для соло/малой команды это надёжнее). Обоснование:
- Расхождение всплывает в `git diff`/PR-ревью — там, куда уже смотришь, а не в отдельной проверке, которую надо не забыть запустить.
- CI-гейт `regenerate && git diff --exit-code` превращает дрейф в жёсткую ошибку сборки.
- Минус — шум в диффах от больших автогенов, но это приемлемая цена за видимость.
.gitignore + внешняя проверка дрейфа 🟡 — работает, только если проверку невозможно обойти; сейчас её обходят вторым скриптом. Скрытый код = скрытый дрейф (уже случилось).
## 2. Два пути сборки — корень проблемы?
🔴 **Да, это корень.** Проблема не в дрейфе как таковом, а в том, что **существует путь, собирающий из потенциально устаревшей копии**. Пока рисковый скрипт (б) доступен, дрейф рано или поздно попадёт в бинарь.
**Оставить ровно один канонический путь.** Скрипт (б) удалить или сделать так, чтобы он физически не мог собрать из несвежей копии (см. п.3–4).
## 3. Что ПРЕДОТВРАЩАЕТ дрейф, а не только детектит
| Механизм | Роль |
|---|---|
| Жёсткий fail на старте сборки | 🟡 **детект** — не даёт собрать плохой бинарь, но дрейф уже есть |
| Авто-синхронизация/генерация перед сборкой | 🟢 **предотвращение** — копия физически не может быть устаревшей |
| git pre-commit hook | 🟡 **детект** на этапе коммита |
**Предотвращает только генерация/синк как обязательная часть сборки** (копия всегда свежая). Fail и hook — детекторы. Рекомендуемая комбинация: **генерация-при-сборке (предотвращение) + CI `git diff --exit-code` после регенерации (страховка от обхода)**.
## 4. Более сильный паттерн: убрать вторую копию, генерировать при сборке
🟢 **Да, это сильнейший вариант.** Нет постоянной копии → нечему рассинхронизироваться. Единственный источник истины = YAML + генератор (`go generate` / шаг Makefile перед `go build`).
Минусы:
- 🟡 Сборка зависит от наличия и детерминированности генератора (версию генератора надо пинить).
- 🟡 Медленнее сборка.
- 🔴 Провайдер перестаёт быть чистым Go-модулем: нельзя `go get`/собрать без тулчейна генератора; воспроизводимость завязана на версию генератора.
- Сложнее просто «посмотреть» сгенерированный код без сборки.
- НЕДОСТАТОЧНО ДАННЫХ: детерминирован ли генератор (стабильный порядок полей/файлов) — без этого diff-гейт будет шуметь.
**Компромисс:** генерировать при сборке И коммитить результат (генерация переписывает закоммиченную копию, CI ловит расхождение) — совмещает «невозможно устареть» с «видно в диффе».
## 5. «Видно сразу при изменении кода, не закопано в доках»
Гарантированно это даёт **только закоммиченный сгенерированный код**: расхождение появляется в `git diff` того же PR, где меняется генератор/YAML. Усилить:
- **pre-commit hook** `regenerate` → расхождение всплывает до коммита;
- **CI-гейт** `regenerate && git diff --exit-code` → нельзя смёржить с дрейфом.
Вариант «генерировать при сборке без копии» даёт *другую* гарантию — «устареть невозможно», но там **нет диффа для обзора**. Если требование именно «видно в изменениях кода» — нужен закоммиченный автоген.
---
**Итог — рекомендация по приоритету:**
1. 🔴 Убрать рисковый путь сборки (б); оставить один канонический.
2. 🟢 Сделать генерацию/синк обязательным шагом канонической сборки (предотвращение).
3. 🟢 Закоммитить сгенерированный код + CI-гейт `regenerate && git diff --exit-code` (видимость + защита от обхода).
4. 🟡 Запинить версию генератора и обеспечить детерминированный вывод, иначе гейт будет ложно срабатывать.
---
## Раунд 4 — универсальность и реестр исключений
Тема: принцип «универсально по умолчанию, исключения — только если API не ложится», и как кодировать исключения, чтобы они были однозначно понимаемы при каждой генерации.
### Контекст, переданный Opus (кратко)
Пайплайн: YAML-спека сервиса (из API) → универсальный генератор → Go-ресурсы → сборка. Код/скрипты/логика генерации — одни для всех стендов и сервисов; различается только YAML. Механизм modifier-ресурсов уже универсален (дженерик читает `kind: modifier` + имя). Не универсально сейчас: хардкод в yaml-generator (`svc.ID == 19 → ip_space`, `svc.ID == 22 → network`) и в docs-generator (`ServiceID == 90`, спец-вывод postgres). Отдельно был инцидент дрейфа git-ignored сгенерированного кода (фон).
Выработанный принцип (просили оценить): «Универсально по умолчанию. Исключение — только если API реально не ложится. Каждое исключение — одна явная таблица/структура, которую КОД ЧИТАЕТ, с полями ЧТО/ЧТО ДЕЛАЕТ/ПОЧЕМУ/ГДЕ. Канонический список — в ARCHITECTURE-доке, код ссылается. Изменение = изменение таблицы → видно в диффе.»
### Ответ Opus
## 1. Оценка принципа
🟢 **Принцип корректен долгосрочно** — «convention over configuration + escape hatch».
Подводные камни:
- 🟡 «API не ложится» субъективно → нужен объективный тест-триггер, иначе exception creep.
- 🔴 Исключения не возвращаются в ядро: когда паттерн повторился 2–3 раза, нужен ритуал «промоушена» в ядро.
- 🟡 Обратный перекос: обобщать реально одноразовый случай — раздувает ядро.
## 2. Как кодировать исключения
| Вариант | Видимость в diff | Нельзя «проспать» | Поддержка | Рассинхрон с YAML |
|---|---|---|---|---|
| (а) именованная таблица в коде, код её читает | 🟢 | 🟢 (если итерирует и падает на неучтённом) | 🟡 нужна пересборка | 🟢 низкий |
| (б) отдельный yaml-конфиг | 🟢 | 🟡 легко забыть подключить | 🟢 без пересборки | 🟡 средний |
| (в) аннотации в YAML-спеке | 🔴 | 🔴 | 🔴 | 🔴 фатально: YAML регенерится → аннотации затираются |
**(в) отклонить.** **Рекомендация: (а)** — именованная структура, которую код итерирует и ассертит.
## 3. Граница «логика» vs «данные»
- В ядре — механизм/алгоритм (как модификатор генерится, маппинг схемы). Никогда не per-service.
- В реестре — чистые данные («сервис X → имя модификатора Y», «сервис 90 → набор полей Z»).
Признаки: 1) `if id == N`, меняющий поток исполнения → извлечь данные; 2) убрать пункт → меняются только значения, не поведение → данные; 3) copy-paste кода → механизм (обобщать), разные строки таблицы → данные.
## 4. Паттерн «override registry» в кодогенераторах
- tfplugingen-openapi: `generator_config.yml` отдельно от спеки + IR (`terraform-plugin-codegen-spec`).
- OpenAPI Generator: vendor extensions `x-*` + template-оверрайды + config-json.
- protoc-плагины: custom options (напр. `google.api.http`).
Что перенять: 1) отдельный версионируемый конфиг оверрайдов; 2) IR-слой; 3) fail на неучтённом; 4) стабильное символьное имя, не сырой ID.
## 5. Не противоречит ли реестр «YAML — единственный источник»
Не противоречит — при разделении двух доменов истины:
- **API-YAML** = истина про «что есть сервис» (машинно-владеемый, регенерится).
- **Реестр оверрайдов** = истина про «наши провайдер-специфичные решения» (человеко-владеемый).
Теневой источник — только если ОДИН факт лежит в обоих. 🔴 Нельзя аннотировать API-YAML. Конвейер: `API-YAML + overrides → merged IR → codegen`.
## 6. Риски «таблица + раздел в ARCHITECTURE.md»
- 🔴 ARCHITECTURE.md дрейфует от таблицы → возврат к `if id==19`, если кто-то добавит ветку в обход.
- 🔴 Числовые ID (19/22/90) непрозрачны.
Как закрыть: 1) 🔴 единая точка маршрутизации + CI-lint/grep-гейт против `svc.ID ==` вне реестра; 2) раздел ARCHITECTURE генерировать ИЗ реестра (golden-test); 3) 4 поля — поля структуры, а не комментарии; 4) стабильные символьные ключи; 5) fail-fast: генератор падает, если спец-обработка без записи в реестре.
---
**Итог:** сильнейшая реализация — отдельный человеко-владеемый override-реестр (данные, не логика), стабильные ключи, IR-слой применения, CI-гейт против хардкодов вне реестра, раздел ARCHITECTURE генерируется из реестра. Это устраняет и `if id==N`, и дрейф доки.