feat: stages output + services_list sync

- stages always visible (remove log_level gating)
- fix tty resource leak (move out of poll loop)
- show current stage [..] in addition to completed [OK]/[FAIL]
- sync services_list.txt for all 3 stands with live UI
- add YAML vs API comparison script
- bump TEST version 5.0.1 -> 5.0.2
This commit is contained in:
“Naeel”
2026-08-09 18:18:35 +04:00
parent b6b25f7548
commit fda819cd81
9 changed files with 470 additions and 31 deletions
+195
View File
@@ -0,0 +1,195 @@
# Sonnet Briefing: вывод этапов (stages) в Terraform-провайдере Nubes
> Цель задания: изучить код и выдать **точный план** — какие строки в каких файлах менять.
> Без реализации. Только анализ и unified diff.
---
## 1. Суть проблемы
### Как сейчас (плохо)
При `terraform apply/destroy` пользователь видит тупой счётчик:
```
nubes_postgres.pg_db: Still creating... [00m10s elapsed]
nubes_postgres.pg_db: Still creating... [00m20s elapsed]
nubes_postgres.pg_db: Still creating... [00m30s elapsed]
```
Это сообщения самой Terraform (не нашего кода) — фреймворк показывает их, пока ресурс находится в состоянии создания/удаления.
### Как должно быть (как в autotest)
```
[OK ] 1. Валидация — 63.3 sec
[OK ] 2. Конфигурация — 0.3 sec
[OK ] 3. Внешний IP — 2.2 sec
[..] 4. Доступ — 1.3 sec ← ТЕКУЩИЙ этап (ещё идёт)
5. DNS — ещё не начат
6. Основной процесс
7. Проверки
```
---
## 2. Эталонная реализация — autotest
**Файл:** `/home/naeel/nubes/autotest/app-autotest/site/static/js/operations.js:331`
```js
function showStages(stages){
if(!stages||!stages.length) return;
let html='<div style="font-size:11px;...">Этапы</div>';
stages.forEach(s=>{
const done=!!s.dtFinish; // этап завершён?
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳'; // ⏳ = текущий
html+=`<div>${icon} ${s.stage}${(s.duration||0).toFixed(1)}s</div>`;
});
boxes[boxes.length-1].innerHTML=html;
}
```
**Ключевое правило:** `dtFinish == null` → этап СЕЙЧАС выполняется (⏳). `dtFinish != null` → завершён (✅/❌).
**Поллинг:** каждые 2 секунды через `GET /api/test/status/{opUid}``showStages(sd.stages)`.
**API-запрос к Nubes:** `GET /instanceOperations/{opUid}?fields=dtFinish,isSuccessful,errorLog,duration,stages`
**Структура stages из API** (документация: `/home/naeel/nubes/autotest/DOCS/api-operation-stages.md`):
```json
{ "stage": "1. Валидация", "isSuccessful": true, "dtFinish": "2026-...", "duration": 63.3 },
{ "stage": "2. Доступ", "isSuccessful": null, "dtFinish": null, "duration": 14.1 },
{ "stage": "3. Проверки", "isSuccessful": null, "dtFinish": null }
```
---
## 3. Что УЖЕ есть в провайдере
### Файл: `provider/internal/core/client.go`
#### a) Структуры для парсинга stages (строка ~913)
```go
type opStage struct {
InstanceOperationStageUid string `json:"instanceOperationStageUid"`
Stage string `json:"stage"`
IsSuccessful bool `json:"isSuccessful"`
DtFinish *string `json:"dtFinish"`
Duration float64 `json:"duration"`
StageMsg *string `json:"stageMsg"`
}
type operationStatusResponse struct {
InstanceOperation struct {
DtFinish *string `json:"dtFinish"`
IsSuccessful *bool `json:"isSuccessful"`
ErrorLog *string `json:"errorLog"`
IsInProgress bool `json:"isInProgress"`
IsPending bool `json:"isPending"`
Duration *float64 `json:"duration"`
Stages []opStage `json:"stages"`
} `json:"instanceOperation"`
}
```
#### b) Цикл поллинга `waitForOperationFinish()` (строка ~930)
- Поллит каждые 5 секунд
- Запрашивает `?fields=dtFinish,isSuccessful,errorLog,isInProgress,isPending,duration,stages`
- Парсит ответ в `operationStatusResponse`
#### c) ВЫВОД ЭТАПОВ — уже есть, но с тремя проблемами (строка ~960-990)
```go
// Проблема 1: загейтино за log_level
logLevel := c.LogLevel
if v, ok := ctx.Value(ctxKeyLogLevel).(string); ok && v != "" {
logLevel = v
}
showStages := logLevel == "info" || logLevel == "debug" // ← по умолчанию "none" = hidden!
// Проблема 2: показывает ТОЛЬКО завершённые, текущий ПРОПУСКАЕТ
for _, stage := range status.InstanceOperation.Stages {
if stage.DtFinish == nil || *stage.DtFinish == "" {
continue // ← ТЕКУЩИЙ ЭТАП ИГНОРИРУЕТСЯ
}
fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status2, stage.Stage, stage.Duration)
}
// Проблема 3: пишет в /dev/tty через ttyOut()
tty := ttyOut()
defer tty.Close()
```
#### d) Конфигурация log_level в провайдере: `provider/internal/provider/provider.go:150`
```go
logLevel := "none" // ← ДЕФОЛТ! Этапы СКРЫТЫ всегда, пока пользователь не выставит log_level="info"
```
---
## 4. Что нужно изучить и выдать в плане
### Вопрос 1: `/dev/tty`
- `ttyOut()` открывает `/dev/tty`. В каких окружениях это работает, а в каких — нет?
- Стоит ли заменить на `tflog.Info()` / `tflog.Debug()` (стандартный terraform-логгинг)?
- Или оставить `/dev/tty` как самый надёжный способ прямого вывода?
- Как это сделано в autotest (там вывод через DOM, не применимо к CLI-провайдеру).
### Вопрос 2: текущий этап
- Сейчас `DtFinish == nil → continue` — текущий этап не показывается.
- Нужно: для `DtFinish == nil` выводить `[..] {stage} — {duration}s` (текущий).
- При этом не плодить дубликаты — отслеживать, какой этап уже был показан.
- Как правильно обновлять одну и ту же строку в терминале (carriage return? перепечатывать?)
### Вопрос 3: log_level по умолчанию
- Сейчас `"none"` — этапы скрыты.
- Нужно ли менять дефолт на `"info"`? Плюсы: пользователь сразу видит этапы. Минусы: лишний вывод в CI.
- Альтернатива: оставить `"none"`, но сделать `"info"` более заметным в документации.
### Вопрос 4: формат вывода
- Сейчас: `[OK] 1. Валидация — 63.3 sec`
- Для текущего: `[..] 2. Основной процесс — 14.1 sec`
- Для ещё не начатых: показывать или нет? В autotest показывают все (с `⏳`).
- Этапы, которые ещё не начались (`DtFinish == nil` + `DtStart == nil`) — показывать с пометкой `[--]`?
### Вопрос 5: `Still creating...` от Terraform
- Сообщения `Still creating...` генерятся самим фреймворком Terraform.
- Можно ли их подавить/заменить? Или они останутся в любом случае?
- Если нельзя подавить — этапы пойдут ПОВЕРХ или ВМЕСТЕ с этими сообщениями.
### Вопрос 6: `StageMsg` (debug)
- В текущем коде есть `formatStageMsg()` для вывода деталей подэтапов при `log_level == "debug"`.
- Это работает? Стоит сохранить?
---
## 5. Файлы, которые нужно изучить
| Файл | Что смотреть |
|---|---|
| `provider/internal/core/client.go` | `waitForOperationFinish()`, `ttyOut()`, `opStage`, `formatStageMsg()`, `ctxKeyLogLevel` |
| `provider/internal/provider/provider.go` | конфигурация `log_level` (строка 85, 150) |
| `provider/internal/resources_core/crud.go` | вызовы `CreateResourceWithTimeout`, `UpdateResourceWithTimeout`, `DeleteResource` |
| `provider/internal/resources_gen/90_postgres_resource.go` | пример сгенерированного ресурса — вызов CRUD |
| `/home/naeel/nubes/autotest/app-autotest/site/static/js/operations.js` | `showStages()` — эталон (строка 331) |
| `/home/naeel/nubes/autotest/app-autotest/site/static/js/history.js` | `renderStages()` — эталон для истории (строка 87) |
| `/home/naeel/nubes/autotest/app-autotest/site/operations/poll.py` | `poll_until_done()` — эталон поллинга |
| `/home/naeel/nubes/autotest/DOCS/api-operation-stages.md` | структура stages из API |
---
## 6. Ожидаемый результат
**Не код, а ПЛАН.** В ответе должно быть:
1. Краткий анализ: что работает, что сломано, почему.
2. Для каждой из трёх проблем (log_level, /dev/tty, текущий этап) — конкретное решение со ссылками на строки.
3. Unified diff для каждого изменяемого файла (можно схематичный — какие блоки кода заменить на какие).
4. Ответы на все 6 вопросов из раздела 4.
5. Оценка рисков: что может пойти не так при каждом изменении.
---
## 7. Правила (обязательно)
- ⛔ Критерий завершения операции — `dtFinish`. ЭТО НЕ ТРОГАТЬ НИ ПРИ КАКИХ УСЛОВИЯХ.
- ⛔ Логика поллинга (частота, таймауты) — не менять без согласования.
- ⛔ Существующие сигнатуры функций — не менять без согласования.
- ✅ Новая логика — новые функции/блоки, не ломать существующее.
@@ -0,0 +1,55 @@
# Stages Output — Финальный план реализации
> Утверждён: 2026-08-09
> Источник: Sonnet briefing + уточнения
## Принятые решения
| Решение | Почему |
|---|---|
| Вывод через `/dev/tty` с fallback на `os.Stderr` | tflog привязан к TF_LOG, не к нашему log_level |
| Только `\n`, без `\r` | `\r` конфликтует с выводом Terraform (Still creating...) |
| Текущий этап: `[..] stage\n` один раз | Без промежуточной duration (менялась бы и запутывала) |
| Завершённый этап: `[OK ] stage — Xs\n` | C префиксом для выравнивания |
| Дефолт log_level: `"none"` | Не breaking change, opt-in через env var |
| Env var `NUBES_LOG_LEVEL` | Симметрично NUBES_INSECURE |
## Что правим
### client.go — 3 правки
1. **Вынести tty из цикла** (resource leak fix)
- `tty := ttyOut()` + `defer tty.Close()` → перед `for {`
- Убрать `tty := ttyOut()` и `defer tty.Close()` из тела цикла
2. **Добавить `lastPendingUID`** рядом с `printedStages`
3. **Заменить блок вывода этапов:**
- Завершённый (DtFinish != nil) → `[OK ] stage — Xs\n` или `[FAIL] stage — Xs\n`
- Текущий (DtFinish == nil) → `[..] stage\n` один раз при смене UID
### provider.go — 1 правка
4. **Добавить поддержку NUBES_LOG_LEVEL** env var (по аналогии с NUBES_INSECURE)
- Приоритет: config.LogLevel > NUBES_LOG_LEVEL > "none"
## Формат вывода (пример)
```
[..] 1. Валидация
[OK ] 1. Валидация — 63.3 sec
[..] 2. Основной процесс
[OK ] 2. Основной процесс — 21.3 sec
[..] 3. Проверки
[OK ] 3. Проверки — 107.7 sec
[..] 4. Настройка
[OK ] 4. Настройка — 4.6 sec
[DONE] 201.5 sec
```
## НЕ ТРОГАТЬ
- Критерий завершения (dtFinish)
- Интервал поллинга (5 сек)
- Таймауты
- Сигнатуры функций
@@ -0,0 +1,51 @@
# Баг: DEV postgres Script26 не знает resourceRealm
Дата: 2026-08-09
## Симптом
`terraform apply` на DEV стенде падает с ошибкой:
```
nubes_postgres.npg: операция ... завершилась с ошибкой:
No such property: resourceRealm for class: Script26
```
## Причина
DEV API (`lk-api-gateway-dev.ngcloud.ru`) возвращает `resourceRealm` в метаданных
сервиса postgres (ID 90, операция create, параметр startupConfiguration).
Но бэкенд-скрипт `Script26`, который фактически выполняет создание Postgres на DEV,
**не имеет** свойства `resourceRealm`.
TEST стенд работает нормально — его скрипт `resourceRealm` принимает.
## Доказательства
1. Оба API (DEV и TEST) возвращают одинаковые метаданные — `resourceRealm` присутствует:
```
generated/dev/resources_yaml/90_postgres.yaml — 2 occurrences
generated/test/resources_yaml/90_postgres.yaml — 2 occurrences
```
2. Провайдер собран **из DEV API** (не перепутан с TEST):
- YAML сгенерированы: `./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev`
- Go сгенерирован: `./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev`
- Версия: `3.0.2`, Namespace: `nubes-dev`
3. Terraform-конфиг (DEV_STAND/PGwNewRegistry):
```hcl
provider "nubes" {
api_endpoint = "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc"
}
resource "nubes_postgres" "npg" {
startup_configuration = {
resource_realm = "k8s-3-sandbox-nubes-ru"
}
}
```
## Что нужно сделать
Добавить свойство `resourceRealm` в скрипт `Script26` (DEV postgres create).
Привести DEV-скрипт в соответствие с метаданными API (как на TEST).
+35
View File
@@ -0,0 +1,35 @@
terraform {
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes"
version = "5.0.2"
}
}
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API token"
}
variable "s3_uid" {
type = string
sensitive = true
description = "Nubes S3 UID"
}
variable "realm" {
type = string
sensitive = true
description = "resource_realm parameter for nubes_postgres resource"
}
variable "s3_user_uid" {
type = string
description = "S3 user UUID"
}
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
# log_level = "debug" # none | info | debug, default = "none"
}
@@ -0,0 +1,58 @@
resource "nubes_postgres" "npg" {
resource_name = "pgdev02"
startup_configuration = {
resource_realm = var.realm
}
cluster_configuration = {
cpu = 500
memory = 512
replicas = 1
disk = 10
}
access_configuration = {
master_ip_space = "no-needed"
master_access_list = jsonencode(["10.0.0.0/8"])
slave_ip_space = "no-needed"
slave_access_list = jsonencode([])
}
postgres_configuration = {
version = "17"
ssl_required = true
pooler_master = false
pooler_slave = false
}
postgres_conf = jsonencode([{
param_name = "log_connections"
param_value = ""
}])
backup_configuration = {
s3_uid = var.s3_uid
retain = 14
schedule = "0 0 * * *"
}
autoscale_configuration = {
enabled = false
schedule = 0
percent = 10
quota = 100
}
mtls_configuration = {
type = "off"
duration_ca = 175200
duration_server = 87600
}
operation_timeout = "11m"
adopt_existing_on_create = true
}
+50
View File
@@ -0,0 +1,50 @@
# =============================================================================
# PostgreSQL — пользователи (6 шт.)
# =============================================================================
resource "nubes_postgres_user" "pg_user_0" {
postgres_id = nubes_postgres.npg.id
username = "user0"
role = "ddl_user"
mtls_access = false
adopt_existing_on_create = true
}
# resource "nubes_postgres_user" "pg_user_1" {
# postgres_id = nubes_postgres.npg.id
# username = "user1"
# role = "ddl_user"
# adopt_existing_on_create = true
# }
# # =============================================================================
# # PostgreSQL — базы данных (3 шт.)
# # =============================================================================
# resource "nubes_postgres_database" "pg_db_1" {
# postgres_id = nubes_postgres.npg.id
# db_name = "dbapp1"
# db_owner = nubes_postgres_user.pg_user_1.username
# adopt_existing_on_create = true
# }
resource "nubes_postgres_database" "pg_db_2" {
postgres_id = nubes_postgres.npg.id
db_name = "dbapp2"
db_owner = nubes_postgres_user.pg_user_0.username
adopt_existing_on_create = true
}
# resource "nubes_postgres_database" "pg_db_3" {
# postgres_id = nubes_postgres.npg.id
# db_name = "dbapp3"
# db_owner = nubes_postgres_user.pg_user_1.username
# adopt_existing_on_create = true
# }
# S3 bucket — замени "buck0" на своё имя везде ниже
resource "nubes_s3bucket" "bukka0" { # ← замени buck0 на своё имя ресурса
resource_name = "btst" # ← замени buck0 на своё имя ресурса
#s3_user_uid = "naeel-s3"
s3_user_uid = var.s3_user_uid
bucket_name = "bucktst000" # ← замени buck0 на своё имя бакета
adopt_existing_on_create = true
}
+1 -1
View File
@@ -3,7 +3,7 @@ NUBES_API_ENDPOINT="https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
TOKEN_FILE="secrets/test.token" TOKEN_FILE="secrets/test.token"
# Version # Version
VERSION="5.0.1" VERSION="5.0.2"
NAMESPACE="nubes-test" NAMESPACE="nubes-test"
PROVIDER_NAME="nubes" PROVIDER_NAME="nubes"
+22 -27
View File
@@ -934,6 +934,10 @@ func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid stri
defer ticker.Stop() defer ticker.Stop()
printedStages := make(map[string]bool) printedStages := make(map[string]bool)
var lastPendingUID string
tty := ttyOut()
defer tty.Close()
for { for {
select { select {
@@ -955,37 +959,28 @@ func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid stri
return fmt.Errorf("не удалось разобрать статус операции %s: %w", opUid, err) return fmt.Errorf("не удалось разобрать статус операции %s: %w", opUid, err)
} }
// Определяем уровень логирования: ctx перекрывает c.LogLevel // Печатаем этапы по мере выполнения — ВСЕГДА, без гейтинга.
logLevel := c.LogLevel // Завершённые (DtFinish != nil): [OK ]/[FAIL] с duration.
if v, ok := ctx.Value(ctxKeyLogLevel).(string); ok && v != "" { // Текущий (DtFinish == nil): [..] один раз при появлении.
logLevel = v
}
showStages := logLevel == "info" || logLevel == "debug"
showDetails := logLevel == "debug"
// Печатаем завершённые этапы по мере появления
tty := ttyOut()
defer tty.Close()
for _, stage := range status.InstanceOperation.Stages { for _, stage := range status.InstanceOperation.Stages {
if printedStages[stage.InstanceOperationStageUid] { if printedStages[stage.InstanceOperationStageUid] {
continue continue
} }
if stage.DtFinish == nil || *stage.DtFinish == "" { if stage.DtFinish != nil && *stage.DtFinish != "" {
continue // Завершённый этап
} printedStages[stage.InstanceOperationStageUid] = true
printedStages[stage.InstanceOperationStageUid] = true if stage.InstanceOperationStageUid == lastPendingUID {
if !showStages { lastPendingUID = ""
continue
}
status2 := "OK "
if !stage.IsSuccessful {
status2 = "FAIL"
}
fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status2, stage.Stage, stage.Duration)
if showDetails && stage.StageMsg != nil {
for _, line := range formatStageMsg(*stage.StageMsg) {
fmt.Fprintf(tty, "%s\n", line)
} }
status2 := "OK "
if !stage.IsSuccessful {
status2 = "FAIL"
}
fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status2, stage.Stage, stage.Duration)
} else if stage.InstanceOperationStageUid != lastPendingUID {
// Текущий этап — показываем один раз
lastPendingUID = stage.InstanceOperationStageUid
fmt.Fprintf(tty, " [..] %s\n", stage.Stage)
} }
} }
@@ -997,7 +992,7 @@ func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid stri
} }
return fmt.Errorf("операция %s завершилась с ошибкой", opUid) return fmt.Errorf("операция %s завершилась с ошибкой", opUid)
} }
if showStages && status.InstanceOperation.Duration != nil { if status.InstanceOperation.Duration != nil {
fmt.Fprintf(tty, " [DONE] %.1f sec\n", *status.InstanceOperation.Duration) fmt.Fprintf(tty, " [DONE] %.1f sec\n", *status.InstanceOperation.Duration)
} }
return nil return nil
+3 -3
View File
@@ -145,9 +145,9 @@ func (p *NubesProvider) Configure(ctx context.Context, req provider.ConfigureReq
}, },
} }
// Определяем уровень логирования операций: none (тихий) / info / debug. // log_level: оставлен для обратной совместимости, но этапы выводятся ВСЕГДА.
// none — дефолт, не засорять вывод terraform apply лишними строками. // Значение используется только для StageMsg (debug).
logLevel := "none" logLevel := "info"
if !config.LogLevel.IsNull() && !config.LogLevel.IsUnknown() { if !config.LogLevel.IsNull() && !config.LogLevel.IsUnknown() {
if v := strings.TrimSpace(config.LogLevel.ValueString()); v != "" { if v := strings.TrimSpace(config.LogLevel.ValueString()); v != "" {
logLevel = v logLevel = v