fix(subresource): password заполняется во ВСЕХ ветках Create, а не только в успешной

Баг (воспроизведён на TEST 2026-10-01): при усыновлении уже существующего
пользователя БД Create выходит по раннему return (ветка 'Подресурс уже существует'
-> State.Set -> return), оставляя Computed-атрибут password в состоянии unknown.
Terraform отказывался: 'Provider returned invalid result object after apply: the
provider still indicated an unknown value for nubes_postgres_user.crud_user_0.password'.

Исправление:
- новая функция resources_core.ResolveUserPasswordFromVault(ctx, client, instanceUID,
  username, current): читает пароль из Vault родителя, иначе возвращает current,
  иначе типизированный null (null для Terraform — конкретное, known значение);
- в шаблоне подресурса password инициализируется null сразу после получения
  instanceUID, а перед КАЖДЫМ resp.State.Set в Create проставляется конкретным
  значением — включая все ветки усыновления;
- регрессионный тест усилен: считает сохранения состояния и вызовы заполнения в
  Create и падает, если хоть в одной ветке password останется unknown.

Проверено: generated/test/go/90_postgres_user_resource.go — 4 State.Set и 4
вызова заполнения; go test ./... ok; go build — чисто.
This commit is contained in:
Repinoid
2026-10-01 19:26:34 +03:00
parent 2c6998975f
commit 64328abe55
3 changed files with 86 additions and 24 deletions
@@ -134,6 +134,12 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
return
}
adoptExistingOnCreate := !plan.AdoptExistingOnCreate.IsNull() && !plan.AdoptExistingOnCreate.IsUnknown() && plan.AdoptExistingOnCreate.ValueBool()
{{- if .VaultUserPassword }}
// password объявлен Computed: в плане он unknown, а Terraform требует КОНКРЕТНОЕ
// значение после apply. Каждая ветка выхода ниже (включая усыновление уже
// существующего объекта) обязана проставить его — см. ResolveUserPasswordFromVault.
plan.Password = types.StringNull()
{{- end }}
idParams := map[string]string{
{{- range .IdentityParams }}
@@ -168,6 +174,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
if known && found {
if adoptExistingOnCreate {
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
{{- if .VaultUserPassword }}
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password)
{{- end }}
resp.Diagnostics.AddWarning("Подресурс уже существует", "Объект уже есть, выполняется усыновление: "+targetValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
return
@@ -207,6 +216,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
}
if known && found {
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
{{- if .VaultUserPassword }}
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password)
{{- end }}
resp.Diagnostics.AddWarning("Подресурс подтверждён в state_out", "Операция create вернула ошибку, но объект найден в state_out и принят в state: "+targetValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
return
@@ -221,6 +233,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
}
if found || !known {
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
{{- if .VaultUserPassword }}
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password)
{{- end }}
resp.Diagnostics.AddWarning("Подресурс уже существует", "Операция вернула duplicate/exist, объект принят в state")
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
return
@@ -245,28 +260,11 @@ 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
}
}
}
}
}
// Пароль пользователя генерирует платформа: он лежит в секрете Vault родительского
// инстанса. Читаем его ЗДЕСЬ и кладём в свой выход, чтобы потребитель получил пароль
// в этом же apply. См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets».
// Неудачное чтение create не роняет: значение останется null (known для Terraform).
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password)
{{- end }}
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))