diff --git a/TOOLS/resource-generator/internal/templates/subresource.go b/TOOLS/resource-generator/internal/templates/subresource.go index 7768f36..3d759c7 100644 --- a/TOOLS/resource-generator/internal/templates/subresource.go +++ b/TOOLS/resource-generator/internal/templates/subresource.go @@ -135,10 +135,20 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx } adoptExistingOnCreate := !plan.AdoptExistingOnCreate.IsNull() && !plan.AdoptExistingOnCreate.IsUnknown() && plan.AdoptExistingOnCreate.ValueBool() {{- if .VaultUserPassword }} - // password объявлен Computed: в плане он unknown, а Terraform требует КОНКРЕТНОЕ - // значение после apply. Каждая ветка выхода ниже (включая усыновление уже - // существующего объекта) обязана проставить его — см. ResolveUserPasswordFromVault. + // password — Computed: в плане он unknown, а Terraform требует КОНКРЕТНОЕ значение после + // apply, иначе «Provider returned invalid result object after apply». Поэтому: + // 1) сразу задаём конкретный null — покрывает ветки, где читать нечего; + // 2) applyPassword() обязана вызываться перед КАЖДЫМ сохранением состояния в Create + // (обычный путь и все ветки усыновления) — она подставляет реальный пароль. + // Замыкание одно, чтобы новая ветка выхода не осталась без заполнения. plan.Password = types.StringNull() + applyPassword := func() { + var warn string + plan.Password, warn = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password) + if warn != "" { + resp.Diagnostics.AddWarning("Пароль пользователя не прочитан из Vault", warn) + } + } {{- end }} idParams := map[string]string{ @@ -175,7 +185,7 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx 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) + applyPassword() {{- end }} resp.Diagnostics.AddWarning("Подресурс уже существует", "Объект уже есть, выполняется усыновление: "+targetValue) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) @@ -217,7 +227,7 @@ 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) + applyPassword() {{- end }} resp.Diagnostics.AddWarning("Подресурс подтверждён в state_out", "Операция create вернула ошибку, но объект найден в state_out и принят в state: "+targetValue) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) @@ -234,7 +244,7 @@ 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) + applyPassword() {{- end }} resp.Diagnostics.AddWarning("Подресурс уже существует", "Операция вернула duplicate/exist, объект принят в state") resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) @@ -264,7 +274,7 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx // инстанса. Читаем его ЗДЕСЬ и кладём в свой выход, чтобы потребитель получил пароль // в этом же 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) + applyPassword() {{- 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 2cfb1fc..9f066fe 100644 --- a/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go +++ b/TOOLS/resource-generator/internal/writers/subresource_vault_password_test.go @@ -89,6 +89,12 @@ func TestWriteSubresource_VaultUserPassword(t *testing.T) { if !strings.Contains(src, "ResolveUserPasswordFromVault(") { t.Errorf("нет чтения пароля из Vault родителя (ResolveUserPasswordFromVault)") } + if !strings.Contains(src, "applyPassword := func()") { + t.Errorf("нет единого замыкания applyPassword") + } + if !strings.Contains(src, "Пароль пользователя не прочитан из Vault") { + t.Errorf("нет предупреждения при неудачном чтении пароля — ошибка останется невидимой") + } if !strings.Contains(src, "priorState.Password") { t.Errorf("в Update нет сохранения уже полученного пароля") } @@ -96,7 +102,7 @@ func TestWriteSubresource_VaultUserPassword(t *testing.T) { // КРИТИЧНО: password — Computed-атрибут. Если хотя бы одна ветка выхода из // Create сохранит состояние, не проставив его, Terraform упадёт с // "Provider returned invalid result object after apply ... unknown value". - // Поэтому: инициализация + вызов перед каждым сохранением состояния. + // Поэтому: инициализация + вызов заполнения перед каждым сохранением состояния. if !strings.Contains(src, "plan.Password = types.StringNull()") { t.Errorf("password не инициализируется конкретным значением до ветвлений") } @@ -108,12 +114,12 @@ func TestWriteSubresource_VaultUserPassword(t *testing.T) { createBody = createBody[:end] } setCount := strings.Count(createBody, "resp.State.Set(ctx, &plan)") - resolveCount := strings.Count(createBody, "ResolveUserPasswordFromVault(") + callCount := strings.Count(createBody, "applyPassword()") if setCount == 0 { t.Fatalf("в Create не найдено сохранение состояния") } - if resolveCount < setCount { - t.Errorf("в Create %d сохранений состояния, но только %d вызовов заполнения пароля — в какой-то ветке password останется unknown", setCount, resolveCount) + if callCount < setCount { + t.Errorf("в Create %d сохранений состояния, но только %d вызовов applyPassword() — в какой-то ветке password останется unknown", setCount, callCount) } }) diff --git a/provider/internal/resources_core/subresource_user_password.go b/provider/internal/resources_core/subresource_user_password.go index 8f6ae4d..5ee0c5a 100644 --- a/provider/internal/resources_core/subresource_user_password.go +++ b/provider/internal/resources_core/subresource_user_password.go @@ -81,24 +81,47 @@ func ExtractUserPassword(secretJSON string, username string) (string, error) { // - удалось прочитать пароль → конкретное значение; // - не удалось → возвращается 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) - } - } - } +// +// Второй результат — текст предупреждения: пустая строка, если всё хорошо либо у +// родителя вообще нет секретов в Vault (для части сервисов это норма). Почему не +// просто тихо вернуть null: иначе "пароль не прочитан" выглядел бы в apply как +// успешный пустой пароль, и причина (недоступен API/Vault, нет имени, нет записи) +// осталась бы невидимой. +func ResolveUserPasswordFromVault(ctx context.Context, client *core.UniversalClient, instanceUID string, username string, current types.String) (types.String, string) { + fallback := func() types.String { + if current.IsNull() || current.IsUnknown() { + return types.StringNull() + } + return current + } + + if client == nil || strings.TrimSpace(instanceUID) == "" { + return fallback(), "" + } + + details, err := client.GetInstanceStateDetails(ctx, instanceUID) + if err != nil { + return fallback(), fmt.Sprintf("не удалось прочитать состояние родительского инстанса %s: %v", instanceUID, err) + } + if details == nil || len(details.Vault.Fields) == 0 { + // У родителя нет секретов Vault — не ошибка. + return fallback(), "" + } + + secrets, serr := client.GetInstanceVaultSecrets(ctx, instanceUID, details.Vault.Fields) + if serr != nil { + return fallback(), fmt.Sprintf("не удалось прочитать секреты Vault %v у инстанса %s: %v", details.Vault.Fields, instanceUID, serr) + } + + 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 + + return fallback(), fmt.Sprintf("пароль пользователя %q не найден в секретах Vault %v (инстанс %s)", username, details.Vault.Fields, instanceUID) }