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:
@@ -134,6 +134,12 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
adoptExistingOnCreate := !plan.AdoptExistingOnCreate.IsNull() && !plan.AdoptExistingOnCreate.IsUnknown() && plan.AdoptExistingOnCreate.ValueBool()
|
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{
|
idParams := map[string]string{
|
||||||
{{- range .IdentityParams }}
|
{{- range .IdentityParams }}
|
||||||
@@ -168,6 +174,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
|
|||||||
if known && found {
|
if known && found {
|
||||||
if adoptExistingOnCreate {
|
if adoptExistingOnCreate {
|
||||||
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
|
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.AddWarning("Подресурс уже существует", "Объект уже есть, выполняется усыновление: "+targetValue)
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
return
|
return
|
||||||
@@ -207,6 +216,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
|
|||||||
}
|
}
|
||||||
if known && found {
|
if known && found {
|
||||||
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
|
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.AddWarning("Подресурс подтверждён в state_out", "Операция create вернула ошибку, но объект найден в state_out и принят в state: "+targetValue)
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
return
|
return
|
||||||
@@ -221,6 +233,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
|
|||||||
}
|
}
|
||||||
if found || !known {
|
if found || !known {
|
||||||
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
|
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.AddWarning("Подресурс уже существует", "Операция вернула duplicate/exist, объект принят в state")
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
return
|
return
|
||||||
@@ -245,28 +260,11 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
|
|||||||
}
|
}
|
||||||
|
|
||||||
{{- if .VaultUserPassword }}
|
{{- if .VaultUserPassword }}
|
||||||
// Пароль пользователя генерирует платформа: после create_user он появляется в секрете
|
// Пароль пользователя генерирует платформа: он лежит в секрете Vault родительского
|
||||||
// Vault родительского инстанса. Читаем его ЗДЕСЬ и кладём в свой выход, чтобы потребитель
|
// инстанса. Читаем его ЗДЕСЬ и кладём в свой выход, чтобы потребитель получил пароль
|
||||||
// получил пароль в ЭТОМ же apply (vault_secrets родителя — Computed и обновляется только
|
// в этом же apply. См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets».
|
||||||
// при Read родителя). См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets».
|
// Неудачное чтение create не роняет: значение останется null (known для Terraform).
|
||||||
// Неудачное чтение create не роняет: поле просто останется пустым.
|
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password)
|
||||||
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 }}
|
{{- end }}
|
||||||
|
|
||||||
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
|
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)
|
t.Errorf("password должен быть Computed + Sensitive, блок:\n%s", block)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(src, "ExtractUserPassword(") || !strings.Contains(src, "GetInstanceVaultSecrets(") {
|
if !strings.Contains(src, "ResolveUserPasswordFromVault(") {
|
||||||
t.Errorf("в Create нет чтения пароля из Vault родителя")
|
t.Errorf("нет чтения пароля из Vault родителя (ResolveUserPasswordFromVault)")
|
||||||
}
|
}
|
||||||
if !strings.Contains(src, "priorState.Password") {
|
if !strings.Contains(src, "priorState.Password") {
|
||||||
t.Errorf("в Update нет сохранения уже полученного пароля")
|
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) {
|
t.Run("фича выключена: атрибута password нет", func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package resources_core
|
package resources_core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"terraform-provider-nubes/internal/core"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtractUserPassword вытаскивает пароль пользователя из JSON-значения секрета Vault
|
// ExtractUserPassword вытаскивает пароль пользователя из JSON-значения секрета Vault
|
||||||
@@ -61,3 +66,39 @@ func ExtractUserPassword(secretJSON string, username string) (string, error) {
|
|||||||
|
|
||||||
return "", nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user