docs(history): раскладка HISTORY по тематическим папкам (75 файлов)
Было: 41 файл в корне HISTORY/ + авторские папки OPUS/ и SONNET/ (34 файла). Стало — тематическая нумерация в стиле NOTES/ (10_, 20_, …): 10_reviews/ ревью кода и разборы от LLM (2) 20_releases/ заливки версий в реестр, чистки реестра, нумерация версий (8) 30_provider/ ядро провайдера: архитектура, модификаторы, UUID, nested (6) 40_generator/ генератор YAML/спеки, формат MAN (3) 50_docs/ пайплайн документации, навигация, публикация, хостинг S3 (9) 60_stands/ стенды и примеры: CRUD, FullPipe, Штурвал, TEST_STAND (7) 70_infra/ реестр, API Gateway, DDoS-Guard, VPN/213, зеркала (4) 90_llm/ диалоги и промпты с LLM вне тематики: OPUS/, SONNET/, gemini/ (34) OPUS/ и SONNET/ перенесены как есть в 90_llm/ — чтобы не рвать пары «бриф → ответ» внутри диалогов. Все переносы — через git mv (история сохранена). Перед правкой: TMP/backup_2026-10-02/HISTORY_before_restructure.tar.gz. Перекрёстные ссылки обновляются следующим коммитом.
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
Вот, отправь Соннету:
|
||||
|
||||
---
|
||||
|
||||
**Контекст**: Пишем Terraform Provider для Nubes Cloud (ColdFusion API). Провайдер на Go, версия 5.0.66, опубликован в registry `terra.k8c.ru/nubes-test/nubes`.
|
||||
|
||||
**Суть проблемы**: `terraform apply` для создания S3-бакета падает с ошибкой. Прошли путь от 403 до EOF.
|
||||
|
||||
**Хронология**:
|
||||
1. `HTTP 403` — DDoS-Guard блокирует Go `crypto/tls` (JA3 fingerprint). curl с того же сервера работает.
|
||||
2. Поменяли URL с `index.cfm/instances` на `index.cfm?endpoint=/instances` — 403 остался.
|
||||
3. Добавили `utls.HelloFirefox_120` — **403 ушёл**, но стал `EOF` (сервер закрывает соединение без ответа).
|
||||
4. Убрали utls, оставили `InsecureSkipVerify: true` — `EOF`.
|
||||
5. Вернули REST-путь (`index.cfm/instances`) + `InsecureSkipVerify: true` — `EOF`.
|
||||
|
||||
**Что работает**: curl с токеном напрямую с того же сервера:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" -H "User-Agent: Mozilla/5.0" \
|
||||
"https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances&page=1&size=100"
|
||||
# → JSON, 200 OK
|
||||
```
|
||||
|
||||
**Код**: `doRequest()` в client.go (строка 831). Использует `http.DefaultTransport.Clone()` с `TLSClientConfig{InsecureSkipVerify: true, MinVersion: TLS1.2}`.
|
||||
|
||||
**Вопросы**:
|
||||
1. Почему Go HTTP клиент (с идентичными заголовками) получает EOF там, где curl получает JSON?
|
||||
2. Может ли ColdFusion-сервер требовать определённый порядок заголовков или HTTP-метод (POST вместо GET)?
|
||||
3. Как заставить Go `http.Client` отправлять точно такие же запросы как curl? Может, через `net/http/httputil` дампить запрос?
|
||||
|
||||
**Файлы**:
|
||||
- client.go
|
||||
- provider.go
|
||||
|
||||
Список задач обновлен
|
||||
|
||||
Searched for files matching `**/client.go`, 2 matches
|
||||
|
||||
Read memory [](file:///home/naeel/.vscode-server/data/User/workspaceStorage/358cf615179442f06eab7245b983b753/GitHub.copilot-chat/memory-tool/memories/repo/tf_provider_inventory.md)
|
||||
|
||||
Searched for text `doRequest|DoRequest` (`**/universal_rebuild/internal/core/client.go`), 20 results
|
||||
|
||||
Searched for text `doRequest|DoRequest` (`**/internal/core/client.go`), 20 results
|
||||
|
||||
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/core/client.go#800-800), lines 800 to 900
|
||||
|
||||
Read [](file:///home/naeel/tf_provider/internal/core/client.go#177-177), lines 177 to 260
|
||||
|
||||
Searched for text `HttpClient|NewClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport` (`**/universal_rebuild/internal/core/client.go`), 5 results
|
||||
|
||||
Searched for text `HttpClient|NewClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport` (`**/internal/core/client.go`), 7 results
|
||||
|
||||
Searched for text `HttpClient|NewUniversalClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport|http.Client{` (`**/universal_rebuild/internal/provider/**`), 11 results
|
||||
|
||||
Searched for text `HttpClient|NewUniversalClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport|http.Client{` (`**/internal/provider/**`), 20 results
|
||||
|
||||
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/provider/provider.go#105-105), lines 105 to 165
|
||||
|
||||
Теперь посмотрю на S3-ресурс и полный URL, чтобы понять что именно запрашивается:
|
||||
|
||||
Searched for text `s3|bucket|S3|Bucket` (`**/universal_rebuild/internal/core/client.go`), 8 results
|
||||
|
||||
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/core/client.go#1-1), lines 1 to 50
|
||||
|
||||
Теперь у меня достаточно контекста. Сформирую план.
|
||||
|
||||
Created memory file [](file:///home/naeel/.vscode-server/data/User/workspaceStorage/358cf615179442f06eab7245b983b753/GitHub.copilot-chat/memory-tool/memories/MjE3NzcyMzEtOGRkYy00ODA4LWI0OGItOGEyZDliNmU5YjFl/plan.md)
|
||||
|
||||
## Анализ и план
|
||||
|
||||
### Корневая причина EOF
|
||||
|
||||
**Главный виновник — HTTP/2.**
|
||||
|
||||
`http.DefaultTransport.Clone()` копирует ссылку на `TLSNextProto` map, в которой зарегистрирован HTTP/2 (`h2`) handler. При TLS-хендшейке Go предлагает `h2` в ALPN-расширении. ColdFusion + DDoS-Guard не поддерживают HTTP/2 — сервер принимает TLS-соединение, но сразу закрывает его без HTTP-ответа → **EOF**.
|
||||
|
||||
Именно поэтому curl работает: он по умолчанию использует HTTP/1.1 и не предлагает `h2` в ALPN.
|
||||
|
||||
`InsecureSkipVerify` здесь роли не играет — он влияет только на проверку сертификата, не на TLS-хендшейк.
|
||||
|
||||
---
|
||||
|
||||
### Ответы на вопросы
|
||||
|
||||
**1. Почему Go получает EOF, а curl — нет?**
|
||||
Go через ALPN предлагает `["h2", "http/1.1"]`. Сервер видит `h2` и закрывает соединение (ColdFusion не понимает HTTP/2). curl предлагает только `["http/1.1"]` — сервер отвечает нормально.
|
||||
|
||||
**2. Может ли ColdFusion требовать особый порядок заголовков?**
|
||||
Маловероятно причина. Порядок заголовков важен для WAAP/DDoS-Guard fingerprinting, но если 403 ушёл, то DDoS-Guard вас пропускает, а EOF — это именно HTTP/2 на уровне приложения.
|
||||
|
||||
**3. Как заставить Go отправлять то же, что curl?**
|
||||
Двумя действиями ниже.
|
||||
|
||||
---
|
||||
|
||||
### Plan: Fix EOF (2 изменения)
|
||||
|
||||
**Файлы к изменению:**
|
||||
- provider.go — строка ~124
|
||||
- client.go — строка ~836 (`doRequest`)
|
||||
|
||||
**Шаг 1 — Отключить HTTP/2** (приоритет #1, наиболее вероятный фикс)
|
||||
|
||||
В provider.go после `transport := http.DefaultTransport.(*http.Transport).Clone()` добавить две строки:
|
||||
|
||||
```go
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSHandshakeTimeout = 60 * time.Second
|
||||
// Отключаем HTTP/2: ColdFusion + DDoS-Guard не поддерживают h2 → EOF
|
||||
transport.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
|
||||
transport.ForceAttemptHTTP2 = false // запрещаем upgrade
|
||||
```
|
||||
|
||||
> `ForceAttemptHTTP2 = false` одного недостаточно — нужен именно пустой `TLSNextProto`, иначе `h2` всё равно остаётся в ALPN.
|
||||
|
||||
**Шаг 2 — Убрать Content-Type на GET** (приоритет #2, возможная причина WAF-блокировки)
|
||||
|
||||
В `doRequest()` в client.go заголовок `Content-Type` выставлять только если payload != nil:
|
||||
|
||||
```go
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
```
|
||||
|
||||
**Шаг 3 — Debug dump** (если 1+2 не помогают)
|
||||
|
||||
Добавить через env-флаг `NUBES_DEBUG_HTTP=1`:
|
||||
|
||||
```go
|
||||
if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
|
||||
dump, _ := httputil.DumpRequestOut(req, true)
|
||||
fmt.Fprintf(os.Stderr, ">>> REQUEST:\n%s\n", dump)
|
||||
}
|
||||
```
|
||||
|
||||
Потом сравнить вывод с `curl -v --http1.1 -H "Authorization: Bearer ..." ...`.
|
||||
|
||||
---
|
||||
|
||||
**Верификация:**
|
||||
1. `terraform apply` для S3-бакета — нет EOF
|
||||
2. `curl -v --http2 <url>` — проверить что с `--http2` curl тоже получает EOF (подтвердит гипотезу)
|
||||
3. `terraform plan` после успешного apply
|
||||
|
||||
---
|
||||
|
||||
Ждать «делай» для реализации?
|
||||
|
||||
Создано задач: 3
|
||||
|
||||
|
||||
Вопрос Соннету:
|
||||
|
||||
---
|
||||
|
||||
**Контекст**: Terraform Provider на Go. API: ColdFusion за DDoS-Guard.
|
||||
|
||||
**Что сделали**:
|
||||
- `TLSNextProto = make(map[string]...)` — GET работает (200 JSON)
|
||||
- `?endpoint=` формат URL — как у генератора
|
||||
- `User-Agent: Mozilla/5.0`
|
||||
|
||||
**Проблема**: GET → 200. POST → 403. curl (GET и POST) → оба 200.
|
||||
|
||||
**Код POST**:
|
||||
```go
|
||||
func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
|
||||
// payload — json-сериализованная структура
|
||||
req, _ := http.NewRequestWithContext(ctx, method, c.ApiEndpoint, body)
|
||||
// ?endpoint=/instances
|
||||
q := req.URL.Query()
|
||||
q.Set("endpoint", path)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
```
|
||||
|
||||
**Работающий curl**:
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "User-Agent: Mozilla/5.0" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"serviceId":13}' \
|
||||
"https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances"
|
||||
```
|
||||
|
||||
**Вопрос**: Что в Go `http.Client` (c `TLSNextProto = make(...)`) может вызывать 403 на POST, при том что GET работает, и curl POST тоже работает? Может ли `http.NewRequestWithContext` добавлять заголовки/байты, которые триггерят DDoS-Guard на POST но не на GET?
|
||||
|
||||
Список задач обновлен
|
||||
|
||||
Прочитаю актуальный код `doRequest` и инициализацию transport, чтобы ответить точно, а не по памяти:
|
||||
|
||||
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/core/client.go#804-804), lines 804 to 880
|
||||
|
||||
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/provider/provider.go#122-122), lines 122 to 155
|
||||
|
||||
## Диагноз: 3 кандидата, #1 самый вероятный
|
||||
|
||||
Код виден полностью. Вот что реально отличает Go POST от curl POST.
|
||||
|
||||
---
|
||||
|
||||
### #1 (главный подозреваемый): `url.Values.Encode()` кодирует `/` как `%2F`
|
||||
|
||||
```go
|
||||
q.Set("endpoint", endpointPath) // endpointPath = "/instances"
|
||||
req.URL.RawQuery = q.Encode() // → endpoint=%2Finstances
|
||||
```
|
||||
|
||||
curl отправляет: `?endpoint=/instances`
|
||||
Go отправляет: `?endpoint=%2Finstances`
|
||||
|
||||
**Почему GET проходит, а POST нет?** DDoS-Guard WAF для POST-запросов почти всегда имеет **allowlist эндпоинтов** — проверяет, что `endpoint` соответствует разрешённым путям. `/instances` совпадает, `%2Finstances` — нет → 403. GET-запросы проверяются слабее (read-only, меньший риск).
|
||||
|
||||
---
|
||||
|
||||
### #2: Нет заголовка `Accept`
|
||||
|
||||
curl всегда добавляет `Accept: */*` автоматически.
|
||||
Ваш Go-код его не ставит.
|
||||
|
||||
DDoS-Guard fingerprinting видит: POST с телом + нет `Accept` → нет `Accept` = нет браузера = потенциальный бот.
|
||||
|
||||
---
|
||||
|
||||
### #3: `Accept-Encoding: gzip` добавляет transport
|
||||
|
||||
Т.к. `DisableCompression` не выставлен, transport добавляет `Accept-Encoding: gzip` ко всем запросам. Для GET это тоже есть → GET проходит → вероятно, не причина. Но на всякий случай стоит проверить.
|
||||
|
||||
---
|
||||
|
||||
### Как проверить немедленно
|
||||
|
||||
`DumpRequestOut` уже в коде:
|
||||
```bash
|
||||
NUBES_DEBUG_HTTP=1 terraform apply 2>&1 | grep -A 30 ">>> REQ POST"
|
||||
```
|
||||
И сравнить с:
|
||||
```bash
|
||||
curl -v -X POST -H "User-Agent: Mozilla/5.0" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"serviceId":13}' \
|
||||
"https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances" 2>&1 | grep -E "^>|^<"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Что менять
|
||||
|
||||
**Fix #1 — не использовать `q.Encode()` для endpoint:**
|
||||
|
||||
```go
|
||||
// Вместо q.Set("endpoint",...) + q.Encode()
|
||||
rawQuery := "endpoint=" + endpointPath // слэш не кодируется
|
||||
if extraQuery != "" {
|
||||
rawQuery += "&" + extraQuery
|
||||
}
|
||||
req.URL.RawQuery = rawQuery
|
||||
```
|
||||
|
||||
**Fix #2 — добавить `Accept: */*`** (как curl):
|
||||
|
||||
```go
|
||||
req.Header.Set("Accept", "*/*")
|
||||
```
|
||||
|
||||
**Fix #3 (опционально) — отключить автоматическое gzip в transport:**
|
||||
|
||||
```go
|
||||
transport.DisableCompression = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Итог
|
||||
|
||||
| Причина | Почему GET ок | Почему POST 403 |
|
||||
|---|---|---|
|
||||
| `%2Finstances` в URL | WAF для GET мягче | WAF для POST проверяет allowlist путей |
|
||||
| Нет `Accept` | То же | Browser fingerprint не проходит |
|
||||
| `Connection: close` | - | (здесь `req.Close = true` нет, уже убрано) |
|
||||
|
||||
Начинать с Fix #1 — вероятность ~70% что это именно оно.
|
||||
|
||||
Создано задач: 4
|
||||
|
||||
|
||||
Вопрос Соннету:
|
||||
|
||||
---
|
||||
|
||||
**Контекст**: Terraform Provider Go. API за DDoS-Guard. GET работает, POST — 403.
|
||||
|
||||
**Проверено**:
|
||||
- `TLSNextProto = make(...)` — ✅ GET 200
|
||||
- `?endpoint=/instances` (не `%2F`) — ✅
|
||||
- `User-Agent: Mozilla/5.0` — ✅
|
||||
- `Accept: */*` — ✅
|
||||
- curl POST (те же URL, тело, заголовки) — ✅ 200/400
|
||||
- Go-тест POST (отдельная программа, тот же TLS-конфиг) — ✅ 201
|
||||
|
||||
**Провайдер**: `doRequest()` → `json.Marshal(payload)` → `bytes.NewBuffer(b)` → `http.NewRequestWithContext(ctx, "POST", url, body)` → `c.HttpClient.Do(req)` → **403**.
|
||||
|
||||
**Код**:
|
||||
```go
|
||||
// provider.go
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSNextProto = make(map[string]func(...))
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}
|
||||
client := &core.UniversalClient{
|
||||
HttpClient: &http.Client{Transport: transport, Timeout: 300 * time.Second},
|
||||
...
|
||||
}
|
||||
|
||||
// client.go doRequest()
|
||||
b, _ := json.Marshal(payload)
|
||||
body := bytes.NewBuffer(b)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", c.ApiEndpoint, body)
|
||||
req.URL.RawQuery = "endpoint=/instances"
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
```
|
||||
|
||||
**Вопрос**: Что в Go `http.Client` с кастомным `Transport` (пустой `TLSNextProto` + `TLSClientConfig`) может вызывать 403 только на POST, при том что:
|
||||
1. Отдельная Go-программа с идентичным TLS-конфигом делает POST успешно (201)
|
||||
2. curl делает POST успешно
|
||||
3. GET-запросы через тот же `doRequest` работают
|
||||
|
||||
Есть ли способ дампнуть сырые байты TLS-хендшейка или HTTP-запроса из провайдера (не из тестовой программы), чтобы сравнить с curl?
|
||||
|
||||
Список задач обновлен
|
||||
|
||||
Ключевой факт: **standalone Go-программа с тем же кодом → 201, провайдер → 403**. Значит причина не в TLS-конфиге и не в заголовках — она в окружении или состоянии самого провайдера.
|
||||
|
||||
---
|
||||
|
||||
## Четыре кандидата
|
||||
|
||||
### #1 (самый вероятный): `HTTPS_PROXY` в окружении Terraform
|
||||
|
||||
`http.DefaultTransport.Clone()` копирует `Proxy: http.ProxyFromEnvironment`. Если при `terraform apply` выставлена переменная `HTTPS_PROXY`, трафик пойдёт через прокси. Прокси может GET пропускать (кэш), а POST — блокировать или трансформировать.
|
||||
|
||||
Standalone-тест работает? Проверьте, запускался ли он в том же shell с теми же env:
|
||||
|
||||
```bash
|
||||
env | grep -i proxy
|
||||
```
|
||||
|
||||
Фикс — убрать прокси из transport:
|
||||
|
||||
```go
|
||||
transport.Proxy = nil // добавить в provider.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### #2: `DefaultTransport` уже модифицирован до `Clone()`
|
||||
|
||||
Terraform Plugin Framework инициализирует gRPC-транспорт и **может изменить глобальный `http.DefaultTransport`** до того, как ваш `Configure` его клонирует. Standalone-тест стартует с чистым DefaultTransport.
|
||||
|
||||
Фикс — не клонировать DefaultTransport, создать с нуля:
|
||||
|
||||
```go
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 60 * time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper),
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecureSkipVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### #3: Повторное использование TCP-соединения (keep-alive)
|
||||
|
||||
Провайдер: клиент создаётся один раз → GET занимает соединение → POST переиспользует то же соединение из пула. DDoS-Guard или ColdFusion может иметь правило: POST на keep-alive соединении после GET = подозрительно.
|
||||
|
||||
Standalone-тест: свежий клиент → каждый запрос на новом соединении.
|
||||
|
||||
Быстрая проверка — добавить `req.Close = true` (форсирует новое соединение на каждый запрос):
|
||||
|
||||
```go
|
||||
req.Close = true
|
||||
```
|
||||
|
||||
Если после этого POST пройдёт — причина именно в connection reuse.
|
||||
|
||||
---
|
||||
|
||||
### #4: Параллельность в Terraform
|
||||
|
||||
Terraform может запускать несколько `Create` параллельно. Rapid-fire POST'ы с одного IP → DDoS-Guard rate limit → 403. Standalone-тест делает один POST.
|
||||
|
||||
---
|
||||
|
||||
## Как дампить реальные байты из провайдера
|
||||
|
||||
### Уровень HTTP (уже есть, но улучшить)
|
||||
|
||||
Добавить дамп и ответа:
|
||||
|
||||
```go
|
||||
if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
|
||||
dump, _ := httputil.DumpRequestOut(req, true)
|
||||
fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, req.URL.String(), dump)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
|
||||
respDump, _ := httputil.DumpResponse(resp, false) // false = без тела
|
||||
fmt.Fprintf(os.Stderr, "\n<<< RESP %d\n%s\n", resp.StatusCode, respDump)
|
||||
return resp, err
|
||||
}
|
||||
```
|
||||
|
||||
### Уровень TLS (сырые байты + расшифровка через Wireshark)
|
||||
|
||||
```go
|
||||
// В provider.go при создании transport:
|
||||
if keylogPath := os.Getenv("SSLKEYLOGFILE"); keylogPath != "" {
|
||||
f, _ := os.OpenFile(keylogPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
|
||||
transport.TLSClientConfig.KeyLogWriter = f
|
||||
}
|
||||
```
|
||||
|
||||
Запуск:
|
||||
```bash
|
||||
# Терминал 1: захват трафика
|
||||
sudo tcpdump -i any -w /tmp/cap.pcap 'host deck-api-test.ngcloud.ru'
|
||||
|
||||
# Терминал 2: terraform apply
|
||||
SSLKEYLOGFILE=/tmp/sslkeys.log terraform apply
|
||||
|
||||
# После: открыть cap.pcap в Wireshark
|
||||
# Edit → Preferences → TLS → (Pre)-Master-Secret log: /tmp/sslkeys.log
|
||||
```
|
||||
|
||||
### Уровень TCP (кастомный DialContext — логировать сырые байты)
|
||||
|
||||
```go
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
c, err := (&net.Dialer{Timeout: 30 * time.Second}).DialContext(ctx, network, addr)
|
||||
if err != nil || os.Getenv("NUBES_DEBUG_HTTP") != "1" {
|
||||
return c, err
|
||||
}
|
||||
return &loggingConn{Conn: c, tag: addr}, nil
|
||||
}
|
||||
|
||||
type loggingConn struct {
|
||||
net.Conn
|
||||
tag string
|
||||
}
|
||||
func (lc *loggingConn) Write(b []byte) (int, error) {
|
||||
fmt.Fprintf(os.Stderr, "TCP→%s WRITE %d bytes: %q\n", lc.tag, len(b), b)
|
||||
return lc.Conn.Write(b)
|
||||
}
|
||||
func (lc *loggingConn) Read(b []byte) (int, error) {
|
||||
n, err := lc.Conn.Read(b)
|
||||
fmt.Fprintf(os.Stderr, "TCP←%s READ %d bytes: %q\n", lc.tag, n, b[:n])
|
||||
return n, err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что делать прямо сейчас
|
||||
|
||||
```bash
|
||||
# Шаг 1: проверить прокси
|
||||
env | grep -iE "proxy|https_proxy|http_proxy"
|
||||
|
||||
# Шаг 2: если прокси нет, тест без connection reuse
|
||||
# добавить req.Close = true в doRequest, пересобрать, apply
|
||||
|
||||
# Шаг 3: если не помогло — дамп
|
||||
NUBES_DEBUG_HTTP=1 terraform apply 2>&1 | grep -A 40 ">>> REQ POST"
|
||||
# сравнить с:
|
||||
curl -v -X POST ... 2>&1 | grep -E "^>|^<"
|
||||
```
|
||||
|
||||
Разница в одну строку в дампе покажет причину.
|
||||
@@ -0,0 +1,66 @@
|
||||
# 2026-07-08/09 — FindInstanceByDisplayName: анализ и дебаг
|
||||
|
||||
## Проблема
|
||||
|
||||
`FindInstanceByDisplayName("NaeelOrg", 19)` возвращает nil → adopt не работает → план показывает `+ create`.
|
||||
|
||||
## Три кандидата (Opus, 07-08)
|
||||
|
||||
### 🔴 #1: silent error swallow в GetInstanceStateRaw
|
||||
`client.go:~605` — `GetInstanceStateRaw` может возвращать ошибку, которая проглатывается `continue`. Ни search-путь, ни fallback не логируют ошибку.
|
||||
|
||||
### 🟡 #2: fields с URL-encoded запятыми
|
||||
`url.Values.Encode()` кодирует `,` → `%2C`. Если API не понимает `%2C`, search возвращает пустой массив → fallback → провал.
|
||||
|
||||
### 🟡 #3: API возвращает другое имя поля
|
||||
Если API возвращает `display_name` вместо `displayName` — `item.DisplayName` = "", `EqualFold("", "NaeelOrg")` = false.
|
||||
|
||||
## Дебаг (07-09)
|
||||
|
||||
### Ошибка #1: `req.Close = true` — не причина
|
||||
Правка `req.Close` только для GET не помогла (v5.0.65). План всё ещё `+ create`.
|
||||
|
||||
### Ошибка #2: три сборки под одной версией 5.0.66
|
||||
VM кеширует провайдер. `terraform init -upgrade` не перекачивает ту же версию. Исправлено: новый билд → новая версия.
|
||||
|
||||
### Ошибка #3: stderr не попадает в `terraform plan 2>&1`
|
||||
Terraform запускает плагин как подпроцесс → stderr не ловится. Исправлено: запись в `/tmp/nubes_find_debug.log`.
|
||||
|
||||
### Результат дебага (v5.0.67)
|
||||
|
||||
Лог `/tmp/nubes_find_debug.log`:
|
||||
```
|
||||
[FIND-DEBUG] ModifyPlan entered, client=true
|
||||
[FIND-DEBUG] PlanExistingResourceDiagnostics entered: serviceId=19 name="NaeelOrg" adopt=true client=true
|
||||
[FIND-DEBUG] search returned 1 results
|
||||
[FIND-DEBUG] search item uid=3f0850f2-3506-4efd-b84b-7270b5027ab5 name="NaeelOrg" svcId=19 matchSvc=true matchName=true
|
||||
```
|
||||
|
||||
**FindInstanceByDisplayName РАБОТАЕТ** — инстанс находится, GetInstanceStateRaw проходит.
|
||||
|
||||
### Почему план `+ create` — это НОРМАЛЬНО
|
||||
|
||||
Adopt происходит на **apply**, не на plan. Plan показывает `+ create` потому что ресурса нет в `terraform.tfstate`.
|
||||
|
||||
На apply: `CreateResource()` → `FindInstanceByDisplayName` → найдёт → `adoptExistingInstanceOnCreate` → подхватит в state.
|
||||
|
||||
## Дальше (завтра)
|
||||
|
||||
1. `terraform apply` — adopt должен сработать
|
||||
2. Убрать debug-логи, собрать чистую версию
|
||||
3. Добавить vdc, nsxt, Штурвал
|
||||
|
||||
## Уроки
|
||||
|
||||
1. **Всегда новая версия на каждую сборку** — иначе VM не подхватывает.
|
||||
2. **Писать debug в файл, не в stderr** — в плагинах Terraform stderr не виден.
|
||||
3. **Не гадать** — сначала дебаг-лог, потом правка.
|
||||
4. **Выполнять ТОЛЬКО по «делай»** — без разрешения ничего не трогать.
|
||||
|
||||
## 2026-07-09: фикс resolveRefSvcParamValues — пустые опциональные ref-параметры
|
||||
|
||||
### Баг
|
||||
`resolveRefSvcParamValues()` в `client.go:1169` не проверяет пустые значения. Когда юзер не задаёт опциональный ref-параметр (например `vdcGroupUid`), `FormatString` возвращает `""`, функция пытается резолвить `""` как имя → `findInstanceUidByDisplayNameRefSvc(ctx, 29, "")` → uid="" → ошибка "не найден экземпляр".
|
||||
|
||||
### Фикс (v5.0.68)
|
||||
Добавлена проверка `strings.TrimSpace(value) == ""` перед вызовом `findInstanceUidByDisplayNameRefSvc`. Пустые опциональные ref-параметры пропускаются без ошибки. Универсально для всех сервисов.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Ответ Соннета: Анализ UX документации Nubes Terraform Provider
|
||||
|
||||
**Дата:** 2026-08-10
|
||||
**Вопрос:** см. prompt_for_sonnet_docs_ux.md
|
||||
**Ответчик:** Claude Sonnet
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Документация функционально корректна, но навигация сломана для нового пользователя — он не может найти нужный ресурс без знания URL. Три приоритета: восстановить sidebar, сделать главную страницу рабочей, поднять размер шрифта MAN.
|
||||
|
||||
---
|
||||
|
||||
## 1. Общая оценка
|
||||
|
||||
**Хорошо:**
|
||||
- YAML → Markdown пайплайн — надёжная основа, параметры актуальны
|
||||
- Cloud snapshot для `state_out_flat` / `vault_secrets` — уникальная ценность, ни у кого нет
|
||||
- Dual example (minimal + full) — правильный выбор
|
||||
|
||||
**Плохо:**
|
||||
- Боковые панели скрыты → юзер попадает на страницу и не знает как вернуться к другим ресурсам
|
||||
- `font-size: 0.62rem` для MAN — нечитаемо, создаёт впечатление "broken UI"
|
||||
- Версия в URL, но не в UI → юзер не уверен смотрит ли он актуальное
|
||||
- Индексная страница — голая таблица из 43 строк без группировки и фильтрации
|
||||
|
||||
---
|
||||
|
||||
## 2. Рекомендации по блокам
|
||||
|
||||
### A. Навигация
|
||||
|
||||
**A1 — Навигация между ресурсами:**
|
||||
Лучший вариант — вернуть левый sidebar с категориями. 43 ресурса легко разбиваются на группы:
|
||||
- **Базы данных**: postgres, mysql, redis, mongodb, ...
|
||||
- **Очереди**: kafka, rabbitmq, activemq, ...
|
||||
- **Хранилище**: s3, swift, ...
|
||||
- **K8s**: kubernetes, helm, ...
|
||||
- **VMware**: vdc, vm, ...
|
||||
- **Приложения**: lucee, nodejs, flask, ...
|
||||
- **Сеть/прочее**: остальное
|
||||
|
||||
Sidebar с категориями даёт ориентацию за 3 секунды. Поиск mkdocs (`search`) — бесплатный бонус.
|
||||
|
||||
**A2 — Вернуть sidebar:**
|
||||
Да. Убрать из `extra.css` строки:
|
||||
```css
|
||||
.md-sidebar--primary { display: none !important; }
|
||||
```
|
||||
Правый sidebar (TOC) убрать только для страниц ресурсов — там он бесполезен. Реализуется через meta-tag `hide: [toc]` в frontmatter генерируемых файлов.
|
||||
|
||||
**A3 — Быстрый поиск:**
|
||||
- Включить встроенный поиск mkdocs-material (`search` plugin)
|
||||
- В `IndexMD()` добавить категории как `## Базы данных`, `## Очереди` — тогда sidebar mkdocs покажет дерево
|
||||
|
||||
---
|
||||
|
||||
### B. Дизайн страницы ресурса
|
||||
|
||||
**B1 — MAN font-size:**
|
||||
Поднять с `0.62rem` до `0.78rem` — достаточно компактно, но читаемо. Заодно обернуть MAN в `<details>` с заголовком "Справка (MAN)" — DevOps обычно не читает MAN, ему нужны параметры.
|
||||
|
||||
**B2 — Структура страницы:**
|
||||
Текущий порядок (MAN в начале) — неоптимален. Предлагаю:
|
||||
```
|
||||
1. Заголовок + Inline nav
|
||||
2. Краткое описание (1-2 строки из ServiceDisplayName + первый абзац MAN)
|
||||
3. Minimal example (СРАЗУ — копируй и пробуй)
|
||||
4. Create params (таблица)
|
||||
5. Outputs (state_out_flat + vault_secrets)
|
||||
6. MAN (в <details> collapsed)
|
||||
```
|
||||
DevOps хочет пример → понял структуру → посмотрел параметры. MAN читает если застрял.
|
||||
|
||||
Это изменение в `buildManualPage()` — перенос `buildExamplePage()` фрагмента вверх. Либо создать новый `buildCombinedLandingPage()`.
|
||||
|
||||
**B3 — Версия в UI:**
|
||||
Добавить в `buildHeader()`:
|
||||
```
|
||||
# Resource nubes_postgres · v5.0.5 · Service ID: 90 · PostgreSQL
|
||||
```
|
||||
`version` уже передаётся в `ResourceDocs()` — просто прокинуть в `buildHeader()`.
|
||||
|
||||
---
|
||||
|
||||
### C. Таблицы параметров
|
||||
|
||||
**C1 — Колонки таблиц:**
|
||||
Текущие колонки: `Code | Type | Description | Constraints`. Добавить `Required` и `Default`:
|
||||
```
|
||||
| Параметр | Тип | Обязательный | По умолчанию | Описание | Ограничения |
|
||||
```
|
||||
`Required` и `Default` уже есть в данных (`SplitParams()` их разделяет), просто не выводятся в единой таблице. Убрать разделение на две таблицы — одна таблица с колонкой Required проще для чтения.
|
||||
|
||||
**C2 — Вложенные параметры (map-fixed):**
|
||||
Текущий вариант (`### clusterConfiguration` → отдельная таблица) — приемлем. Улучшить: добавить ссылку-якорь в основной таблице:
|
||||
```
|
||||
| clusterConfiguration | map-fixed | [Развернуть ↓](#clusterconfiguration) | ... |
|
||||
```
|
||||
Так юзер понимает что кликнуть. Реализуется в `renderParamTable()` + `renderNestedParams()`.
|
||||
|
||||
---
|
||||
|
||||
### D. Примеры
|
||||
|
||||
**D1 — Страница Example:**
|
||||
- Поменять местами: Minimal example → Full example (не в `<details>`)
|
||||
Сейчас Full в раскрывашке — правильно. Но заголовок `Minimal example — only required parameters` на английском среди русского контента — резает глаз. Перевести.
|
||||
- Добавить комментарии в код: `# Выберите из: 1, 3, 5` для параметров с value_list — LLM уже обогащает, но это должно быть в HCL-примере тоже.
|
||||
- Outputs usage: сейчас шаблонные строки с `baza`. Показать реальные ключи из cloud snapshot если есть:
|
||||
```hcl
|
||||
# PostgreSQL connection string:
|
||||
# nubes_postgres.baza.state_out_flat["internalConnect.master"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### E. Общие рекомендации
|
||||
|
||||
**E1 — Чего не хватает:**
|
||||
1. **Lifecycle warning** — блок про `suspend_on_destroy` сейчас внизу create params мелким шрифтом. Это КРИТИЧНАЯ информация (пользователь может случайно "удалить" БД). Поднять выше, оформить как `!!! danger` admonition.
|
||||
2. **Связанные ресурсы** — PostgreSQL → пример связки с Lucee/NodeJS уже есть в `buildOutputsPage()`, но только для service_id=90. Обобщить через теги в YAML.
|
||||
3. **Changelog** — нужен, но это отдельная задача (нужно хранить diff между версиями YAML).
|
||||
|
||||
**E2 — Приоритеты:**
|
||||
Quick wins (высокий эффект, минимум кода):
|
||||
1. Убрать `display:none` с primary sidebar + включить search
|
||||
2. MAN font-size 0.62rem → 0.78rem
|
||||
3. Версия в buildHeader()
|
||||
4. Добавить категории в IndexMD() + _nav_fragment.yml
|
||||
|
||||
---
|
||||
|
||||
## 3. Приоритизированный план действий
|
||||
|
||||
**Phase 1 — Quick wins (1-2 дня, 1 разработчик)**
|
||||
1. `extra.css`: убрать `display:none` для primary sidebar
|
||||
2. `extra.css`: MAN font-size `0.62rem` → `0.78rem`
|
||||
3. writers.go: прокинуть `version` в `buildHeader()`
|
||||
4. `IndexMD()`: добавить категорийные заголовки (требует маппинга service → category)
|
||||
5. `WriteNavFragment()`: группировка по категориям в `_nav_fragment.yml`
|
||||
|
||||
**Phase 2 — Структура страницы (3-5 дней)**
|
||||
6. Новый порядок секций: пример → параметры → MAN в `<details>`
|
||||
7. Объединить required/defaults в одну таблицу с колонкой Required
|
||||
8. Lifecycle warning → admonition `danger`
|
||||
|
||||
**Phase 3 — Таблицы и примеры (1 неделя)**
|
||||
9. Якори для вложенных параметров
|
||||
10. Реальные ключи из cloud snapshot в HCL-примерах
|
||||
11. Перевести заголовки примеров на русский
|
||||
|
||||
**Долгосрок (отдельный эпик)**
|
||||
12. Changelog между версиями
|
||||
13. Теги для связанных ресурсов
|
||||
|
||||
---
|
||||
|
||||
## 4. Конкретные предложения по коду
|
||||
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| extra.css | Убрать строки `display: none !important` для обоих sidebar; MAN `.man-content` `font-size: 0.78rem` |
|
||||
| `writers/writers.go`: `buildHeader()` | Добавить `version` параметр, вывести в заголовок |
|
||||
| `writers/writers.go`: `IndexMD()` | Принять маппинг `categoryMap map[int]string`, выводить заголовки `## Category` |
|
||||
| `writers/writers.go`: `buildCreateParamsPage()` | Единая таблица Required + Default с колонкой Required (bool); lifecycle → admonition |
|
||||
| `writers/writers.go`: `buildManualPage()` | MAN обернуть в `<details><summary>Справка (MAN)</summary>` |
|
||||
| mkdocs.yml | Включить `search` plugin |
|
||||
|
||||
---
|
||||
|
||||
## 5. Антипаттерны — что НЕ делать
|
||||
|
||||
1. **Не делать многоуровневую sidebar по операциям** — `Manual / Create params / Modify params` в sidebar превратит дерево в 43×6=258 пунктов. Только верхний уровень в sidebar, внутри — inline nav.
|
||||
2. **Не трогать LLM prompt ради структуры** — структура страниц это Go-генератор, не LLM. LLM только обогащает тексты.
|
||||
3. **Не делать HTML-таблицы** — Markdown-таблицы уже работают; HTML нужен только для сложных случаев (`lifecycle-note` div — допустимо).
|
||||
4. **Не скрывать пример в `<details>`** — сейчас Full example скрыт, это правильно. Minimal должен быть ОТКРЫТ и первым.
|
||||
5. **Не добавлять JS-фильтрацию на index** — mkdocs search уже умеет фильтровать; второй поиск создаёт путаницу.
|
||||
|
||||
---
|
||||
|
||||
**Ключевой вывод:** Самое больное место — отсутствие sidebar и нечитаемый MAN. Эти два изменения (по 3 строки CSS) дадут 80% UX-эффекта. Остальное — итеративно.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Ответ Соннета: Как смержить nav (статический + динамический)
|
||||
|
||||
**Дата:** 2026-08-10
|
||||
**Вопрос:** Как смержить статический `nav:` (руководства) и динамический (43 ресурса по категориям) в mkdocs?
|
||||
**Ответчик:** Claude Sonnet
|
||||
|
||||
---
|
||||
|
||||
## Нет встроенного `!include` в mkdocs
|
||||
|
||||
mkdocs-material не имеет нативного механизма подключения внешних YAML-фрагментов в `nav:`. Плагины (monorepo, awesome-pages) решают другие задачи и здесь не помогут.
|
||||
|
||||
---
|
||||
|
||||
## Три варианта (по убыванию рекомендованности)
|
||||
|
||||
### Вариант 1 — Рекомендую: расширить существующий Python pre-build шаг
|
||||
|
||||
В `04_build_and_publish_docs.sh` уже есть Python-блок, который пишет `.mkdocs.tmp.yml`. Добавить туда чтение `_nav_fragment.yml` и инъекцию в `nav:`:
|
||||
|
||||
```python
|
||||
import yaml
|
||||
|
||||
nav_fragment_path = Path(docs_dir) / "_nav_fragment.yml" if docs_dir else None
|
||||
if nav_fragment_path and nav_fragment_path.exists():
|
||||
fragment = yaml.safe_load(nav_fragment_path.read_text(encoding="utf-8"))
|
||||
resources_nav = fragment.get("resources_nav", [])
|
||||
|
||||
config = yaml.safe_load(text)
|
||||
for item in config.get("nav", []):
|
||||
if isinstance(item, dict) and "Ресурсы" in item:
|
||||
item["Ресурсы"] = resources_nav
|
||||
break
|
||||
text = yaml.dump(config, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||
```
|
||||
|
||||
**Плюсы:** ноль новых зависимостей, PyYAML уже в окружении, merge в одном месте, статические секции ("Руководства") остаются нетронутыми.
|
||||
|
||||
**Предупреждение:** PyYAML при `dump` меняет форматирование (кавычки, отступы) — это нормально для `.mkdocs.tmp.yml`, который никто не читает руками.
|
||||
|
||||
### Вариант 2: docs-generator пишет полный mkdocs.yml
|
||||
|
||||
Сделать отдельный файл `mkdocs_base.yml` (тема, плагины, CSS, статический nav — без ресурсов), Go-генератор его читает, добавляет ресурсный nav, пишет финальный mkdocs.yml.
|
||||
|
||||
**Минус:** Go-генератор становится ответственным за весь mkdocs.yml, сложнее поддерживать структуру темы/плагинов.
|
||||
|
||||
### Вариант 3: mkdocs-awesome-pages
|
||||
|
||||
Плагин создаёт `.pages` файлы в директориях и управляет порядком через них. Но `nav:` в mkdocs.yml при этом должен быть либо полностью убран, либо включать ресурсы явно — проблему merge не решает.
|
||||
|
||||
---
|
||||
|
||||
## Дополнительная проблема: guides при смене `docs_dir`
|
||||
|
||||
Когда `docs_dir` переключается на `generated/{stand}/docs_llm`, пути вида `30_registry/guides/getting-started.md` в `nav:` ломаются — этих файлов там нет.
|
||||
|
||||
**Решение:** в том же Python pre-build шаге скопировать `30_registry` в `$MKDOCS_DOCS_DIR/30_registry/` перед сборкой. Или — убрать guides из профильного nav (оставить только ресурсы).
|
||||
|
||||
---
|
||||
|
||||
**Итого:** Вариант 1 — минимальные изменения, всё уже на месте. Нужно только расширить существующий Python блок в `04_build_and_publish_docs.sh` примерно на 10 строк + скопировать `30_registry` в `docs_dir`.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Sonnet: ответ по улучшению документации
|
||||
|
||||
## Принцип: «User Journey First» — 4 сценария
|
||||
|
||||
A. «Хочу задеплоить» → описание (30s) → минимальный пример (1m) → apply
|
||||
B. «Хочу настроить параметр» → справка с читаемыми constraints
|
||||
C. «Хочу использовать output» → outputs + HCL-примеры
|
||||
D. «Хочу modify/restart/suspend» → страница операций
|
||||
|
||||
## Изменения по 3 слоям
|
||||
|
||||
### Слой 1: LLM-промпт — P1 (макс. польза, 0 компиляции)
|
||||
- Раскрывать `value_list` → «Допустимые значения: 1, 3, 5, 7»
|
||||
- Раскрывать `regex` → «Формат: cron»
|
||||
- Заполнять пустые описания из MAN
|
||||
- Группы (clusterConfiguration) — 1 предложение из MAN
|
||||
- Операции — заполнять «—» из MAN
|
||||
- Заменять TODO в примерах на реальные значения
|
||||
|
||||
### Слой 2: docs-generator (Go) — P2
|
||||
- Убрать колонку ID
|
||||
- value_list → читаемый текст
|
||||
- Двойной пример: минимальный (15 строк) + полный в <details>
|
||||
- Секция «Быстрый старт» перед MAN
|
||||
|
||||
### Слой 3: mkdocs — P3
|
||||
- Sidebar по категориям (Базы данных / K8s / Хранилище / ...)
|
||||
- Хлебные крошки
|
||||
- Убрать inline-навигацию (заменяет sidebar)
|
||||
- Back/Next кнопки
|
||||
|
||||
## Порядок реализации
|
||||
1. LLM промпт — мгновенный эффект на все 34 сервиса
|
||||
2. renderParamTable — убрать ID, раскрыть constraints
|
||||
3. buildExamplePage — двойной пример
|
||||
4. mkdocs nav — категории в sidebar
|
||||
@@ -0,0 +1,130 @@
|
||||
# Sonnet Briefing: анализ и улучшение документации провайдера
|
||||
|
||||
Цель: изучить КАЖДЫЙ шаг генерации документации и предложить конкретные улучшения,
|
||||
чтобы пользователю было понятно и удобно работать с каждым ресурсом.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ Файлы — ПРОЧИТАТЬ ОБЯЗАТЕЛЬНО ВСЕ
|
||||
|
||||
### Генераторы
|
||||
|
||||
| # | Файл | Что смотреть |
|
||||
|---|------|-------------|
|
||||
| 1 | `TOOLS/docs-generator/main.go` | весь main — как вызывается, какие флаги |
|
||||
| 2 | `TOOLS/docs-generator/internal/writers/writers.go` | ВСЕ функции. Особенно: `buildCreateParamsPage`, `buildModifyParamsPage`, `buildOutputsPage`, `buildExamplePage`, `buildManualPage`, `renderParamTable`, `renderNestedParams`, `htmlToMarkdown`, `formatParamOrBlock` |
|
||||
| 3 | `TOOLS/docs-generator/internal/types/` | структуры YAML-спеки |
|
||||
|
||||
### LLM-обработка
|
||||
|
||||
| # | Файл | Что смотреть |
|
||||
|---|------|-------------|
|
||||
| 4 | `TOOLS/scripts/05_generate_docs_llm.py` | весь скрипт — как вызывается LLM, промпт, как парсится ответ |
|
||||
| 5 | `docs/LLM_DOCS_GENERATION.md` | архитектура, правила для LLM |
|
||||
|
||||
### Результаты генерации (примеры)
|
||||
|
||||
| # | Файл | Что смотреть |
|
||||
|---|------|-------------|
|
||||
| 6 | `generated/test/docs/postgres_params_create.md` | RAW-вывод docs-generator (без LLM) |
|
||||
| 7 | `generated/test/docs_llm/90_postgres.md` | ПОСЛЕ LLM-обработки |
|
||||
| 8 | `generated/test/docs/postgres.md` | главная страница (MAN) |
|
||||
| 9 | `generated/test/docs/postgres_example.md` | HCL пример |
|
||||
| 10 | `generated/test/docs/postgres_outputs.md` | выходные параметры |
|
||||
| 11 | `generated/test/docs/postgres_ops.md` | список операций |
|
||||
|
||||
---
|
||||
|
||||
## Текущий пайплайн (3 шага)
|
||||
|
||||
```
|
||||
YAML-спеки
|
||||
→ docs-generator (Go) → raw .md с HTML-таблицами
|
||||
→ LLM (gpt-oss-120b, по одному файлу) → улучшенные .md
|
||||
→ mkdocs-material → статический сайт → S3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что видит пользователь СЕЙЧАС (пример: postgres_params_create.md)
|
||||
|
||||
### До LLM (raw):
|
||||
```html
|
||||
<table><thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Description</th><th>Constraints</th></tr></thead>
|
||||
<tr><td>788</td><td><strong><code>cluster_configuration</code></strong></td><td><code>map-fixed</code></td><td></td><td></td></tr>
|
||||
```
|
||||
- Колонка ID (техническая, пользователю не нужна)
|
||||
- Английские заголовки (Code, Description, Constraints)
|
||||
- Пустые ячейки Description
|
||||
- Raw `value_list=` в Constraints
|
||||
|
||||
### После LLM:
|
||||
```
|
||||
| clusterConfiguration | map-fixed | да | — | — |
|
||||
```
|
||||
- Русские заголовки ✅
|
||||
- ID убран ✅
|
||||
- Но: пустые Description (—), value_list не раскрыт
|
||||
|
||||
---
|
||||
|
||||
## Проблемы (что нужно улучшить)
|
||||
|
||||
### 1. Пустые описания параметров
|
||||
Многие параметры в выводе имеют `—` в колонке «Описание». Если сервис предоставил `descr` или `man` в YAML — он должен быть в документации.
|
||||
|
||||
### 2. Технические колонки
|
||||
- Колонка «Constraints» показывает `value_list=1, 3, 5, 7` вместо читаемого «Допустимые значения: 1, 3, 5, 7»
|
||||
- Колонка «Default» показывает пустую строку вместо «нет» или «—»
|
||||
- ID параметров виден в raw-версии, но нужен ли он вообще?
|
||||
|
||||
### 3. MAN-секция (service_man)
|
||||
Это HTML-строка с полным руководством от облачного провайдера. Она обрабатывается `htmlToMarkdown()` — regex-заменами. Часто результат нечитаемый: сломанные списки, потерянные ссылки, HTML-мусор.
|
||||
|
||||
### 4. HCL-примеры
|
||||
`buildExamplePage` генерирует пример с ВСЕМИ параметрами (required + default). Это гигантский манифест на 100+ строк. Может, показывать сначала минимальный working example, а полный — отдельно?
|
||||
|
||||
### 5. Навигация
|
||||
На каждой странице — строка навигации из 6 ссылок. Занимает место, дублируется. Может, сделать сайдбар или хлебные крошки?
|
||||
|
||||
### 6. LLM-промпт (05_generate_docs_llm.py)
|
||||
Промпт просит «улучшить формулировки», но:
|
||||
- Не просит раскрывать value_list в читаемый вид
|
||||
- Не просит добавлять «почему» и «зачем» к параметрам
|
||||
- Не использует `service_man` как дополнительный контекст для обогащения описаний
|
||||
- Обрабатывает по одному файлу — теряет контекст между страницами
|
||||
|
||||
---
|
||||
|
||||
## Вопросы
|
||||
|
||||
### Q1: Структура страниц
|
||||
Текущая: Manual | Create params | Modify params | Outputs | Ops | Example.
|
||||
Удобно ли это? Что переставить/добавить/убрать? Может, всё на одной странице с якорями?
|
||||
|
||||
### Q2: HTML-таблицы vs Markdown-таблицы
|
||||
Сейчас raw — HTML, LLM конвертирует в Markdown-таблицы. Оставить Markdown? Или HTML-таблицы лучше (CSS, выравнивание)?
|
||||
|
||||
### Q3: LLM-промпт
|
||||
Как улучшить промпт чтобы:
|
||||
- description параметров наполнялся из `service_man` где возможно
|
||||
- value_list показывался читаемо
|
||||
- empty cells говорили «не указано» а не «—»
|
||||
|
||||
### Q4: HCL-примеры
|
||||
Минимальный пример + полный? Или только минимальный? Или только полный?
|
||||
|
||||
### Q5: Постраничная vs одностраничная документация
|
||||
7 .md файлов на сервис. Это норм или перебор? Может, генерировать один README.md на сервис со всем внутри?
|
||||
|
||||
### Q6: Что ещё можно улучшить для UX?
|
||||
Посмотри на любые 2-3 страницы из `generated/test/docs_llm/` и скажи: что непонятно, что раздражает, чего не хватает.
|
||||
|
||||
---
|
||||
|
||||
## Ожидаемый ответ
|
||||
|
||||
1. Анализ текущего состояния: что хорошо, что плохо (с конкретными примерами из файлов)
|
||||
2. Конкретные предложения по каждому из 6 вопросов
|
||||
3. Unified diff предлагаемых изменений в writers.go и 05_generate_docs_llm.py
|
||||
4. Пример одной страницы «как должно быть» для postgres (хотя бы params_create)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Документация — полный диалог и финальный план
|
||||
|
||||
## Ответы на 3 вопроса Соннета
|
||||
|
||||
### 1. Inline-навигацию убирать?
|
||||
**Убирать, но сначала добавить страницы в sidebar.**
|
||||
Сейчас ресурсные страницы не в `mkdocs.yml nav:` — они сироты. Inline nav — единственная навигация.
|
||||
Порядок: сначала P3 (добавить в sidebar через `_nav_fragment.yml`), потом убрать inline из writers.go.
|
||||
|
||||
### 2. Минимальный пример — только required без default?
|
||||
**Да.** Параметр с дефолтом и так сработает без указания. Минимальный пример = required=true И default пустой.
|
||||
15 строк вместо 100.
|
||||
|
||||
### 3. Категории для sidebar
|
||||
Группировка по 7 категориям:
|
||||
|
||||
| Категория | Сервисы |
|
||||
|---|---|
|
||||
| Базы данных | postgres, redis, mongodb, mariadb, clickhouse |
|
||||
| Очереди | rabbitmq, kafka |
|
||||
| Хранилище | s3, s3bucket, nextcloud |
|
||||
| K8s | k8s_velero, k8s_sthutrval_cluster, k8s_openbao, vc_mgmt_sthutrval_cluster |
|
||||
| VMware | vc_org, vc_vdc, vc_nsxt, vcexternalip, vapp, vc_vm_v2, vc_vm_v3, vc_vdc_group |
|
||||
| Приложения | flask, nodejs, lucee, http, gitea, superset, pgadmin, harbor, akhq, llm_ai |
|
||||
| Сеть | zones_v2, dnsrecord |
|
||||
|
||||
---
|
||||
|
||||
## Финальный план (3 слоя)
|
||||
|
||||
### P1: LLM-промпт (05_generate_docs_llm.py) — 0 компиляции, 34 сервиса
|
||||
6 инструкций:
|
||||
- value_list → «Допустимые значения: X, Y, Z»
|
||||
- regex → «Формат: cron / UUID / IP»
|
||||
- Описания групп из MAN
|
||||
- Пустые описания заполнять
|
||||
- Операции без «—»
|
||||
- TODO в примерах → реальные значения
|
||||
|
||||
### P2: docs-generator (writers.go)
|
||||
- renderParamTable: убрать ID, value_list → читаемый текст
|
||||
- buildExamplePage: минимальный пример + полный в <details>
|
||||
|
||||
### P3: mkdocs навигация
|
||||
- docs-generator генерирует _nav_fragment.yml с категориями
|
||||
- 04_build_and_publish_docs.sh вставляет его в mkdocs.yml
|
||||
- writers.go: убрать inline nav
|
||||
- mkdocs.yml: breadcrumbs + prev/next
|
||||
|
||||
### Порядок: P1 → P2 → P3
|
||||
@@ -0,0 +1,32 @@
|
||||
# Документация — финальный план P1 (утверждён)
|
||||
|
||||
Дата: 2026-08-09
|
||||
Источник: Sonnet, после серии брифов и уточнений
|
||||
|
||||
## Что меняется в 05_generate_docs_llm.py
|
||||
|
||||
### 1. SYSTEM_PROMPT — замена
|
||||
Новый промпт с правилами A-E (см. HISTORY/SONNET/docs_prompt_full_response.md)
|
||||
|
||||
### 2. max_tokens: 4096 → 8192
|
||||
|
||||
### 3. Новая функция extract_man(text) → str
|
||||
Вырезает блок ## MAN из Name.md. Используется как контекст для params/ops.
|
||||
|
||||
### 4. Новая функция build_prompt(file_type, filename, content, man) → str
|
||||
Формирует сообщение для LLM: тип файла + MAN-контекст + содержимое.
|
||||
|
||||
### 5. Обработка ВСЕХ типов файлов
|
||||
Было: только Name.md
|
||||
Стало: Name.md, _params_create.md, _params_modify.md, _ops.md, _example.md
|
||||
MAN-контекст: для params и ops, без MAN для главной и примеров.
|
||||
|
||||
### 6. Копирование в docs_llm/
|
||||
- outputs, params-landing, subresource — копировать as-is
|
||||
- 30_registry/, guides/, index.md — копировать из docs/
|
||||
|
||||
### Решения
|
||||
- Subresource: копировать as-is (не через LLM)
|
||||
- max_tokens: 8192
|
||||
- Вывод: docs_llm/
|
||||
- 30_registry копировать в самом скрипте
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sonnet: финальный план P1 — 05_generate_docs_llm.py
|
||||
|
||||
## Пайплайн на один сервис
|
||||
|
||||
```
|
||||
docs/Name.md ─┐
|
||||
docs/Name_params_create.md ─┤ LLM → docs_llm/Name.md
|
||||
docs/Name_params_modify.md ─┤ docs_llm/Name_params_create.md
|
||||
docs/Name_ops.md ─┤ docs_llm/Name_params_modify.md
|
||||
docs/Name_example.md ─┘ docs_llm/Name_ops.md
|
||||
docs_llm/Name_example.md
|
||||
|
||||
docs/Name_outputs.md ─── copy → docs_llm/Name_outputs.md
|
||||
docs/Name_params.md ─── copy → docs_llm/Name_params.md
|
||||
docs/Name_subresource*.md ─── copy → docs_llm/Name_subresource*.md
|
||||
```
|
||||
|
||||
## Ключевые детали
|
||||
|
||||
### MAN-контекст
|
||||
- Извлекается из `docs/Name.md` (raw HTML) функцией `extract_man()`
|
||||
- Передаётся в том же сообщении что и params/ops файлы
|
||||
- Для главной страницы (Name.md) и примеров (_example.md) — без MAN
|
||||
|
||||
### Параметры LLM
|
||||
- `max_tokens`: 4096 → **8192**
|
||||
- `temperature`: 0.15 (без изменений)
|
||||
- `model`: gpt-oss-120b (без изменений)
|
||||
|
||||
### Интеграция с 04_build_and_publish_docs.sh
|
||||
Вариант A: DOCS_GEN_DIR → `docs_llm/`. Скрипт копирует guides/, 30_registry/, index.md в docs_llm/.
|
||||
|
||||
## Вопрос: subresource-страницы обрабатывать LLM?
|
||||
postgres_user.md, postgres_database.md, postgres_backup.md — их структура как у _params_create.md.
|
||||
Пока копировать as-is или тоже через LLM?
|
||||
@@ -0,0 +1,38 @@
|
||||
# Sonnet: ответы — готовый SYSTEM_PROMPT и механизм MAN→группы
|
||||
|
||||
## Вопрос 2: MAN → группы
|
||||
|
||||
Явный маппинг НЕ нужен. LLM делает семантический матч:
|
||||
- Видит группу `clusterConfiguration` с sub-params `cpu, memory, disk, replicas`
|
||||
- Видит в MAN: «Квота (millicore) ядра пода... Квота памяти... Размер диска... Количество узлов»
|
||||
- Сопоставляет по смыслу → «Ресурсы пода кластера: CPU, RAM, диск, реплики»
|
||||
|
||||
Условие: MAN в том же сообщении, что и params-файл.
|
||||
|
||||
## Готовый SYSTEM_PROMPT
|
||||
|
||||
См. полный текст с правилами A-E:
|
||||
- A: удалить колонку ID
|
||||
- B: value_list → «Допустимые значения: X, Y, Z»
|
||||
- C: regex → читаемый формат
|
||||
- D: пустые описания → заполнить из MAN
|
||||
- E: группы (map-fixed) → 1 предложение о содержимом
|
||||
|
||||
+ правила для _ops.md, _example.md, главной страницы (MAN)
|
||||
|
||||
## Новая логика вызова LLM
|
||||
|
||||
MAN передаётся как контекст в том же сообщении что и params-файл:
|
||||
```
|
||||
Тип файла: _params_create
|
||||
Сервис: postgres
|
||||
=== MAN СЕРВИСА ===
|
||||
{текст MAN из Name.md}
|
||||
=== Файл ===
|
||||
{содержимое}
|
||||
```
|
||||
|
||||
## 2 вопроса
|
||||
|
||||
1. max_tokens 4096 → 8192? (params для postgres ~4KB HTML)
|
||||
2. Писать в docs_llm/ или сразу на место?
|
||||
@@ -0,0 +1,162 @@
|
||||
# Sonnet: ПОЛНЫЙ ответ — готовый SYSTEM_PROMPT + механизм MAN→группы
|
||||
|
||||
## Вопрос 2: механизм маппинга MAN → группы
|
||||
|
||||
**Явный маппинг не нужен.** LLM делает его сам через семантику.
|
||||
|
||||
Как это работает для `clusterConfiguration`:
|
||||
|
||||
```
|
||||
LLM видит в _params_create.md:
|
||||
группа: clusterConfiguration (map-fixed)
|
||||
sub-params: cpu, memory, disk, replicas
|
||||
|
||||
LLM видит в MAN (в том же сообщении):
|
||||
«Квота (millicore) ядра пода... Квота (megabyte) памяти...
|
||||
Размер диска... Количество узлов (реплик)...»
|
||||
|
||||
LLM выводит:
|
||||
→ «Ресурсы пода кластера: CPU (milicores), RAM (MB), диск (GB), реплики»
|
||||
```
|
||||
|
||||
**Страховка**: группы без секции в MAN (`autoscaleConfiguration`) — LLM работает только по именам sub-params: `enabled`, `percent`, `quota`, `schedule` → «Автомасштабирование PV: расширяет диск на заданный процент при заполнении».
|
||||
|
||||
**Условие**: MAN передаётся в **том же сообщении**, что и params-файл, а не отдельно.
|
||||
|
||||
---
|
||||
|
||||
## Полный SYSTEM_PROMPT
|
||||
|
||||
```python
|
||||
SYSTEM_PROMPT = """Ты — технический писатель Nubes Terraform Provider.
|
||||
Улучшаешь автогенерированные Markdown-файлы документации.
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
АБСОЛЮТНЫЕ ЗАПРЕТЫ
|
||||
═══════════════════════════════════════════════════════
|
||||
- НЕ выдумывай имена параметров, типы, значения по умолчанию
|
||||
- НЕ трогай HCL-блоки (всё внутри ```hcl ... ```)
|
||||
- НЕ трогай имена параметров в таблицах (snake_case / camelCase из API)
|
||||
- НЕ трогай навигационные строки вида [Manual](x.md) · [Create params](y.md) ...
|
||||
- НЕ добавляй и не удаляй строки/колонки в таблицах
|
||||
- Верни ТОЛЬКО готовый текст файла. Без объяснений, без``` вокруг всего текста
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
ПРАВИЛА ДЛЯ ТАБЛИЦ ПАРАМЕТРОВ (_params_create.md, _params_modify.md)
|
||||
═══════════════════════════════════════════════════════
|
||||
Таблицы содержат столбцы: ID | Code | Type | Required | Default | Description | Constraints
|
||||
Можно менять ТОЛЬКО текст в <td>Description</td> и <td>Constraints</td>.
|
||||
|
||||
ПРАВИЛО A — удали колонку ID:
|
||||
Удали <th>ID</th> из заголовка и соответствующий первый <td>число</td> из каждой строки.
|
||||
|
||||
ПРАВИЛО B — value_list в Constraints:
|
||||
value_list=1, 3, 5, 7 → очисти ячейку Constraints до пустой.
|
||||
В ячейку Description добавь строку: «Допустимые значения: **1, 3, 5, 7**»
|
||||
Если в Description уже был текст — добавь после него, через пробел или перевод строки (<br/>).
|
||||
|
||||
ПРАВИЛО C — regex в Constraints:
|
||||
Замени regex-строку на читаемое описание формата:
|
||||
- cron-подобный regex → «Формат: cron-выражение. Пример: `0 0 * * *`»
|
||||
- UUID regex → «Формат: UUID»
|
||||
- IP-адрес regex → «Формат: IP-адрес»
|
||||
- Прочее → кратко опиши формат своими словами
|
||||
|
||||
ПРАВИЛО D — пустое Description (пустая ячейка или —):
|
||||
Напиши краткое описание параметра (1–2 предложения). Приоритет источников:
|
||||
1. MAN — ищи текст, связанный с параметром по смыслу и по именам sub-params
|
||||
2. Имя параметра snake_case → понятный русский
|
||||
3. Тип и контекст соседних параметров в группе
|
||||
|
||||
ПРАВИЛО E — верхнеуровневые группы (строки с map-fixed или array-map-fixed):
|
||||
Эти строки — контейнеры, в них вложены sub-params.
|
||||
Если Description пустое — напиши 1 предложение: что содержит группа и зачем.
|
||||
Смотри на имена sub-params (они идут в следующих строках) + MAN.
|
||||
Пример: clusterConfiguration с sub-params cpu/memory/disk/replicas
|
||||
→ «Ресурсы пода кластера: CPU (milicores), RAM (MB), диск (GB) и количество реплик»
|
||||
Пример: backupConfiguration с sub-params s3_uid/retain/schedule
|
||||
→ «Параметры резервного копирования: S3-хранилище, расписание и глубина хранения»
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
ПРАВИЛА ДЛЯ ОПЕРАЦИЙ (_ops.md)
|
||||
═══════════════════════════════════════════════════════
|
||||
Операции без описания (пустая строка, нет текста после —):
|
||||
Напиши 1 предложение о том, что делает операция с ресурсом.
|
||||
Используй MAN если передан. Не придумывай параметров.
|
||||
|
||||
Универсальные шаблоны (если MAN не помогает):
|
||||
suspend → «Приостановка ресурса (поды остановлены, данные сохранены)»
|
||||
resume → «Запуск ранее остановленного ресурса»
|
||||
restart → «Перезапуск подов ресурса. ⚠️ Возможна кратковременная недоступность»
|
||||
reconcile → «Принудительная синхронизация состояния с API»
|
||||
recovery → «Восстановление из резервной копии»
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
ПРАВИЛА ДЛЯ ГЛАВНОЙ СТРАНИЦЫ (Name.md — секция ## MAN)
|
||||
═══════════════════════════════════════════════════════
|
||||
Блок ## MAN содержит HTML внутри <div class="man-content">.
|
||||
Преобразуй HTML → читаемый Markdown:
|
||||
<h2>/<h3> → ## / ###
|
||||
<ul><li> → - элемент списка
|
||||
<strong> → **текст**
|
||||
<code> → `текст`
|
||||
<a href="url">текст</a> → [текст](url)
|
||||
<br/>, <p>, <div> → удали тег, замени переносами строк где нужно
|
||||
Лишние пустые строки подряд → одна пустая строка
|
||||
|
||||
Сохраняй всё смысловое содержание. Не перефразируй, не сокращай.
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
ПРАВИЛА ДЛЯ ПРИМЕРОВ (_example.md)
|
||||
═══════════════════════════════════════════════════════
|
||||
Строки с TODO — замени на типичный реальный пример если он предсказуем:
|
||||
resource_name = "TODO" → "my-postgres"
|
||||
resource_realm = "TODO" → "k8s-3-sandbox-nubes-ru" # укажите ваш кластер
|
||||
master_ip_space = "TODO" → "internet-no-antiddos-v1" # из вашей организации
|
||||
slave_ip_space = "TODO" → "internet-no-antiddos-v1" # из вашей организации
|
||||
|
||||
Оставь TODO если значение непредсказуемо (UUID чужого ресурса):
|
||||
s3_uid = "TODO" → s3_uid = "TODO" # UUID ресурса nubes_s3 из state: nubes_s3.backup_store.id
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Новая логика USER-сообщения
|
||||
|
||||
```python
|
||||
# При обработке _params_create / _params_modify / _ops / _example:
|
||||
man_text = extract_man_section(service_main_md) # берём ## MAN из Name.md
|
||||
|
||||
prompt = f"""Тип файла: {file_type}
|
||||
Сервис: {service_name}
|
||||
|
||||
=== MAN СЕРВИСА (контекст для описаний групп и параметров) ===
|
||||
{man_text}
|
||||
|
||||
=== Файл для улучшения: {filename} ===
|
||||
{file_content}
|
||||
"""
|
||||
|
||||
# При обработке Name.md (главная):
|
||||
prompt = f"""Тип файла: ГЛАВНАЯ СТРАНИЦА
|
||||
Сервис: {service_name}
|
||||
|
||||
=== Файл для улучшения: {filename} ===
|
||||
{file_content}
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2 вопроса от Соннета
|
||||
|
||||
1. `max_tokens` сейчас `4096` — но `_params_create.md` для postgres ~4KB HTML. Поднять до `8192`?
|
||||
2. Писать в `docs_llm/` или сразу на место?
|
||||
|
||||
---
|
||||
|
||||
## Ответы
|
||||
|
||||
1. **max_tokens = 8192** — да. После обогащения описаниями файл станет больше.
|
||||
2. **Писать в `docs_llm/`** — не затирать сырой вывод docs-generator, нужен для отладки.
|
||||
@@ -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,65 @@
|
||||
# Sonnet: ответы по тестированию stages без реальных инстансов
|
||||
|
||||
## 1. Мок API
|
||||
|
||||
Достаточно мокать только `/instanceOperations/{uid}?fields=...`.
|
||||
|
||||
`waitForOperationFinish` делает ровно один тип запроса — `doRequest GET /instanceOperations/{uid}?fields=...`. Создаёшь `httptest.NewServer`, настраиваешь нужные ответы, конструируешь клиент:
|
||||
|
||||
```go
|
||||
c := &UniversalClient{
|
||||
HttpClient: &http.Client{},
|
||||
ApiEndpoint: testServer.URL,
|
||||
ApiToken: "test-token",
|
||||
}
|
||||
```
|
||||
|
||||
Мокать create → params → run **не нужно** — вызываешь `waitForOperationFinish` напрямую с любым `opUid`.
|
||||
|
||||
## 2. Время
|
||||
|
||||
Проблема: `time.NewTicker(5 * time.Second)` захардкожен, таймаут 30 минут.
|
||||
|
||||
**Решение: добавить поле `PollInterval time.Duration` в `UniversalClient`.**
|
||||
Нулевое значение → default 5s. В тесте ставить 1ms.
|
||||
|
||||
`time.Now()` мокать **не нужно** — `timeout` уже параметр функции. В тесте передаёшь `200ms`, дедлайн считается `time.Now().Add(200ms)` — реальное время, работает нормально.
|
||||
|
||||
## 3. Перехват вывода tty
|
||||
|
||||
**Рекомендуемый подход: добавить поле `Writer io.Writer` в `UniversalClient`.**
|
||||
В `waitForOperationFinish` заменить `ttyOut()` на: `if c.Writer != nil { w = c.Writer } else { w = ttyOut() }`.
|
||||
В тесте: `c.Writer = &bytes.Buffer{}` → проверяешь что именно напечатано. В проде: оставляешь nil → поведение не меняется.
|
||||
|
||||
`os.Pipe()` + подмена `os.Stderr` **не рекомендует** — глобальное состояние, ломается при параллельных тестах.
|
||||
|
||||
## 4. Конкретные тест-кейсы
|
||||
|
||||
| # | Сценарий | Mock отдаёт | Ожидаем |
|
||||
|---|----------|-------------|---------|
|
||||
| 1 | Stages по одному | poll 1: 1 stage без dtFinish; poll 2: тот же stage с dtFinish; poll 3: операция с dtFinish+isSuccessful=true | `err == nil` |
|
||||
| 2 | Все stages сразу | poll 1: dtFinish + isSuccessful=true + все stages завершены | `err == nil`, Writer содержит `[OK]` для каждого |
|
||||
| 3 | Fail на этапе 3 | stages[0,1] OK, stages[2] isSuccessful=false, операция dtFinish + isSuccessful=false + errorLog="disk error" | `err` содержит `"disk error"` |
|
||||
| 4 | API 500 | всегда 500 (doRequest уже делает 3 retry внутри) | `err != nil` |
|
||||
| 5 | Таймаут | никогда не отдаёт dtFinish | `err` содержит `"не завершилась за установленный таймаут"` |
|
||||
| 6 | Отмена ctx | poll завис | `err` содержит `"отменена"` |
|
||||
|
||||
## 5. Рефакторинг
|
||||
|
||||
**Да, минимально — два новых поля в `UniversalClient`:**
|
||||
|
||||
```go
|
||||
type UniversalClient struct {
|
||||
// ... существующие поля ...
|
||||
PollInterval time.Duration // 0 = default 5s
|
||||
Writer io.Writer // nil = ttyOut()
|
||||
}
|
||||
```
|
||||
|
||||
В `waitForOperationFinish` (~3 строки правки):
|
||||
- `interval := c.PollInterval; if interval == 0 { interval = 5*time.Second }`
|
||||
- `w := c.Writer; if w == nil { w = ttyOut(); defer w.(*os.File).Close() }`
|
||||
|
||||
Не ломает существующие вызовы (оба поля zero-value совместимы с текущим поведением).
|
||||
|
||||
**Что НЕ нужно менять:** сигнатуру функции, логику дедлайна через `time.Now()`, retry в `doRequest`.
|
||||
@@ -0,0 +1,48 @@
|
||||
# DeepSeek Flash: ответы по тестированию stages
|
||||
|
||||
См. HISTORY/SONNET/stages_testing_question.md — исходные вопросы.
|
||||
|
||||
## 1. Мок API
|
||||
|
||||
**Да, достаточно httptest.NewServer.** Только `/instanceOperations/{uid}?fields=...`. Цепочку create→params→run не мокать.
|
||||
|
||||
```go
|
||||
client := &UniversalClient{
|
||||
HttpClient: srv.Client(),
|
||||
ApiEndpoint: srv.URL,
|
||||
ApiToken: "test",
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Время
|
||||
|
||||
**Поле PollInterval time.Duration** в UniversalClient. 0 → default 5s. В тесте 1ms.
|
||||
|
||||
Таймаут: передать маленький timeout параметром (например 30ms). time.Now мокать не нужно.
|
||||
|
||||
## 3. Перехват tty
|
||||
|
||||
Вариант B — os.Pipe + подмена os.Stderr. Минус: не потокобезопасно (без t.Parallel() ок).
|
||||
|
||||
Вариант C (чище) — вынести печать в метод printStages(w io.Writer, ...), тогда ttyOut для продешкна, bytes.Buffer для теста.
|
||||
|
||||
## 4. Тест-кейсы (5 шт)
|
||||
|
||||
| # | Кейс | Выдача сервера |
|
||||
|---|------|----------------|
|
||||
| 1 | stages по одному | счётчик вызовов: stage1 без dtFinish → stage1 с dtFinish, stage2 без → всё dtFinish |
|
||||
| 2 | все сразу | первый ответ: всё dtFinish, isSuccessful=true |
|
||||
| 3 | fail на этапе 3 | stage3: isSuccessful=false, errorLog="boom" |
|
||||
| 4 | API 500 → retry | 500 → 200. ⚠️ doRequest retry 2s→4s→8s, нужен RetryBaseDelay |
|
||||
| 5 | таймаут | всегда isInProgress=true, dtFinish пустой |
|
||||
|
||||
## 5. Рефакторинг
|
||||
|
||||
**Да, минимально. НО:** в коде стоит ⛔ запрет на правку без оператора.
|
||||
|
||||
Безопасный минимум:
|
||||
- PollInterval (0 → 5s default)
|
||||
- printStages(w io.Writer) — вынос вывода
|
||||
- RetryBaseDelay (0 → 2s default) — для кейса 500
|
||||
|
||||
Альтернатива без рефакторинга: тестировать с реальными 5s тиками — медленно.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Sonnet v2: ответы по тестированию stages (после исправленного брифа)
|
||||
|
||||
## Q1. Мок API
|
||||
Только `/instanceOperations/{uid}?fields=...`. `httptest.NewServer` + `HttpClient`/`ApiEndpoint`.
|
||||
|
||||
## Q2. Время
|
||||
Два hardcoded таймера: `time.NewTicker(5s)` и `doRequest` delays (2s→4s→8s).
|
||||
- Без рефакторинга: тесты медленные (~5-19s)
|
||||
- С рефакторингом: поле `tickerInterval` — но ⛔ запрещает менять частоту опроса
|
||||
|
||||
## Q3. Перехват tty
|
||||
- Без рефакторинга: вывод в stderr, не ломает тесты
|
||||
- С рефакторингом: поле `ttyWriter io.Writer`
|
||||
|
||||
## Q4. Тест-кейсы (5 шт)
|
||||
1. stages по одному — `atomic.Int32` счётчик
|
||||
2. все сразу
|
||||
3. фейл — `IsSuccessful=false`, `ErrorLog="disk error"`
|
||||
4. **503 (не 500!)** — 500 не retryable. 503 — retryable. Тест ~19s без рефакторинга
|
||||
5. таймаут — `timeout=6s`
|
||||
|
||||
## Q5. Рефакторинг
|
||||
Без рефакторинга — всё тестируемо, но медленно.
|
||||
С рефакторингом — 6 строк, не в protected zone, но `tickerInterval` требует согласования (⛔).
|
||||
|
||||
## Итог
|
||||
| Тест | Без рефакторинга | С рефакторингом |
|
||||
|---|---|---|
|
||||
| sequential | 15s | 150ms |
|
||||
| all_at_once | 5s | 50ms |
|
||||
| fail | 5s | 50ms |
|
||||
| 503_retry | 19s | быстро |
|
||||
| timeout | 5s | 100ms |
|
||||
| tty capture | ❌ | ✅ |
|
||||
@@ -0,0 +1,53 @@
|
||||
# Sonnet: тестирование stages без реальных инстансов
|
||||
|
||||
Реализовали вывод этапов в `waitForOperationFinish()`. Код в репе.
|
||||
Нужно протестировать БЕЗ создания реальных инстансов в облаке.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ Файлы — ПРОЧИТАТЬ ОБЯЗАТЕЛЬНО (иначе ответ будет неполным)
|
||||
|
||||
| # | Файл | Строки | Что искать |
|
||||
|---|------|--------|------------|
|
||||
| 1 | `provider/internal/core/client.go` | 29–55 | `UniversalClient` struct: поля `HttpClient`, `ApiEndpoint`, `ApiToken` |
|
||||
| 2 | `provider/internal/core/client.go` | 895–1010 | `waitForOperationFinish()` — ВСЯ функция от сигнатуры до закрывающей `}` |
|
||||
| 3 | `provider/internal/core/client.go` | 895–910 | ⛔ «ЗАПРЕЩЕНО ПРАВИТЬ ЭТОТ КОД БЕЗ ЯВНОГО СОГЛАСОВАНИЯ» |
|
||||
| 4 | `provider/internal/core/client.go` | 900–930 | `ttyOut()`, `opStage` struct, `operationStatusResponse` struct |
|
||||
| 5 | `provider/internal/core/client.go` | 1030–1140 | `doRequest()` — retry-логика, паузы **2s → 4s → 8s**, `maxRetries=3` |
|
||||
| 6 | `provider/internal/core/client_test.go` | весь файл | как создаётся `UniversalClient`, структура существующих тестов |
|
||||
|
||||
---
|
||||
|
||||
## Вопросы
|
||||
|
||||
### 1. Мок API
|
||||
Достаточно ли `httptest.NewServer` ТОЛЬКО для `/instanceOperations/{uid}?fields=...`?
|
||||
Или нужно мокать всю цепочку create → params → run → poll?
|
||||
|
||||
### 2. Время
|
||||
`time.NewTicker(5*time.Second)`, таймаут 30 мин. Как ускорить тест?
|
||||
Dependency injection? Mock `time.Now`?
|
||||
|
||||
### 3. Перехват tty
|
||||
`ttyOut()` → `/dev/tty` с fallback на `os.Stderr`. Как перехватить вывод?
|
||||
|
||||
### 4. Тест-кейсы
|
||||
Какие тесты написать:
|
||||
- stages по одному (прогресс)
|
||||
- stages все сразу
|
||||
- фейл на этапе N
|
||||
- API 500 → retry (**учти задержки в `doRequest`: 2s → 4s → 8s**)
|
||||
- таймаут операции
|
||||
|
||||
### 5. Рефакторинг
|
||||
Стоит ли рефакторить `waitForOperationFinish`?
|
||||
**Учти ⛔ запрет на правку (стр. 895–910).**
|
||||
|
||||
---
|
||||
|
||||
## Ожидаемый ответ
|
||||
|
||||
1. Конкретный ответ на каждый вопрос
|
||||
2. Unified diff минимальных изменений (если нужен рефакторинг)
|
||||
3. Псевдокод каждого тест-кейса
|
||||
4. Оценка: что можно без рефакторинга, что потребует правок
|
||||
Reference in New Issue
Block a user