fix(subresource): диагностика чтения пароля + одна точка заполнения в Create

По итогам код-ревью (коммит 64328ab):
- ResolveUserPasswordFromVault теперь возвращает и текст предупреждения: если пароль
  прочитать не удалось (недоступен API/Vault, пустое имя, нет записи), в выводе apply
  появляется Warning. Раньше поле молча становилось null и причина была невидима.
  Отсутствие секретов у родителя ошибкой не считается (для части сервисов это норма).
- В шаблоне подресурса заполнение вынесено в одно замыкание applyPassword(),
  вызываемое перед каждым resp.State.Set в Create (4 сохранения — 4 вызова).
  Убирает четыре одинаковые строки и снижает риск забыть новую ветку выхода.
- Тест проверяет: замыкание есть, предупреждение есть, и вызовов applyPassword()
  не меньше, чем сохранений состояния в Create.

Проверено: generated/test/go/90_postgres_user_resource.go — 4 State.Set, 4 вызова
(плюс одно упоминание в комментарии); go test ./... ok; go build ./internal/... и
полная сборка провайдера во временной копии с новым generated — чисто.
This commit is contained in:
Repinoid
2026-10-01 19:31:35 +03:00
parent 64328abe55
commit 93784e44a7
3 changed files with 68 additions and 29 deletions
@@ -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() adoptExistingOnCreate := !plan.AdoptExistingOnCreate.IsNull() && !plan.AdoptExistingOnCreate.IsUnknown() && plan.AdoptExistingOnCreate.ValueBool()
{{- if .VaultUserPassword }} {{- if .VaultUserPassword }}
// password объявлен Computed: в плане он unknown, а Terraform требует КОНКРЕТНОЕ // password — Computed: в плане он unknown, а Terraform требует КОНКРЕТНОЕ значение после
// значение после apply. Каждая ветка выхода ниже (включая усыновление уже // apply, иначе «Provider returned invalid result object after apply». Поэтому:
// существующего объекта) обязана проставить его — см. ResolveUserPasswordFromVault. // 1) сразу задаём конкретный null — покрывает ветки, где читать нечего;
// 2) applyPassword() обязана вызываться перед КАЖДЫМ сохранением состояния в Create
// (обычный путь и все ветки усыновления) — она подставляет реальный пароль.
// Замыкание одно, чтобы новая ветка выхода не осталась без заполнения.
plan.Password = types.StringNull() 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 }} {{- end }}
idParams := map[string]string{ idParams := map[string]string{
@@ -175,7 +185,7 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
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 }} {{- if .VaultUserPassword }}
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password) applyPassword()
{{- end }} {{- 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)...)
@@ -217,7 +227,7 @@ 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 }} {{- if .VaultUserPassword }}
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password) applyPassword()
{{- end }} {{- 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)...)
@@ -234,7 +244,7 @@ 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 }} {{- if .VaultUserPassword }}
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password) applyPassword()
{{- end }} {{- 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)...)
@@ -264,7 +274,7 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx
// инстанса. Читаем его ЗДЕСЬ и кладём в свой выход, чтобы потребитель получил пароль // инстанса. Читаем его ЗДЕСЬ и кладём в свой выход, чтобы потребитель получил пароль
// в этом же apply. См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets». // в этом же apply. См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets».
// Неудачное чтение create не роняет: значение останется null (known для Terraform). // Неудачное чтение create не роняет: значение останется null (known для Terraform).
plan.Password = resources_core.ResolveUserPasswordFromVault(ctx, r.client, instanceUID, plan.Username.ValueString(), plan.Password) applyPassword()
{{- end }} {{- end }}
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
@@ -89,6 +89,12 @@ func TestWriteSubresource_VaultUserPassword(t *testing.T) {
if !strings.Contains(src, "ResolveUserPasswordFromVault(") { if !strings.Contains(src, "ResolveUserPasswordFromVault(") {
t.Errorf("нет чтения пароля из Vault родителя (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") { if !strings.Contains(src, "priorState.Password") {
t.Errorf("в Update нет сохранения уже полученного пароля") t.Errorf("в Update нет сохранения уже полученного пароля")
} }
@@ -96,7 +102,7 @@ func TestWriteSubresource_VaultUserPassword(t *testing.T) {
// КРИТИЧНО: password — Computed-атрибут. Если хотя бы одна ветка выхода из // КРИТИЧНО: password — Computed-атрибут. Если хотя бы одна ветка выхода из
// Create сохранит состояние, не проставив его, Terraform упадёт с // Create сохранит состояние, не проставив его, Terraform упадёт с
// "Provider returned invalid result object after apply ... unknown value". // "Provider returned invalid result object after apply ... unknown value".
// Поэтому: инициализация + вызов перед каждым сохранением состояния. // Поэтому: инициализация + вызов заполнения перед каждым сохранением состояния.
if !strings.Contains(src, "plan.Password = types.StringNull()") { if !strings.Contains(src, "plan.Password = types.StringNull()") {
t.Errorf("password не инициализируется конкретным значением до ветвлений") t.Errorf("password не инициализируется конкретным значением до ветвлений")
} }
@@ -108,12 +114,12 @@ func TestWriteSubresource_VaultUserPassword(t *testing.T) {
createBody = createBody[:end] createBody = createBody[:end]
} }
setCount := strings.Count(createBody, "resp.State.Set(ctx, &plan)") setCount := strings.Count(createBody, "resp.State.Set(ctx, &plan)")
resolveCount := strings.Count(createBody, "ResolveUserPasswordFromVault(") callCount := strings.Count(createBody, "applyPassword()")
if setCount == 0 { if setCount == 0 {
t.Fatalf("в Create не найдено сохранение состояния") t.Fatalf("в Create не найдено сохранение состояния")
} }
if resolveCount < setCount { if callCount < setCount {
t.Errorf("в Create %d сохранений состояния, но только %d вызовов заполнения пароля — в какой-то ветке password останется unknown", setCount, resolveCount) t.Errorf("в Create %d сохранений состояния, но только %d вызовов applyPassword() — в какой-то ветке password останется unknown", setCount, callCount)
} }
}) })
@@ -81,24 +81,47 @@ func ExtractUserPassword(secretJSON string, username string) (string, error) {
// - удалось прочитать пароль → конкретное значение; // - удалось прочитать пароль → конкретное значение;
// - не удалось → возвращается current, если оно уже известно (например, из state), // - не удалось → возвращается current, если оно уже известно (например, из state),
// иначе типизированный null (null тоже «known» для Terraform). // иначе типизированный 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 { // родителя вообще нет секретов в Vault (для части сервисов это норма). Почему не
if secrets, serr := client.GetInstanceVaultSecrets(ctx, instanceUID, details.Vault.Fields); serr == nil { // просто тихо вернуть null: иначе "пароль не прочитан" выглядел бы в apply как
for _, secretValue := range secrets { // успешный пустой пароль, и причина (недоступен API/Vault, нет имени, нет записи)
pw, perr := ExtractUserPassword(secretValue, username) // осталась бы невидимой.
if perr != nil { func ResolveUserPasswordFromVault(ctx context.Context, client *core.UniversalClient, instanceUID string, username string, current types.String) (types.String, string) {
continue fallback := func() types.String {
} if current.IsNull() || current.IsUnknown() {
if pw != "" { return types.StringNull()
return types.StringValue(pw) }
} 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 fallback(), fmt.Sprintf("пароль пользователя %q не найден в секретах Vault %v (инстанс %s)", username, details.Vault.Fields, instanceUID)
}
return current
} }