diff --git a/TOOLS/resource-generator/internal/templates/subresource.go b/TOOLS/resource-generator/internal/templates/subresource.go index a1d5f32..7768f36 100644 --- a/TOOLS/resource-generator/internal/templates/subresource.go +++ b/TOOLS/resource-generator/internal/templates/subresource.go @@ -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)) diff --git a/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go b/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go index e2840f5..2cfb1fc 100644 --- a/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go +++ b/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go @@ -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) { diff --git a/provider/internal/resources_core/subresource_user_password.go b/provider/internal/resources_core/subresource_user_password.go index 6afae01..8f6ae4d 100644 --- a/provider/internal/resources_core/subresource_user_password.go +++ b/provider/internal/resources_core/subresource_user_password.go @@ -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 +}