From 58c519e7128437dc81ac5e97ea87bac0b92f395c Mon Sep 17 00:00:00 2001 From: Repinoid Date: Thu, 1 Oct 2026 17:15:14 +0300 Subject: [PATCH] =?UTF-8?q?gen(subresource):=20=D0=B2=D1=8B=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20password=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE-=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B8=D1=80=D1=83=D0=B5?= =?UTF-8?q?=D0=BC=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: у части сервисов пароль пользователя генерирует платформа и кладёт его в секрет Vault РОДИТЕЛЬСКОГО инстанса. vault_secrets родителя — Computed и обновляется только при его Read, поэтому внутри одного apply после create_user пароль недоступен (Invalid index). Из-за этого в pg/outputs.tf приходилось читать vault_secrets кластера, а стенду требовались два apply. Решение: подресурс-пользователь отдаёт пароль СВОИМ выходом сразу после create_user. - types.go: GenSubresource.VaultUserPassword (признак из данных спека); - loader.go: признак = подресурс user + у сервиса есть vault-выходы + create_user принимает username и НЕ принимает password; имя сервиса нигде не проверяется; - templates/subresource.go: поле модели + Computed/Sensitive атрибут password, чтение Vault родителя в Create (GetInstanceStateDetails + GetInstanceVaultSecrets) и перенос уже полученного пароля в Update (чтобы Computed-атрибут не стал unknown); - resources_core/subresource_user_password.go: ExtractUserPassword — разбор {"":{"password":"..."}} с безопасным возвратом пустой строки; - writers: регрессионный тест «фича включена/выключена». Проверено генерацией и сборкой test-стенда: выход получили 4 подресурса (postgres_user, kafka_user, clickhouse_user, mongodb_user); mariadb_user НЕ затронут (там пароль входной); k8s_*_user и vc_org_user не затронуты (пользователь адресуется не через username). go test ./... — ok, go build — чисто. --- .../internal/loader/loader.go | 48 +++++++- .../internal/templates/subresource.go | 48 ++++++++ .../internal/types/types.go | 8 ++ .../subresource_vault_password_test.go | 106 ++++++++++++++++++ .../subresource_user_password.go | 63 +++++++++++ 5 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go create mode 100644 provider/internal/resources_core/subresource_user_password.go diff --git a/TOOLS/resource-generator/internal/loader/loader.go b/TOOLS/resource-generator/internal/loader/loader.go index 296b410..d86dacf 100644 --- a/TOOLS/resource-generator/internal/loader/loader.go +++ b/TOOLS/resource-generator/internal/loader/loader.go @@ -223,6 +223,23 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams) sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams) sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams) + + // VaultUserPassword: пароль пользователя ГЕНЕРИРУЕТ платформа — в create-параметрах + // подресурса нет кода password, — а сервис отдаёт vault-выходы. Значит пароль + // появится в Vault родителя после create_user, и подресурс обязан вернуть его сам + // (выход password), иначе потребителю придётся читать vault_secrets родителя, что + // внутри одного apply невозможно. См. TOOLS/ARCHITECTURE.md. + // + // Признак берётся из ДАННЫХ спека, без сверки с именем сервиса: поэтому mariadb + // (там пароль задаётся на входе create_user) сюда не попадает. + // + // Отдельно требуется параметр username: именно по нему провайдер находит пароль + // в секрете Vault. У сервисов, где пользователь адресуется иначе (k8s_*_user, + // vc_org_user), выход пока не генерируется. + if sr.SubName == "user" && serviceHasVaultOutputs(spec) && + hasParamCode(sr.CreateParams, "username") && !hasParamCode(sr.CreateParams, "password") { + sr.VaultUserPassword = true + } subs = append(subs, *sr) } @@ -460,8 +477,35 @@ func validateModifierOperation(i int, op *lib.OperationSpec) error { return nil } -// analyzeUseStateForUnknown определяет, нужны ли импорты boolplanmodifier/int64planmodifier: -// есть ли среди SchemaParams скалярный Optional+Computed (без Default) параметр +// serviceHasVaultOutputs сообщает, отдаёт ли сервис секреты через Vault +// (в outputs объявлены vault_secrets / vault_fields). +// +// Зачем: это признак «сервис держит секреты в Vault родителя». Используется вместе +// с отсутствием входного password у create-подресурса, чтобы включить выход password +// у подресурса-пользователя (см. VaultUserPassword в types.GenSubresource). +// Признак берётся из данных спека — имя сервиса нигде не проверяется. +func serviceHasVaultOutputs(spec types.ServiceSpec) bool { + for _, p := range spec.Outputs.Params { + switch strings.ToLower(strings.TrimSpace(p.Code)) { + case "vault_secrets", "vault_fields": + return true + } + } + return false +} + +// hasParamCode сообщает, есть ли среди параметров параметр с таким кодом +// (регистр и пробелы не важны). +func hasParamCode(params []types.Param, code string) bool { + for _, p := range params { + if strings.EqualFold(strings.TrimSpace(p.Code), code) { + return true + } + } + return false +} + +// analyzeUseStateForUnknown определяет, нужны ли импорты boolplanmodifier/int64planmodifier:// есть ли среди SchemaParams скалярный Optional+Computed (без Default) параметр // соответствующего типа, для которого генерируется UseStateForUnknown(). func analyzeUseStateForUnknown(schemaParams []types.Param) (needsBool, needsInt64 bool) { for _, p := range schemaParams { diff --git a/TOOLS/resource-generator/internal/templates/subresource.go b/TOOLS/resource-generator/internal/templates/subresource.go index c2456c2..a1d5f32 100644 --- a/TOOLS/resource-generator/internal/templates/subresource.go +++ b/TOOLS/resource-generator/internal/templates/subresource.go @@ -46,6 +46,12 @@ type {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model struct { AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` SkipMissingOnDelete types.Bool ` + "`" + `tfsdk:"skip_missing_on_delete"` + "`" + ` KeepOnDestroy types.Bool ` + "`" + `tfsdk:"keep_on_destroy"` + "`" + ` + {{- if .VaultUserPassword }} + // Password — пароль пользователя, который генерирует платформа и кладёт в секрет + // Vault родителя. Заполняется в Create сразу после create_user: потребителю не нужно + // читать vault_secrets родителя (внутри одного apply оно ещё пусто). + Password types.String ` + "`" + `tfsdk:"password"` + "`" + ` + {{- end }} OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + ` LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + ` {{- range .SchemaParams }} @@ -71,6 +77,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Schema(ctx "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, "skip_missing_on_delete": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)}, "keep_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), MarkdownDescription: "Режим state_only при destroy: подресурс не удаляется и не меняется в облаке, только убирается из состояния. Нужен там, где родительский инстанс при destroy не удаляется (например suspend_on_destroy = true) и объект должен остаться внутри него. false = обычное удаление."}, + {{- if .VaultUserPassword }} + "password": schema.StringAttribute{Computed: true, Sensitive: true, MarkdownDescription: "Пароль пользователя, который генерирует платформа. Провайдер читает его из Vault родительского инстанса сразу после создания пользователя."}, + {{- end }} "operation_timeout": schema.StringAttribute{Optional: true}, "log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."}, {{- range .SchemaParams }} @@ -235,6 +244,31 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx } } + {{- if .VaultUserPassword }} + // Пароль пользователя генерирует платформа: после create_user он появляется в секрете + // Vault родительского инстанса. Читаем его ЗДЕСЬ и кладём в свой выход, чтобы потребитель + // получил пароль в ЭТОМ же apply (vault_secrets родителя — Computed и обновляется только + // при Read родителя). См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets». + // Неудачное чтение create не роняет: поле просто останется пустым. + plan.Password = types.StringNull() + if instanceUID != "" { + if details, derr := r.client.GetInstanceStateDetails(ctx, instanceUID); derr == nil && details != nil && len(details.Vault.Fields) > 0 { + if secrets, serr := r.client.GetInstanceVaultSecrets(ctx, instanceUID, details.Vault.Fields); serr == nil { + for _, secretValue := range secrets { + pw, perr := resources_core.ExtractUserPassword(secretValue, plan.Username.ValueString()) + if perr != nil { + continue + } + if pw != "" { + plan.Password = types.StringValue(pw) + break + } + } + } + } + } + {{- end }} + plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } @@ -254,6 +288,20 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Update(ctx if resp.Diagnostics.HasError() { return } + {{- if .VaultUserPassword }} + // Пароль меняет платформа, а не пользователь: при modify его не трогаем и оставляем уже + // полученное значение из state (иначе Computed-атрибут станет null/unknown и Terraform + // ругнётся на несовместимый результат после apply). + if plan.Password.IsNull() || plan.Password.IsUnknown() { + var priorState {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model + if stateDiags := req.State.Get(ctx, &priorState); !stateDiags.HasError() { + plan.Password = priorState.Password + } + if plan.Password.IsNull() || plan.Password.IsUnknown() { + plan.Password = types.StringNull() + } + } + {{- end }} {{- if .HasRefSvcParams }} {{- range .ModifyParams }} {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} diff --git a/TOOLS/resource-generator/internal/types/types.go b/TOOLS/resource-generator/internal/types/types.go index e47a000..1f68598 100644 --- a/TOOLS/resource-generator/internal/types/types.go +++ b/TOOLS/resource-generator/internal/types/types.go @@ -136,6 +136,14 @@ type GenSubresource struct { NeedsStringPlanMod bool NeedsJsonPlanMod bool NeedsStringsImport bool + // VaultUserPassword — true, если пароль пользователя ГЕНЕРИРУЕТ платформа + // (в create-параметрах подресурса НЕТ кода password) и сервис отдаёт vault-выходы. + // Тогда подресурс получает выход password (Computed, Sensitive), читаемый из Vault + // родителя СРАЗУ после create_user. См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets». + // + // Признак вычисляется из ДАННЫХ спека (наличие vault-выходов + отсутствие входного + // password), поэтому mariadb (где пароль задаётся на входе) сюда не попадает. + VaultUserPassword bool } // GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource). diff --git a/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go b/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go new file mode 100644 index 0000000..e2840f5 --- /dev/null +++ b/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go @@ -0,0 +1,106 @@ +package writers + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "resource-generator/internal/types" +) + +// TestWriteSubresource_VaultUserPassword — регрессионный тест выхода password у +// подресурса-пользователя. +// +// Зачем: у части сервисов пароль пользователя ГЕНЕРИРУЕТ платформа и кладёт его в +// секрет Vault родительского инстанса. Подресурс обязан вернуть пароль СВОИМ выходом +// сразу после create_user — иначе потребитель не получит его в том же apply +// (vault_secrets родителя — Computed и обновляется только при Read родителя). +// См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets». +// +// Проверяем три части фичи и то, что она НЕ включается у чужих подресурсов: +// 1) поле модели Password с тэгом tfsdk:"password"; +// 2) атрибут схемы Computed + Sensitive; +// 3) чтение секрета Vault в Create и перенос пароля в Update; +// 4) при VaultUserPassword=false ничего этого нет (чтобы mariadb и подресурсы без +// секретов не получили лишний атрибут). +func TestWriteSubresource_VaultUserPassword(t *testing.T) { + base := types.GenSubresource{ + ServiceName: "postgres", + ServiceID: 90, + SubName: "user", + CreateOpName: "create_user", + DeleteOpName: "delete_user", + CreateParams: []types.Param{ + {Code: "username", Type: "string", Required: true, Descr: "Имя пользователя"}, + {Code: "role", Type: "string", Required: true, Descr: "Роль"}, + }, + SchemaParams: []types.Param{ + {Code: "username", Type: "string", Required: true, Descr: "Имя пользователя"}, + {Code: "role", Type: "string", Required: true, Descr: "Роль"}, + }, + IdentityParams: []types.Param{ + {Code: "username", Type: "string", Required: true, Descr: "Имя пользователя"}, + }, + UsesString: true, + } + + render := func(t *testing.T, sr types.GenSubresource) string { + t.Helper() + dir := t.TempDir() + if err := WriteSubresource(dir, sr); err != nil { + t.Fatalf("WriteSubresource вернул ошибку: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("не прочитал каталог генерации: %v", err) + } + if len(entries) != 1 { + t.Fatalf("ожидался один сгенерированный файл, получено %d", len(entries)) + } + raw, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatalf("не прочитал %s: %v", entries[0].Name(), err) + } + return squashSpaces(string(raw)) + } + + t.Run("фича включена: выход password есть", func(t *testing.T) { + sr := base + sr.VaultUserPassword = true + src := render(t, sr) + + if !strings.Contains(src, `Password types.String`) || !strings.Contains(src, `tfsdk:"password"`) { + t.Errorf("в модели нет поля Password с тэгом tfsdk:\"password\"") + } + + idx := strings.Index(src, `"password": schema.StringAttribute{`) + if idx < 0 { + t.Fatalf("в схеме нет атрибута password") + } + block := src[idx:] + if end := strings.Index(block, " },"); end > 0 { + block = block[:end] + } + if !strings.Contains(block, "Computed: true") || !strings.Contains(block, "Sensitive: true") { + t.Errorf("password должен быть Computed + Sensitive, блок:\n%s", block) + } + + if !strings.Contains(src, "ExtractUserPassword(") || !strings.Contains(src, "GetInstanceVaultSecrets(") { + t.Errorf("в Create нет чтения пароля из Vault родителя") + } + if !strings.Contains(src, "priorState.Password") { + t.Errorf("в Update нет сохранения уже полученного пароля") + } + }) + + t.Run("фича выключена: атрибута password нет", func(t *testing.T) { + src := render(t, base) + if strings.Contains(src, `tfsdk:"password"`) { + t.Errorf("атрибут password не должен появляться без VaultUserPassword") + } + if strings.Contains(src, "ExtractUserPassword(") { + t.Errorf("чтение Vault не должно появляться без VaultUserPassword") + } + }) +} diff --git a/provider/internal/resources_core/subresource_user_password.go b/provider/internal/resources_core/subresource_user_password.go new file mode 100644 index 0000000..6afae01 --- /dev/null +++ b/provider/internal/resources_core/subresource_user_password.go @@ -0,0 +1,63 @@ +package resources_core + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ExtractUserPassword вытаскивает пароль пользователя из JSON-значения секрета Vault +// родительского инстанса. +// +// ЗАЧЕМ: у части сервисов (проверено на postgres) платформа САМА генерирует пароль +// в операции create_user и кладёт его в секрет Vault родителя. Формат значения +// (проверено через GET /instances//vault/users): +// +// {"": {"password": "<пароль>"}} +// +// Подресурс обязан вернуть этот пароль СВОИМ выходом сразу после create_user, иначе +// потребитель не сможет получить его в том же apply (vault_secrets родителя — Computed +// и обновляется только при Read родителя). См. TOOLS/ARCHITECTURE.md, +// раздел «Subresource-born secrets». +// +// Возвращает "" без ошибки, если пользователя в секрете нет или пароль отсутствует: +// это не фатально (пароль остаётся неизвестным до следующего чтения) и не должно +// ломать create. +func ExtractUserPassword(secretJSON string, username string) (string, error) { + raw := strings.TrimSpace(secretJSON) + if raw == "" { + return "", nil + } + name := strings.TrimSpace(username) + if name == "" { + return "", fmt.Errorf("пустое имя пользователя для поиска пароля в Vault") + } + + // Основной формат: {"": {"password": "..."}}. + var byUser map[string]struct { + Password string `json:"password"` + } + if err := json.Unmarshal([]byte(raw), &byUser); err == nil { + if entry, ok := byUser[name]; ok && strings.TrimSpace(entry.Password) != "" { + return strings.TrimSpace(entry.Password), nil + } + // Регистр имени может отличаться (платформа нормализует его сама). + for key, entry := range byUser { + if strings.EqualFold(strings.TrimSpace(key), name) && strings.TrimSpace(entry.Password) != "" { + return strings.TrimSpace(entry.Password), nil + } + } + } + + // Упрощённый формат: {"password": "..."} — единственный пользователь в секрете. + var flat struct { + Password string `json:"password"` + } + if err := json.Unmarshal([]byte(raw), &flat); err == nil { + if strings.TrimSpace(flat.Password) != "" { + return strings.TrimSpace(flat.Password), nil + } + } + + return "", nil +}