From 86871498a2ef5f0ea2726b4af7dba12ae4fcb8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 31 Aug 2026 20:19:31 +0300 Subject: [PATCH] Fix provider review findings --- HISTORY/2026-08-31_opus_code_review.md | 173 ++++++++++++++++++ TOOLS/config/dev/profile.env | 2 +- TOOLS/config/prod/profile.env | 2 +- TOOLS/config/test/profile.env | 2 +- .../internal/helpers/helpers.go | 10 +- .../internal/templates/action.go | 2 +- .../internal/templates/instance.go | 2 +- .../internal/templates/subresource.go | 3 +- .../internal/writers/writers.go | 22 ++- TOOLS/resource-generator/main.go | 47 ++++- provider/internal/core/client.go | 172 +++++++---------- scripts/check_cloud_instances.py | 15 +- scripts/s3_notification_example.sh | 16 +- 13 files changed, 327 insertions(+), 141 deletions(-) create mode 100644 HISTORY/2026-08-31_opus_code_review.md diff --git a/HISTORY/2026-08-31_opus_code_review.md b/HISTORY/2026-08-31_opus_code_review.md new file mode 100644 index 0000000..3884794 --- /dev/null +++ b/HISTORY/2026-08-31_opus_code_review.md @@ -0,0 +1,173 @@ +# Code Review провайдера — Opus — 2026-08-31 + +**Источник:** анализ и код-ревью через VS Code Copilot Chat +**Статус:** анализ завершён; часть исправлений внесена 2026-08-31 + +## Область анализа + +Проверены: + +- рукописное ядро провайдера в `provider/internal/core` и `provider/internal/resources_core`; +- CRUD, state management и валидация; +- HTTP-слой и `client.go`; +- регистрация провайдера и TLS-настройки; +- генераторы Go-ресурсов, YAML и build-пайплайн; +- Python- и shell-скрипты; +- gateway. + +## Критичные находки + +### 1. Отладочный лог с данными инстансов пишется в `/tmp` безусловно + +В `provider/internal/core/client.go:629-637` замыкание `debug()` в `FindInstanceByDisplayName` всегда пишет в `/tmp/nubes_find_debug.log` с правами `0644`. В лог попадают `instanceUid`, `displayName` и `serviceId`. + +Файл не защищён условием `NUBES_DEBUG_HTTP`, не ротируется и не очищается. Это создаёт риск раскрытия данных и неконтролируемого роста файла. + +**Рекомендация:** убрать постоянную запись либо включать её только через явный debug-флаг; использовать безопасный путь и контролируемую ротацию. + +### 2. Bearer-токен попадает в stderr при HTTP-отладке + +В `provider/internal/core/client.go:1100-1101` вызов `httputil.DumpRequestOut(req, ...)` выводит полный исходящий запрос вместе с заголовком `Authorization: Bearer ` при `NUBES_DEBUG_HTTP=1`. + +Токен может попасть в логи CI/CD или окружения выполнения. + +**Рекомендация:** перед дампом удалять или маскировать `Authorization`; не выводить секреты ни в одном режиме. + +### 3. В Python-скрипте сетевые вызовы выполняются без таймаутов + +В `scripts/check_cloud_instances.py:87-88` вызовы `self.session.get(...)` не передают `timeout=`. При зависании API процесс может ожидать ответ бесконечно. + +**Рекомендация:** добавить явные таймауты ко всем HTTP-вызовам и определить единое значение или конфигурационный параметр. + +## Существенные находки + +### 4. Retry сетевых ошибок применяется к POST-запросам + +В `provider/internal/core/client.go:1113-1120` при сетевой ошибке повторяется любой HTTP-метод, включая POST к `/instances` и `/instanceOperations`. + +Если сервер принял запрос, но ответ потерян, повтор может создать дубликат инстанса или операции. Идемпотентность POST не гарантирована. + +**Рекомендация:** ограничить retry идемпотентными методами либо использовать идемпотency key и явную серверную поддержку повторов. + +### 5. Ответ `401 Unauthorized` включён в retryable + +В `provider/internal/core/client.go:1150-1156` статус `401` считается повторяемым. Протухший или неверный токен приводит к трём попыткам с задержкой, маскируя исходную ошибку авторизации и увеличивая время отказа. + +**Рекомендация:** исключить `401` из retryable; возвращать ошибку авторизации сразу. + +### 6. Gateway раскрывает внутренние upstream-адреса + +В `gateway/server.js:60-71` корневой endpoint `/` и обработчик 404 возвращают наружу адреса `upstream` для маршрутов. + +Публичный ответ раскрывает внутреннюю топологию сервисов. + +**Рекомендация:** убрать `upstream` из публичных ответов; внутренние адреса оставлять только в серверных логах с необходимой санацией. + +### 7. Некорректное определение неуспешной операции в Python + +В `scripts/check_cloud_instances.py:187-189` используется сравнение `last_op.get("isSuccessful") == False`. При отсутствии поля возвращается `None`, поэтому состояние `OPERATION_FAILED` не определяется. + +**Рекомендация:** использовать проверку `is False` либо явно обрабатывать отсутствие ключа согласно контракту API. + +## Умеренные находки + +### 8. Retry-логика дублируется в трёх местах + +В `provider/internal/core/client.go:777-905` похожие циклы retry присутствуют в `doRequest`, `GetInstanceState` и `GetInstanceStateRaw`. + +Дублирование увеличивает риск расхождения поведения и повторного появления ошибок безопасности. + +**Рекомендация:** вынести общую retry-логику в единый внутренний helper с параметрами метода, таймаутов и политики повторов. + +### 9. Пагинация имеет тихий предел 10 000 инстансов + +В fallback-ветке `FindInstanceByDisplayName` (`provider/internal/core/client.go:747-749`) поиск прекращается после `page > 100` при размере страницы `100`. + +При большем количестве инстансов совпадение может не быть найдено без предупреждения. + +**Рекомендация:** убрать произвольный предел либо возвращать диагностируемую ошибку/предупреждение при достижении лимита. + +### 10. Ошибка `gofmt` не останавливает генерацию + +`FormatSourceOrWarn` в `TOOLS/resource-generator/writers.go:61` при ошибке форматирования только выводит предупреждение и записывает исходник. + +В результате pipeline может сохранить неформатированный или потенциально некомпилируемый Go-код. + +**Рекомендация:** считать ошибку форматирования фатальной для генерации либо выполнять последующую обязательную компиляционную проверку. + +### 11. Секрет передаётся в командной строке shell-скрипта + +В `TOOLS/s3_notification_example.sh:74` значение `SECRET_KEY` передаётся аргументом в `mc alias set`. + +Секрет может быть виден через `ps` или аналогичный список процессов. + +**Рекомендация:** использовать механизм передачи секрета через stdin, переменную окружения, конфигурационный файл с безопасными правами или другой поддерживаемый секретный канал. + +## Дополнительные замечания + +- В `provider/internal/core/client.go` ссылка на `tools/gen_v2/generate_resources_v2.go` обновлена на актуальный путь `TOOLS/resource-generator/internal/templates/instance.go`. +- В исходниках генератора (`TOOLS/resource-generator/internal/templates/*`, `TOOLS/resource-generator/internal/writers/writers.go`) метка `Code generated by tools/gen_v2` обновлена на `Code generated by TOOLS/resource-generator`. +- Текущий `provider/internal/resources_gen/registry.go` обновлён на новую метку генератора. +- `TOOLS/resource-generator/main.go` переведён на `run()` с корректным `exit code=1` и агрегированным отчётом по ошибкам записи ресурсов (instance/subresource/action). +- Пути debug-логов в `provider/internal/core/client.go` переведены на `os.TempDir()` с override через `NUBES_DEBUG_DIR` (без хардкода `/tmp`). + +## Что выглядит хорошо + +- Сериализация операций на инстансе через `instanceMutexes` в `client.go` защищает от параллельных операций API. +- TLS настроен с `MinVersion: TLS 1.2`; `InsecureSkipVerify` по умолчанию равен `false`. +- `api_token` отмечен как `Sensitive: true` в схеме провайдера. +- Канонизация JSON для сравнения state устраняет ложные различия из-за порядка ключей. + +## Итоговый статус + +| Находка | Статус | +|---|---| +| Безусловная запись данных инстансов в `/tmp` | Исправлено: debug gated + права `0600` | +| Bearer-токен в HTTP debug dump | Исправлено: `Authorization` маскируется | +| Python HTTP-вызовы без таймаутов | Исправлено: добавлен `REQUEST_TIMEOUT` | +| Retry POST-запросов | Исправлено: retry сетевых ошибок только для GET | +| `401` в retryable | Исправлено: исключён из retryable | +| Раскрытие upstream в gateway | Исправлено: `upstream` удалён из root-ответа | +| Ошибка определения `OPERATION_FAILED` | Исправлено: сравнение через `is False` | +| Дублирование retry-логики | Исправлено: общий helper для чтения состояния | +| Тихий предел пагинации | Частично исправлено: добавлена явная ошибка при достижении лимита | +| Некритичная ошибка `gofmt` в генераторе | Исправлено: fail-fast при ошибке форматирования | +| Секрет в аргументах shell-команды | Исправлено: исключена передача в argv | + +## Выполненные изменения (2026-08-31) + +- `provider/internal/core/client.go`: + - debug-лог `FindInstanceByDisplayName` теперь пишется только при `NUBES_DEBUG_HTTP=1`; + - права debug-логов снижены до `0600`; + - в stderr-дампе HTTP-запроса маскируется заголовок `Authorization`; + - retry сетевых ошибок ограничен методом `GET`; + - `401 Unauthorized` удалён из `isRetryable`; + - при достижении лимита fallback-пагинации возвращается явная ошибка. + - `GetInstanceState` и `GetInstanceStateRaw` переведены на общий helper `getInstanceStateWithRetry` с единым retry/HTTP-поведением. +- `scripts/check_cloud_instances.py`: + - добавлен `REQUEST_TIMEOUT = 30` и применён ко всем `session.get(...)`; + - проверка failed-операции изменена на `is False`. +- `gateway/server.js`: + - удалено поле `upstream` из публичного ответа `GET /`. +- `TOOLS/resource-generator/internal/helpers/helpers.go`: + - `FormatSourceOrWarn` переведён на fail-fast: возвращает ошибку при сбое `gofmt`. +- `TOOLS/resource-generator/internal/writers/writers.go`: + - все вызовы форматирования обрабатывают ошибку и прерывают генерацию. +- `TOOLS/resource-generator/main.go`: + - убраны `panic` на первом сбое записи ресурса; + - добавлена агрегация ошибок генерации с отчётом по каждому ресурсу; + - завершение с `exit code=1` и человекочитаемым сообщением в stderr. +- `TOOLS/resource-generator/internal/templates/instance.go`: + - обновлён marker генерации на `Code generated by TOOLS/resource-generator`. +- `TOOLS/resource-generator/internal/templates/subresource.go`: + - обновлён marker генерации на `Code generated by TOOLS/resource-generator`. +- `TOOLS/resource-generator/internal/templates/action.go`: + - обновлён marker генерации на `Code generated by TOOLS/resource-generator`. +- `provider/internal/resources_gen/registry.go`: + - обновлён marker генерации на `Code generated by TOOLS/resource-generator`. +- `scripts/s3_notification_example.sh`: + - убрана передача секрета в аргументах процесса; + - для `mc` используется временный `--config-dir` и переменная `MC_HOST_`. +- `provider/internal/core/client.go`: + - debug log path переведён на `os.TempDir()`; + - добавлен override директории через `NUBES_DEBUG_DIR`. diff --git a/TOOLS/config/dev/profile.env b/TOOLS/config/dev/profile.env index 899fca2..867d4b3 100644 --- a/TOOLS/config/dev/profile.env +++ b/TOOLS/config/dev/profile.env @@ -4,7 +4,7 @@ TOKEN_FILE="secrets/dev.token" # Release versions # Version -VERSION="3.0.7" +VERSION="3.0.8" NAMESPACE="nubes-dev" PROVIDER_NAME="nubes" diff --git a/TOOLS/config/prod/profile.env b/TOOLS/config/prod/profile.env index 7f73b7b..6a2376f 100644 --- a/TOOLS/config/prod/profile.env +++ b/TOOLS/config/prod/profile.env @@ -4,7 +4,7 @@ TOKEN_FILE="secrets/prod.token" # Release versions # Version -VERSION="2.0.7" +VERSION="2.0.8" NAMESPACE="nubes" PROVIDER_NAME="nubes" diff --git a/TOOLS/config/test/profile.env b/TOOLS/config/test/profile.env index 415c94b..8b43cd5 100644 --- a/TOOLS/config/test/profile.env +++ b/TOOLS/config/test/profile.env @@ -3,7 +3,7 @@ NUBES_API_ENDPOINT="https://lk-api-gateway-test.ngcloud.ru/api/v1/svc" TOKEN_FILE="secrets/test.token" # Version -VERSION="5.0.7" +VERSION="5.0.8" NAMESPACE="nubes-test" PROVIDER_NAME="nubes" diff --git a/TOOLS/resource-generator/internal/helpers/helpers.go b/TOOLS/resource-generator/internal/helpers/helpers.go index 52145d6..9dd247b 100644 --- a/TOOLS/resource-generator/internal/helpers/helpers.go +++ b/TOOLS/resource-generator/internal/helpers/helpers.go @@ -4,7 +4,6 @@ package helpers import ( "fmt" "go/format" - "os" "strings" "unicode" @@ -168,14 +167,13 @@ func EnsureGoIdent(s string) string { return "R" + s } -// FormatSourceOrWarn форматирует Go-код. При ошибке пишет warning в stderr. -func FormatSourceOrWarn(filePath string, src []byte) []byte { +// FormatSourceOrWarn форматирует Go-код и возвращает ошибку при сбое gofmt. +func FormatSourceOrWarn(filePath string, src []byte) ([]byte, error) { formatted, err := format.Source(src) if err != nil { - fmt.Fprintf(os.Stderr, "WARNING: gofmt failed for %s: %v — writing unformatted code\n", filePath, err) - return src + return nil, fmt.Errorf("gofmt failed for %s: %w", filePath, err) } - return formatted + return formatted, nil } // PlanModifierType возвращает суффикс типа план-модификатора (Bool/Int64/String). diff --git a/TOOLS/resource-generator/internal/templates/action.go b/TOOLS/resource-generator/internal/templates/action.go index 5e099ff..4c1eede 100644 --- a/TOOLS/resource-generator/internal/templates/action.go +++ b/TOOLS/resource-generator/internal/templates/action.go @@ -24,7 +24,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -// Code generated by tools/gen_v2. DO NOT EDIT. +// Code generated by TOOLS/resource-generator. DO NOT EDIT. // Service: {{.ServiceName}} // Action: {{.ActionName}} diff --git a/TOOLS/resource-generator/internal/templates/instance.go b/TOOLS/resource-generator/internal/templates/instance.go index 39f22e2..d154826 100644 --- a/TOOLS/resource-generator/internal/templates/instance.go +++ b/TOOLS/resource-generator/internal/templates/instance.go @@ -29,7 +29,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -// Code generated by tools/gen_v2. DO NOT EDIT. +// Code generated by TOOLS/resource-generator. DO NOT EDIT. // Service: {{.Name}} // Service ID: {{.ServiceID}} diff --git a/TOOLS/resource-generator/internal/templates/subresource.go b/TOOLS/resource-generator/internal/templates/subresource.go index 1f90258..9f2b101 100644 --- a/TOOLS/resource-generator/internal/templates/subresource.go +++ b/TOOLS/resource-generator/internal/templates/subresource.go @@ -29,7 +29,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -// Code generated by tools/gen_v2. DO NOT EDIT. +// Code generated by TOOLS/resource-generator. DO NOT EDIT. // Service: {{.ServiceName}} // Subresource: {{.SubName}} @@ -439,4 +439,3 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Configure(_ r.client = client } ` - diff --git a/TOOLS/resource-generator/internal/writers/writers.go b/TOOLS/resource-generator/internal/writers/writers.go index de22969..91d8683 100644 --- a/TOOLS/resource-generator/internal/writers/writers.go +++ b/TOOLS/resource-generator/internal/writers/writers.go @@ -59,7 +59,10 @@ func WriteInstanceResource(outDir string, svc types.GenResource) error { return err } - formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + formatted, err := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + if err != nil { + return err + } return os.WriteFile(filePath, formatted, 0644) } @@ -91,7 +94,10 @@ func WriteSubresource(outDir string, sr types.GenSubresource) error { return err } - formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + formatted, err := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + if err != nil { + return err + } return os.WriteFile(filePath, formatted, 0644) } @@ -121,7 +127,10 @@ func WriteActionResource(outDir string, act types.GenAction) error { return err } - formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + formatted, err := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + if err != nil { + return err + } return os.WriteFile(filePath, formatted, 0644) } @@ -131,7 +140,7 @@ func WriteRegistry(outDir string, services []types.GenResource, subs []types.Gen var buf bytes.Buffer buf.WriteString("package resources_gen\n\n") buf.WriteString("import \"github.com/hashicorp/terraform-plugin-framework/resource\"\n\n") - buf.WriteString("// Code generated by tools/gen_v2. DO NOT EDIT.\n") + buf.WriteString("// Code generated by TOOLS/resource-generator. DO NOT EDIT.\n") buf.WriteString("func AllResources() []func() resource.Resource {\n") buf.WriteString("\treturn []func() resource.Resource{\n") for _, svc := range services { @@ -147,7 +156,10 @@ func WriteRegistry(outDir string, services []types.GenResource, subs []types.Gen buf.WriteString("}\n") regPath := filepath.Join(outDir, "registry.go") - formatted := helpers.FormatSourceOrWarn(regPath, buf.Bytes()) + formatted, err := helpers.FormatSourceOrWarn(regPath, buf.Bytes()) + if err != nil { + return err + } return os.WriteFile(regPath, formatted, 0644) } diff --git a/TOOLS/resource-generator/main.go b/TOOLS/resource-generator/main.go index c500c2b..d80c21d 100644 --- a/TOOLS/resource-generator/main.go +++ b/TOOLS/resource-generator/main.go @@ -16,6 +16,8 @@ package main import ( + "errors" + "fmt" "os" "strings" @@ -23,37 +25,66 @@ import ( "resource-generator/internal/writers" ) +type serviceWriteErr struct { + kind string + name string + err error +} + func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } +} + +func run() error { resourcesDir := strings.TrimSpace(os.Getenv("NUBES_RESOURCES_DIR")) if resourcesDir == "" { - panic("NUBES_RESOURCES_DIR is required — must point to generated/{stand}/resources_yaml/") + return fmt.Errorf("NUBES_RESOURCES_DIR is required — must point to generated/{stand}/resources_yaml/") } outDir := strings.TrimSpace(os.Getenv("NUBES_RESOURCES_GEN_DIR")) if outDir == "" { - panic("NUBES_RESOURCES_GEN_DIR is required — must point to generated/{stand}/go/") + return fmt.Errorf("NUBES_RESOURCES_GEN_DIR is required — must point to generated/{stand}/go/") } instanceResources, subresources, actions, err := loader.LoadSpecs(resourcesDir) if err != nil { - panic(err) + return err } + var writeErrs []serviceWriteErr + for _, svc := range instanceResources { if err := writers.WriteInstanceResource(outDir, svc); err != nil { - panic(err) + writeErrs = append(writeErrs, serviceWriteErr{kind: "instance", name: svc.Name, err: err}) } } for _, sr := range subresources { if err := writers.WriteSubresource(outDir, sr); err != nil { - panic(err) + name := fmt.Sprintf("%s_%s", sr.ServiceName, sr.SubName) + writeErrs = append(writeErrs, serviceWriteErr{kind: "subresource", name: name, err: err}) } } for _, act := range actions { if err := writers.WriteActionResource(outDir, act); err != nil { - panic(err) + name := fmt.Sprintf("%s_%s", act.ServiceName, act.ActionName) + writeErrs = append(writeErrs, serviceWriteErr{kind: "action", name: name, err: err}) } } - if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions); err != nil { - panic(err) + + if len(writeErrs) > 0 { + var b strings.Builder + b.WriteString("generation failed for one or more resources:\n") + for _, we := range writeErrs { + b.WriteString(fmt.Sprintf("- %s %s: %v\n", we.kind, we.name, we.err)) + } + return errors.New(strings.TrimSpace(b.String())) } + + if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions); err != nil { + return err + } + + return nil } diff --git a/provider/internal/core/client.go b/provider/internal/core/client.go index 7a2fa0d..6e5c9b2 100644 --- a/provider/internal/core/client.go +++ b/provider/internal/core/client.go @@ -19,6 +19,7 @@ import ( "net/http/httputil" "net/url" "os" + "path/filepath" "regexp" "strings" "sync" @@ -90,6 +91,25 @@ var ctxKeyLogLevel = ctxKeyLogLevelType{} // Go-http-client по умолчанию блокируется фильтром ddos-guard. const userAgent = "Mozilla/5.0" +func debugLogPath(fileName string) string { + if dir := strings.TrimSpace(os.Getenv("NUBES_DEBUG_DIR")); dir != "" { + return filepath.Join(dir, fileName) + } + return filepath.Join(os.TempDir(), fileName) +} + +func isHTTPDebugEnabled() bool { + return os.Getenv("NUBES_DEBUG_HTTP") == "1" +} + +func sanitizeAuthHeader(h http.Header) http.Header { + cloned := h.Clone() + if cloned.Get("Authorization") != "" { + cloned.Set("Authorization", "Bearer [REDACTED]") + } + return cloned +} + // CtxWithLogLevel возвращает ctx с переопределённым уровнем логирования. func CtxWithLogLevel(ctx context.Context, level string) context.Context { return context.WithValue(ctx, ctxKeyLogLevel, level) @@ -621,7 +641,10 @@ type InstanceStateResponse struct { // Если найдено больше одного non-deleted инстанса — возвращает ошибку. func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, serviceId int, displayName string) (*InstanceStateResponse, error) { debug := func(format string, args ...interface{}) { - f, err := os.OpenFile("/tmp/nubes_find_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if !isHTTPDebugEnabled() { + return + } + f, err := os.OpenFile(debugLogPath("nubes_find_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return } @@ -684,6 +707,8 @@ func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, service // Если быстрый поиск не дал результатов — пагинированный fallback if len(found) == 0 { debug("[FIND-DEBUG] search path failed, entering fallback (serviceId=%d, name=%q)", serviceId, displayName) + const maxFallbackPages = 100 + hitPageLimit := false page := 1 for { reqURL := c.buildURL(fmt.Sprintf("/instances?page=%d&size=100", page)) @@ -753,10 +778,15 @@ func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, service } page++ - if page > 100 { + if page > maxFallbackPages { + hitPageLimit = true break } } + + if hitPageLimit && len(found) == 0 { + return nil, fmt.Errorf("поиск инстанса по display_name достиг лимита пагинации (%d страниц); сузьте фильтр или повторите с более точными параметрами", maxFallbackPages) + } } if len(found) == 0 { @@ -778,58 +808,14 @@ func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, service } func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - const maxRetries = 3 - baseDelay := 2 * time.Second - - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - select { - case <-time.After(baseDelay * time.Duration(1<<(attempt-1))): - case <-ctx.Done(): - return nil, ctx.Err() - } - } - - url := c.buildURL(fmt.Sprintf("/instances/%s", instanceUid)) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", userAgent) - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - lastErr = err - continue - } - defer resp.Body.Close() - - if resp.StatusCode == 401 { - lastErr = fmt.Errorf("HTTP статус %d", resp.StatusCode) - continue - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode) - } - - var res struct { - Instance InstanceStateResponse `json:"instance"` - } - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - if err := validateInstanceStatus(&res.Instance); err != nil { - return nil, err - } - - return &res.Instance, nil + state, err := c.getInstanceStateWithRetry(ctx, instanceUid) + if err != nil { + return nil, err } - return nil, fmt.Errorf("GetInstanceState failed after %d retries: %w", maxRetries, lastErr) + if err := validateInstanceStatus(state); err != nil { + return nil, err + } + return state, nil } func isInstanceDeleted(state *InstanceStateResponse) bool { @@ -845,54 +831,23 @@ func isInstanceDeleted(state *InstanceStateResponse) bool { // GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса. // Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы. func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - const maxRetries = 3 - baseDelay := 2 * time.Second + return c.getInstanceStateWithRetry(ctx, instanceUid) +} - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - select { - case <-time.After(baseDelay * time.Duration(1<<(attempt-1))): - case <-ctx.Done(): - return nil, ctx.Err() - } - } - - reqURL := c.buildURL(fmt.Sprintf("/instances/%s", instanceUid)) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", userAgent) - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - lastErr = err - continue - } - defer resp.Body.Close() - - if resp.StatusCode == 401 { - lastErr = fmt.Errorf("HTTP статус %d", resp.StatusCode) - continue - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode) - } - - var res struct { - Instance InstanceStateResponse `json:"instance"` - } - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res.Instance, nil +func (c *UniversalClient) getInstanceStateWithRetry(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { + respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) + if err != nil { + return nil, err } - return nil, fmt.Errorf("GetInstanceStateRaw failed after %d retries: %w", maxRetries, lastErr) + + var res struct { + Instance InstanceStateResponse `json:"instance"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return nil, err + } + + return &res.Instance, nil } // ===== UNIVERSAL OPERATION WAIT (APPEND-ONLY) ===== @@ -1096,14 +1051,16 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa } // DEBUG - if os.Getenv("NUBES_DEBUG_HTTP") == "1" { - dump, _ := httputil.DumpRequestOut(req, body != nil) + if isHTTPDebugEnabled() { + reqForDump := req.Clone(req.Context()) + reqForDump.Header = sanitizeAuthHeader(req.Header) + dump, _ := httputil.DumpRequestOut(reqForDump, body != nil) fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump) } // DEBUG в файл - if os.Getenv("NUBES_DEBUG_HTTP") == "1" { - f, _ := os.OpenFile("/tmp/nubes_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if isHTTPDebugEnabled() { + f, _ := os.OpenFile(debugLogPath("nubes_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if f != nil { fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String()) f.Close() @@ -1112,8 +1069,8 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa resp, err := c.HttpClient.Do(req) if err != nil { lastErr = err - // Сетевые ошибки — retry (кроме последней попытки) - if attempt < maxRetries { + // Сетевые ошибки: retry только для идемпотентного GET. + if attempt < maxRetries && method == "GET" { continue } return nil, nil, err @@ -1149,8 +1106,7 @@ func isRetryable(statusCode int) bool { return statusCode == http.StatusTooManyRequests || // 429 statusCode == http.StatusServiceUnavailable || // 503 statusCode == http.StatusBadGateway || // 502 - statusCode == http.StatusGatewayTimeout || // 504 - statusCode == http.StatusUnauthorized // 401 — Gateway иногда отбрасывает валидный JWT + statusCode == http.StatusGatewayTimeout // 504 } func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) { @@ -1197,7 +1153,7 @@ func extractUIDFromLocation(loc string) string { // Однако в state UUID должен сохраняться в том регистре, который написал пользователь // (иначе plan != state → "Provider produced inconsistent result after apply"). // Восстановление регистра делается в Create/Read/Update generated resource, см. шаблон -// instanceTemplate в tools/gen_v2/generate_resources_v2.go (блоки "Restore user-provided casing"). +// instanceTemplate в TOOLS/resource-generator/internal/templates/instance.go (блоки "Restore user-provided casing"). func (c *UniversalClient) resolveRefSvcParamValues(ctx context.Context, opParams []universalCfsParam, params map[int]string) (map[int]string, error) { if len(params) == 0 || len(opParams) == 0 { diff --git a/scripts/check_cloud_instances.py b/scripts/check_cloud_instances.py index 867ab37..9f8d726 100644 --- a/scripts/check_cloud_instances.py +++ b/scripts/check_cloud_instances.py @@ -52,6 +52,7 @@ class CloudInstanceChecker: # ⛔ LEGACY: deck-api ЗАКРЫВАЕТСЯ. Использовать lk-api-gateway. BASE_URL = "https://lk-api-gateway.ngcloud.ru/api/v1/svc" + REQUEST_TIMEOUT = 30 def __init__(self, token: str): """ @@ -88,7 +89,11 @@ class CloudInstanceChecker: "pageSize": "100" } - response = self.session.get(f"{self.BASE_URL}/instances", params=params) + response = self.session.get( + f"{self.BASE_URL}/instances", + params=params, + timeout=self.REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() @@ -120,7 +125,11 @@ class CloudInstanceChecker: "fields": "instanceConfigDtCreated,instanceUid,displayName,descr,svc,state,operations,availableOperations,uptime,isDeleted,updaterLogin,updaterShortname,explainedStatus,man,dependencies,dependentInstances,svcExtendedName" } - response = self.session.get(f"{self.BASE_URL}/instances/{instance_uid}", params=params) + response = self.session.get( + f"{self.BASE_URL}/instances/{instance_uid}", + params=params, + timeout=self.REQUEST_TIMEOUT, + ) response.raise_for_status() return response.json().get("instance") @@ -184,7 +193,7 @@ class CloudInstanceChecker: operations = instance_data.get("operations", []) if operations: last_op = operations[-1] - if last_op.get("isSuccessful") == False: + if last_op.get("isSuccessful") is False: return InstanceState.OPERATION_FAILED return InstanceState.UNKNOWN diff --git a/scripts/s3_notification_example.sh b/scripts/s3_notification_example.sh index 96cf0e5..562aa7e 100644 --- a/scripts/s3_notification_example.sh +++ b/scripts/s3_notification_example.sh @@ -63,10 +63,18 @@ if [[ -z "$ACCESS_KEY" || -z "$SECRET_KEY" || -z "$HOST_BASE" ]]; then fi mc alias rm "$ALIAS_NAME" >/dev/null 2>&1 || true -mc alias set "$ALIAS_NAME" "https://$HOST_BASE" "$ACCESS_KEY" "$SECRET_KEY" >/dev/null + +MC_CONFIG_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$MC_CONFIG_DIR" +} +trap cleanup EXIT + +export MC_HOST_${ALIAS_NAME}="https://${ACCESS_KEY}:${SECRET_KEY}@${HOST_BASE}" +mc --config-dir "$MC_CONFIG_DIR" alias ls >/dev/null echo "[1/3] Текущие notification-правила в бакете $BUCKET:" -mc event ls "$ALIAS_NAME/$BUCKET" || true +mc --config-dir "$MC_CONFIG_DIR" event ls "$ALIAS_NAME/$BUCKET" || true echo "[2/3] Добавление правила: events=$EVENTS target=$TARGET_ARN" EVENT_FLAGS=() @@ -83,9 +91,9 @@ if [[ -n "$SUFFIX" ]]; then FILTER_FLAGS+=("--suffix" "$SUFFIX") fi -mc event add "$ALIAS_NAME/$BUCKET" "$TARGET_ARN" "${EVENT_FLAGS[@]}" "${FILTER_FLAGS[@]}" +mc --config-dir "$MC_CONFIG_DIR" event add "$ALIAS_NAME/$BUCKET" "$TARGET_ARN" "${EVENT_FLAGS[@]}" "${FILTER_FLAGS[@]}" echo "[3/3] Проверка после добавления:" -mc event ls "$ALIAS_NAME/$BUCKET" +mc --config-dir "$MC_CONFIG_DIR" event ls "$ALIAS_NAME/$BUCKET" echo "OK: правило уведомлений настроено"