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))
@@ -86,12 +86,35 @@ func TestWriteSubresource_VaultUserPassword(t *testing.T) {
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, "ResolveUserPasswordFromVault(") {
t.Errorf("нет чтения пароля из Vault родителя (ResolveUserPasswordFromVault)")
}
if !strings.Contains(src, "priorState.Password") {
t.Errorf("в Update нет сохранения уже полученного пароля")
}
// КРИТИЧНО: password — Computed-атрибут. Если хотя бы одна ветка выхода из
// Create сохранит состояние, не проставив его, Terraform упадёт с
// "Provider returned invalid result object after apply ... unknown value".
// Поэтому: инициализация + вызов перед каждым сохранением состояния.
if !strings.Contains(src, "plan.Password = types.StringNull()") {
t.Errorf("password не инициализируется конкретным значением до ветвлений")
}
createBody := src
if idx := strings.Index(src, ") Create("); idx > 0 {
createBody = src[idx:]
}
if end := strings.Index(createBody, ") Read("); end > 0 {
createBody = createBody[:end]
}
setCount := strings.Count(createBody, "resp.State.Set(ctx, &plan)")
resolveCount := strings.Count(createBody, "ResolveUserPasswordFromVault(")
if setCount == 0 {
t.Fatalf("в Create не найдено сохранение состояния")
}
if resolveCount < setCount {
t.Errorf("в Create %d сохранений состояния, но только %d вызовов заполнения пароля — в какой-то ветке password останется unknown", setCount, resolveCount)
}
})
t.Run("фича выключена: атрибута password нет", func(t *testing.T) {
@@ -1,9 +1,14 @@
package resources_core
import (
"context"
"encoding/json"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// ExtractUserPassword вытаскивает пароль пользователя из JSON-значения секрета Vault
@@ -61,3 +66,39 @@ func ExtractUserPassword(secretJSON string, username string) (string, error) {
return "", nil
}
// ResolveUserPasswordFromVault читает пароль пользователя из секретов Vault
// родительского инстанса и возвращает готовое значение для атрибута password.
//
// Зачем функция, а не код в шаблоне: подресурс сохраняет состояние в НЕСКОЛЬКИХ
// местах (обычный create, усыновление уже существующего объекта, подтверждение по
// state_out). Если хотя бы в одной ветке оставить password неизвестным, Terraform
// падает с «Provider returned invalid result object after apply: ... still indicated
// an unknown value». Поэтому все ветки обязаны проставлять КОНКРЕТНОЕ значение —
// через эту функцию.
//
// Поведение:
// - удалось прочитать пароль → конкретное значение;
// - не удалось → возвращается current, если оно уже известно (например, из state),
// иначе типизированный null (null тоже «known» для Terraform).
func ResolveUserPasswordFromVault(ctx context.Context, client *core.UniversalClient, instanceUID string, username string, current types.String) types.String {
if client != nil && strings.TrimSpace(instanceUID) != "" {
if details, err := client.GetInstanceStateDetails(ctx, instanceUID); err == nil && details != nil && len(details.Vault.Fields) > 0 {
if secrets, serr := client.GetInstanceVaultSecrets(ctx, instanceUID, details.Vault.Fields); serr == nil {
for _, secretValue := range secrets {
pw, perr := ExtractUserPassword(secretValue, username)
if perr != nil {
continue
}
if pw != "" {
return types.StringValue(pw)
}
}
}
}
}
if current.IsNull() || current.IsUnknown() {
return types.StringNull()
}
return current
}