gen(subresource): выход password для авто-генерируемого пароля пользователя

Проблема: у части сервисов пароль пользователя генерирует платформа и кладёт его в
секрет 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 — разбор
  {"<username>":{"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 — чисто.
This commit is contained in:
Repinoid
2026-10-01 17:15:14 +03:00
parent 1a049efa44
commit 58c519e712
5 changed files with 271 additions and 2 deletions
@@ -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") }}