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:
@@ -223,6 +223,23 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
|
|||||||
params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams)
|
params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams)
|
||||||
sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams)
|
sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams)
|
||||||
sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams)
|
sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams)
|
||||||
|
|
||||||
|
// VaultUserPassword: пароль пользователя ГЕНЕРИРУЕТ платформа — в create-параметрах
|
||||||
|
// подресурса нет кода password, — а сервис отдаёт vault-выходы. Значит пароль
|
||||||
|
// появится в Vault родителя после create_user, и подресурс обязан вернуть его сам
|
||||||
|
// (выход password), иначе потребителю придётся читать vault_secrets родителя, что
|
||||||
|
// внутри одного apply невозможно. См. TOOLS/ARCHITECTURE.md.
|
||||||
|
//
|
||||||
|
// Признак берётся из ДАННЫХ спека, без сверки с именем сервиса: поэтому mariadb
|
||||||
|
// (там пароль задаётся на входе create_user) сюда не попадает.
|
||||||
|
//
|
||||||
|
// Отдельно требуется параметр username: именно по нему провайдер находит пароль
|
||||||
|
// в секрете Vault. У сервисов, где пользователь адресуется иначе (k8s_*_user,
|
||||||
|
// vc_org_user), выход пока не генерируется.
|
||||||
|
if sr.SubName == "user" && serviceHasVaultOutputs(spec) &&
|
||||||
|
hasParamCode(sr.CreateParams, "username") && !hasParamCode(sr.CreateParams, "password") {
|
||||||
|
sr.VaultUserPassword = true
|
||||||
|
}
|
||||||
subs = append(subs, *sr)
|
subs = append(subs, *sr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,8 +477,35 @@ func validateModifierOperation(i int, op *lib.OperationSpec) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyzeUseStateForUnknown определяет, нужны ли импорты boolplanmodifier/int64planmodifier:
|
// serviceHasVaultOutputs сообщает, отдаёт ли сервис секреты через Vault
|
||||||
// есть ли среди SchemaParams скалярный Optional+Computed (без Default) параметр
|
// (в outputs объявлены vault_secrets / vault_fields).
|
||||||
|
//
|
||||||
|
// Зачем: это признак «сервис держит секреты в Vault родителя». Используется вместе
|
||||||
|
// с отсутствием входного password у create-подресурса, чтобы включить выход password
|
||||||
|
// у подресурса-пользователя (см. VaultUserPassword в types.GenSubresource).
|
||||||
|
// Признак берётся из данных спека — имя сервиса нигде не проверяется.
|
||||||
|
func serviceHasVaultOutputs(spec types.ServiceSpec) bool {
|
||||||
|
for _, p := range spec.Outputs.Params {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(p.Code)) {
|
||||||
|
case "vault_secrets", "vault_fields":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasParamCode сообщает, есть ли среди параметров параметр с таким кодом
|
||||||
|
// (регистр и пробелы не важны).
|
||||||
|
func hasParamCode(params []types.Param, code string) bool {
|
||||||
|
for _, p := range params {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(p.Code), code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyzeUseStateForUnknown определяет, нужны ли импорты boolplanmodifier/int64planmodifier:// есть ли среди SchemaParams скалярный Optional+Computed (без Default) параметр
|
||||||
// соответствующего типа, для которого генерируется UseStateForUnknown().
|
// соответствующего типа, для которого генерируется UseStateForUnknown().
|
||||||
func analyzeUseStateForUnknown(schemaParams []types.Param) (needsBool, needsInt64 bool) {
|
func analyzeUseStateForUnknown(schemaParams []types.Param) (needsBool, needsInt64 bool) {
|
||||||
for _, p := range schemaParams {
|
for _, p := range schemaParams {
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ type {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model struct {
|
|||||||
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
|
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
|
||||||
SkipMissingOnDelete types.Bool ` + "`" + `tfsdk:"skip_missing_on_delete"` + "`" + `
|
SkipMissingOnDelete types.Bool ` + "`" + `tfsdk:"skip_missing_on_delete"` + "`" + `
|
||||||
KeepOnDestroy types.Bool ` + "`" + `tfsdk:"keep_on_destroy"` + "`" + `
|
KeepOnDestroy types.Bool ` + "`" + `tfsdk:"keep_on_destroy"` + "`" + `
|
||||||
|
{{- if .VaultUserPassword }}
|
||||||
|
// Password — пароль пользователя, который генерирует платформа и кладёт в секрет
|
||||||
|
// Vault родителя. Заполняется в Create сразу после create_user: потребителю не нужно
|
||||||
|
// читать vault_secrets родителя (внутри одного apply оно ещё пусто).
|
||||||
|
Password types.String ` + "`" + `tfsdk:"password"` + "`" + `
|
||||||
|
{{- end }}
|
||||||
OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + `
|
OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + `
|
||||||
LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + `
|
LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + `
|
||||||
{{- range .SchemaParams }}
|
{{- range .SchemaParams }}
|
||||||
@@ -71,6 +77,9 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Schema(ctx
|
|||||||
"adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)},
|
"adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)},
|
||||||
"skip_missing_on_delete": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)},
|
"skip_missing_on_delete": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)},
|
||||||
"keep_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), MarkdownDescription: "Режим state_only при destroy: подресурс не удаляется и не меняется в облаке, только убирается из состояния. Нужен там, где родительский инстанс при destroy не удаляется (например suspend_on_destroy = true) и объект должен остаться внутри него. false = обычное удаление."},
|
"keep_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), MarkdownDescription: "Режим state_only при destroy: подресурс не удаляется и не меняется в облаке, только убирается из состояния. Нужен там, где родительский инстанс при destroy не удаляется (например suspend_on_destroy = true) и объект должен остаться внутри него. false = обычное удаление."},
|
||||||
|
{{- if .VaultUserPassword }}
|
||||||
|
"password": schema.StringAttribute{Computed: true, Sensitive: true, MarkdownDescription: "Пароль пользователя, который генерирует платформа. Провайдер читает его из Vault родительского инстанса сразу после создания пользователя."},
|
||||||
|
{{- end }}
|
||||||
"operation_timeout": schema.StringAttribute{Optional: true},
|
"operation_timeout": schema.StringAttribute{Optional: true},
|
||||||
"log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."},
|
"log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."},
|
||||||
{{- range .SchemaParams }}
|
{{- range .SchemaParams }}
|
||||||
@@ -235,6 +244,31 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
|
plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams))
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
}
|
}
|
||||||
@@ -254,6 +288,20 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Update(ctx
|
|||||||
if resp.Diagnostics.HasError() {
|
if resp.Diagnostics.HasError() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
{{- if .VaultUserPassword }}
|
||||||
|
// Пароль меняет платформа, а не пользователь: при modify его не трогаем и оставляем уже
|
||||||
|
// полученное значение из state (иначе Computed-атрибут станет null/unknown и Terraform
|
||||||
|
// ругнётся на несовместимый результат после apply).
|
||||||
|
if plan.Password.IsNull() || plan.Password.IsUnknown() {
|
||||||
|
var priorState {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model
|
||||||
|
if stateDiags := req.State.Get(ctx, &priorState); !stateDiags.HasError() {
|
||||||
|
plan.Password = priorState.Password
|
||||||
|
}
|
||||||
|
if plan.Password.IsNull() || plan.Password.IsUnknown() {
|
||||||
|
plan.Password = types.StringNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{- end }}
|
||||||
{{- if .HasRefSvcParams }}
|
{{- if .HasRefSvcParams }}
|
||||||
{{- range .ModifyParams }}
|
{{- range .ModifyParams }}
|
||||||
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
|
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ type GenSubresource struct {
|
|||||||
NeedsStringPlanMod bool
|
NeedsStringPlanMod bool
|
||||||
NeedsJsonPlanMod bool
|
NeedsJsonPlanMod bool
|
||||||
NeedsStringsImport bool
|
NeedsStringsImport bool
|
||||||
|
// VaultUserPassword — true, если пароль пользователя ГЕНЕРИРУЕТ платформа
|
||||||
|
// (в create-параметрах подресурса НЕТ кода password) и сервис отдаёт vault-выходы.
|
||||||
|
// Тогда подресурс получает выход password (Computed, Sensitive), читаемый из Vault
|
||||||
|
// родителя СРАЗУ после create_user. См. TOOLS/ARCHITECTURE.md → «Subresource-born secrets».
|
||||||
|
//
|
||||||
|
// Признак вычисляется из ДАННЫХ спека (наличие vault-выходов + отсутствие входного
|
||||||
|
// password), поэтому mariadb (где пароль задаётся на входе) сюда не попадает.
|
||||||
|
VaultUserPassword bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource).
|
// GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package resources_core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractUserPassword вытаскивает пароль пользователя из JSON-значения секрета Vault
|
||||||
|
// родительского инстанса.
|
||||||
|
//
|
||||||
|
// ЗАЧЕМ: у части сервисов (проверено на postgres) платформа САМА генерирует пароль
|
||||||
|
// в операции create_user и кладёт его в секрет Vault родителя. Формат значения
|
||||||
|
// (проверено через GET /instances/<uid>/vault/users):
|
||||||
|
//
|
||||||
|
// {"<username>": {"password": "<пароль>"}}
|
||||||
|
//
|
||||||
|
// Подресурс обязан вернуть этот пароль СВОИМ выходом сразу после create_user, иначе
|
||||||
|
// потребитель не сможет получить его в том же apply (vault_secrets родителя — Computed
|
||||||
|
// и обновляется только при Read родителя). См. TOOLS/ARCHITECTURE.md,
|
||||||
|
// раздел «Subresource-born secrets».
|
||||||
|
//
|
||||||
|
// Возвращает "" без ошибки, если пользователя в секрете нет или пароль отсутствует:
|
||||||
|
// это не фатально (пароль остаётся неизвестным до следующего чтения) и не должно
|
||||||
|
// ломать create.
|
||||||
|
func ExtractUserPassword(secretJSON string, username string) (string, error) {
|
||||||
|
raw := strings.TrimSpace(secretJSON)
|
||||||
|
if raw == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(username)
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("пустое имя пользователя для поиска пароля в Vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основной формат: {"<username>": {"password": "..."}}.
|
||||||
|
var byUser map[string]struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &byUser); err == nil {
|
||||||
|
if entry, ok := byUser[name]; ok && strings.TrimSpace(entry.Password) != "" {
|
||||||
|
return strings.TrimSpace(entry.Password), nil
|
||||||
|
}
|
||||||
|
// Регистр имени может отличаться (платформа нормализует его сама).
|
||||||
|
for key, entry := range byUser {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(key), name) && strings.TrimSpace(entry.Password) != "" {
|
||||||
|
return strings.TrimSpace(entry.Password), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Упрощённый формат: {"password": "..."} — единственный пользователь в секрете.
|
||||||
|
var flat struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &flat); err == nil {
|
||||||
|
if strings.TrimSpace(flat.Password) != "" {
|
||||||
|
return strings.TrimSpace(flat.Password), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user