gen(subresource): выход password для авто-генерируемого пароля пользователя

Проблема: у части сервисов пароль пользователя генерирует платформа и кладёт его в
секрет Vault РОДИТЕЛЬСКОГО инстанса. vault_secrets родителя — Computed и обновляется
только при его Read, поэтому внутри одного apply после create_user пароль недоступен
(Invalid index). Из-за этого в pg/outputs.tf приходилось читать vault_secrets кластера,
а стенду требовались два apply.

Решение: подресурс-пользователь отдаёт пароль СВОИМ выходом сразу после create_user.
- types.go: GenSubresource.VaultUserPassword (признак из данных спека);
- loader.go: признак = подресурс user + у сервиса есть vault-выходы + create_user
  принимает username и НЕ принимает password; имя сервиса нигде не проверяется;
- templates/subresource.go: поле модели + Computed/Sensitive атрибут password,
  чтение Vault родителя в Create (GetInstanceStateDetails + GetInstanceVaultSecrets)
  и перенос уже полученного пароля в Update (чтобы Computed-атрибут не стал unknown);
- resources_core/subresource_user_password.go: ExtractUserPassword — разбор
  {"<username>":{"password":"..."}} с безопасным возвратом пустой строки;
- writers: регрессионный тест «фича включена/выключена».

Проверено генерацией и сборкой test-стенда: выход получили 4 подресурса
(postgres_user, kafka_user, clickhouse_user, mongodb_user); mariadb_user НЕ затронут
(там пароль входной); k8s_*_user и vc_org_user не затронуты (пользователь
адресуется не через username). go test ./... — ok, go build — чисто.
This commit is contained in:
Repinoid
2026-10-01 17:15:14 +03:00
parent 1a049efa44
commit 58c519e712
5 changed files with 271 additions and 2 deletions
@@ -0,0 +1,106 @@
package writers
import (
"os"
"path/filepath"
"strings"
"testing"
"resource-generator/internal/types"
)
// TestWriteSubresource_VaultUserPassword — регрессионный тест выхода password у
// подресурса-пользователя.
//
// Зачем: у части сервисов пароль пользователя ГЕНЕРИРУЕТ платформа и кладёт его в
// секрет Vault родительского инстанса. Подресурс обязан вернуть пароль СВОИМ выходом
// сразу после create_user — иначе потребитель не получит его в том же apply
// (vault_secrets родителя — Computed и обновляется только при Read родителя).
// См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets».
//
// Проверяем три части фичи и то, что она НЕ включается у чужих подресурсов:
// 1) поле модели Password с тэгом tfsdk:"password";
// 2) атрибут схемы Computed + Sensitive;
// 3) чтение секрета Vault в Create и перенос пароля в Update;
// 4) при VaultUserPassword=false ничего этого нет (чтобы mariadb и подресурсы без
// секретов не получили лишний атрибут).
func TestWriteSubresource_VaultUserPassword(t *testing.T) {
base := types.GenSubresource{
ServiceName: "postgres",
ServiceID: 90,
SubName: "user",
CreateOpName: "create_user",
DeleteOpName: "delete_user",
CreateParams: []types.Param{
{Code: "username", Type: "string", Required: true, Descr: "Имя пользователя"},
{Code: "role", Type: "string", Required: true, Descr: "Роль"},
},
SchemaParams: []types.Param{
{Code: "username", Type: "string", Required: true, Descr: "Имя пользователя"},
{Code: "role", Type: "string", Required: true, Descr: "Роль"},
},
IdentityParams: []types.Param{
{Code: "username", Type: "string", Required: true, Descr: "Имя пользователя"},
},
UsesString: true,
}
render := func(t *testing.T, sr types.GenSubresource) string {
t.Helper()
dir := t.TempDir()
if err := WriteSubresource(dir, sr); err != nil {
t.Fatalf("WriteSubresource вернул ошибку: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("не прочитал каталог генерации: %v", err)
}
if len(entries) != 1 {
t.Fatalf("ожидался один сгенерированный файл, получено %d", len(entries))
}
raw, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("не прочитал %s: %v", entries[0].Name(), err)
}
return squashSpaces(string(raw))
}
t.Run("фича включена: выход password есть", func(t *testing.T) {
sr := base
sr.VaultUserPassword = true
src := render(t, sr)
if !strings.Contains(src, `Password types.String`) || !strings.Contains(src, `tfsdk:"password"`) {
t.Errorf("в модели нет поля Password с тэгом tfsdk:\"password\"")
}
idx := strings.Index(src, `"password": schema.StringAttribute{`)
if idx < 0 {
t.Fatalf("в схеме нет атрибута password")
}
block := src[idx:]
if end := strings.Index(block, " },"); end > 0 {
block = block[:end]
}
if !strings.Contains(block, "Computed: true") || !strings.Contains(block, "Sensitive: true") {
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, "priorState.Password") {
t.Errorf("в Update нет сохранения уже полученного пароля")
}
})
t.Run("фича выключена: атрибута password нет", func(t *testing.T) {
src := render(t, base)
if strings.Contains(src, `tfsdk:"password"`) {
t.Errorf("атрибут password не должен появляться без VaultUserPassword")
}
if strings.Contains(src, "ExtractUserPassword(") {
t.Errorf("чтение Vault не должно появляться без VaultUserPassword")
}
})
}