refactor: 集中显式注入并归位凭据领域规则
Verify / test (pull_request) Successful in 13m29s
Verify / lint (pull_request) Successful in 14m13s
Verify / database-integration (pull_request) Successful in 15m48s

This commit is contained in:
2026-09-27 19:16:48 +00:00
parent 72ce3eda40
commit 7834cab97f
34 changed files with 612 additions and 259 deletions
@@ -0,0 +1,123 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package credential
import (
"crypto/rand"
"encoding/base64"
"errors"
"regexp"
"strconv"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var ErrApplicationCredentialInvalid = errors.New("application credential is invalid")
var applicationIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
// ApplicationCredential 是内存中的应用连接凭据,不得放入 CR 或普通日志。
// 它与 Instance 管理凭据分开,固定输出交付合同中的七键,不生成带密码的 URI。
type ApplicationCredential struct {
username string
password string
database string
endpoint instance.Endpoint
}
func NewApplicationCredential(username, password, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
if !applicationIdentifier.MatchString(username) || !applicationIdentifier.MatchString(database) || password == "" {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
if endpoint.Validate() != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
return ApplicationCredential{
username: username,
password: password,
database: database,
endpoint: endpoint,
}, nil
}
// GenerateApplicationCredential 仅供已获准首次创建凭据的供应步骤调用。
// 不能在读取失败、写入结果不确定或重启后无条件重新调用。
func GenerateApplicationCredential(username, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
password := make([]byte, 32)
rand.Read(password)
return NewApplicationCredential(username, base64.RawURLEncoding.EncodeToString(password), database, endpoint)
}
func (c ApplicationCredential) String() string { return "[redacted application credential]" }
func (c ApplicationCredential) GoString() string { return c.String() }
func (c ApplicationCredential) MarshalJSON() ([]byte, error) {
return []byte(`"[redacted application credential]"`), nil
}
// SecretData 只在凭据后端或数据库连接边界使用;返回值包含明文密码,禁止记录日志。
// 每次返回独立 map,调用方不能修改已经构造的凭据。
func (c ApplicationCredential) SecretData() map[string]any {
endpoint := c.endpoint.Values()
return map[string]any{
"username": c.username,
"password": c.password,
"database": c.database,
"host": endpoint.Host,
"hostaddr": endpoint.HostAddr,
"port": strconv.Itoa(endpoint.Port),
"sslmode": string(endpoint.TLSMode),
}
}
func (c ApplicationCredential) Validate() error {
_, err := NewApplicationCredential(c.username, c.password, c.database, c.endpoint)
return err
}
// MatchesTarget 只比较连接目标,不向用例暴露密码;管理库名不是应用连接目标的一部分。
func (c ApplicationCredential) MatchesTarget(username, database string, endpoint instance.Endpoint) bool {
actual, wanted := c.endpoint.Values(), endpoint.Values()
return c.username == username && c.database == database && actual.Host == wanted.Host &&
actual.HostAddr == wanted.HostAddr && actual.Port == wanted.Port && actual.TLSMode == wanted.TLSMode
}
// ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。
func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) {
values := make(map[string]string, 7)
for _, key := range []string{"username", "password", "database", "host", "hostaddr", "port", "sslmode"} {
value, ok := data[key].(string)
if !ok || value == "" {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
values[key] = value
}
port, err := strconv.Atoi(values["port"])
if err != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: values["host"],
HostAddr: values["hostaddr"],
Port: port,
ManagementDatabase: values["database"],
TLSMode: instance.TLSMode(values["sslmode"]),
})
if err != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
return NewApplicationCredential(values["username"], values["password"], values["database"], endpoint)
}
@@ -0,0 +1,82 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package credential_test
import (
"encoding/json"
"fmt"
"maps"
"strings"
"testing"
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestApplicationCredential(t *testing.T) {
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432,
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
})
if err != nil {
t.Fatal(err)
}
first, err := credentialdomain.GenerateApplicationCredential("owner", "app", endpoint)
if err != nil {
t.Fatal(err)
}
second, err := credentialdomain.GenerateApplicationCredential("owner", "app", endpoint)
if err != nil {
t.Fatal(err)
}
data := first.SecretData()
if len(data) != 7 || data["password"] == second.SecretData()["password"] || len(data["password"].(string)) != 43 {
t.Fatal("expected seven keys and independent 256-bit passwords")
}
parsed, err := credentialdomain.ParseApplicationCredential(data)
if err != nil || !maps.Equal(parsed.SecretData(), data) {
t.Fatal("credential did not round trip")
}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatal(err)
}
for _, output := range []string{fmt.Sprint(first), fmt.Sprintf("%+v", first), fmt.Sprintf("%#v", first), string(encoded)} {
if strings.Contains(output, data["password"].(string)) {
t.Fatal("credential formatting leaked the password")
}
}
data["password"] = "changed"
if first.SecretData()["password"] == "changed" {
t.Fatal("caller mutated credential")
}
for key := range data {
invalid := maps.Clone(data)
delete(invalid, key)
if _, err := credentialdomain.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted missing %s", key)
}
invalid[key] = 42
if _, err := credentialdomain.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted non-string %s", key)
}
}
if (credentialdomain.ApplicationCredential{}).Validate() == nil {
t.Fatal("accepted zero credential")
}
}
@@ -0,0 +1,129 @@
package credential
import (
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
type Location struct {
Mount string
Path string
}
// Phase 表达凭据准备进度,不依赖 Kubernetes Condition 的类型或 Reason。
type Phase uint8
const (
Pending Phase = iota
Pinned
Creating
Prepared
Conflict
Unavailable
Stopped
InvalidTarget
)
type State struct {
Location *Location
Version int64
Phase Phase
Message string
}
func (s State) WithPhase(phase Phase, message string) State {
s.Phase, s.Message = phase, message
return s
}
func (s State) Confirmed() bool { return s.Version > 0 }
// Created 只接受首次创建并回读得到的版本,不能把后续写入认作首次供应。
func (s State) Created(version int64) (State, *Issue) {
if version != 1 {
return s, &Issue{Conflict, "凭据创建冲突或结果不确定;请核对固定位置的版本历史,未认领、覆盖或重新生成密码"}
}
s.Version = version
return s.WithPhase(Prepared, "凭据已创建并回读确认;尚未创建 PostgreSQL 资源或交付给 Tenant"), nil
}
// Resume 决定新一轮是否可以继续。未确认的创建不能靠读取成功认领。
func (s State) Resume() (State, bool) {
if s.Version != 0 {
return s, true
}
switch s.Phase {
case Conflict:
return s, false
case Creating:
return s.WithPhase(Conflict, "凭据创建未留下成功确认;请核对固定位置与后端历史并人工处理,未重新生成密码"), false
default:
return s, true
}
}
func (s State) CheckLocation(configured Location) *Issue {
if s.Location != nil && *s.Location != configured {
return &Issue{Unavailable, "部署配置与固定凭据位置不一致;请恢复原 mount/path 配置,未迁移或改密"}
}
return nil
}
type Instance struct {
binding.Instance
Endpoint instance.Endpoint
}
// Target 只包含供应资格所需事实,不含 resourceVersion、Conditions 或 repository 对象。
type Target struct {
Database binding.Database
Tenant *binding.Tenant
Instance *Instance
DatabaseProtected bool
TenantProtected bool
}
type Issue struct {
Phase Phase
Message string
}
func (t Target) RequiresPreparation() bool { return t.Database.Source == "Provision" }
func (t Target) CheckCredential(value ApplicationCredential) *Issue {
if t.Instance == nil || !value.MatchesTarget(t.Database.LoginRole, t.Database.Name, t.Instance.Endpoint) {
return &Issue{Conflict, "已确认凭据与当前 Instance/database/loginRole 不一致;请人工核实,未修改凭据"}
}
return nil
}
func (t Target) Check() *Issue {
database := t.Database
if database.Deleting || database.Phase == binding.Deleting || database.Phase == "Released" {
return &Issue{Stopped, "Database 正在删除或已释放;保留凭据与 finalizer,不执行供应或清理"}
}
if database.Tenant == nil || t.Tenant == nil || t.Tenant.Database == nil {
return &Issue{Unavailable, "等待 Database 与 Tenant 双向绑定完成"}
}
if *database.Tenant != t.Tenant.Identity || *t.Tenant.Database != database.Identity {
return &Issue{Conflict, "双向绑定的名称或 UID 不匹配,未创建凭据"}
}
if t.Tenant.Deleting || t.Tenant.Phase != binding.Bound || !t.DatabaseProtected || !t.TenantProtected {
return &Issue{Stopped, "Tenant 未完成绑定、正在删除或缺少 finalizer 保护,未创建凭据"}
}
request, err := t.Tenant.Request.Resolve(t.Tenant.Identity)
if err != nil || (request.Provision != nil && !database.MatchesProvision(request, t.Tenant.Identity)) || request.Name != database.Identity.Name {
return &Issue{Conflict, "Tenant 申请与 Database 目标不一致,未创建凭据"}
}
if t.Instance == nil || database.InstanceUID == "" {
return &Issue{Unavailable, "等待 Instance 与已记录的实例身份"}
}
if issue := t.Instance.Check(&database); issue != nil {
phase := Unavailable
if issue.Reason == binding.Conflict {
phase = Conflict
}
return &Issue{phase, issue.Message}
}
return nil
}
@@ -0,0 +1,59 @@
package credential_test
import (
"testing"
credential "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
)
func TestPreparationResume(t *testing.T) {
for _, test := range []struct {
name string
phase credential.Phase
version int64
continueAllowed bool
result credential.Phase
}{
{"尚未创建", credential.Pinned, 0, true, credential.Pinned},
{"依赖恢复", credential.Unavailable, 0, true, credential.Unavailable},
{"中断创建", credential.Creating, 0, false, credential.Conflict},
{"未确认冲突", credential.Conflict, 0, false, credential.Conflict},
{"已确认后读取失败", credential.Unavailable, 1, true, credential.Unavailable},
{"已确认后冲突重验", credential.Conflict, 1, true, credential.Conflict},
} {
t.Run(test.name, func(t *testing.T) {
original := credential.State{Phase: test.phase, Version: test.version, Message: "保留原诊断"}
state, allowed := original.Resume()
if allowed != test.continueAllowed || state.Phase != test.result || state.Version != original.Version {
t.Fatal("恢复判定或确认版本发生变化")
}
if test.phase == credential.Conflict && state.Message != original.Message {
t.Fatal("冲突重入应保留原诊断")
}
})
}
}
func TestPreparationLocationAndConfirmation(t *testing.T) {
location := credential.Location{Mount: "applications", Path: "database/uid"}
state := credential.State{Location: &location, Phase: credential.Creating}
if issue := state.CheckLocation(location); issue != nil {
t.Fatal("固定位置不应被拒绝")
}
if issue := state.CheckLocation(credential.Location{Mount: "other", Path: location.Path}); issue == nil || issue.Phase != credential.Unavailable {
t.Fatal("配置变化必须停止,不迁移已固定位置")
}
for _, version := range []int64{0, -1, 2} {
result, issue := state.Created(version)
if issue == nil || issue.Phase != credential.Conflict || result.Confirmed() {
t.Fatal("错误版本不得确认创建")
}
}
result, issue := state.Created(1)
if issue != nil || !result.Confirmed() || result.Phase != credential.Prepared || result.Location != state.Location {
t.Fatal("首次写入回读应确认并保留位置")
}
if state.Version != 0 {
t.Fatal("领域判定不得修改调用方的旧状态")
}
}
@@ -0,0 +1,52 @@
package credential_test
import (
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
credential "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
)
const (
targetTestApplication = "sampleapp"
targetTestInstance = "test-instance"
)
func TestPreparationTarget(t *testing.T) {
for _, test := range []struct {
name string
change func(*credential.Target)
want credential.Phase
}{
{"完整绑定", func(*credential.Target) {}, credential.Pending},
{"单向绑定", func(target *credential.Target) { target.Tenant.Database = nil }, credential.Unavailable},
{"旧租户身份", func(target *credential.Target) { target.Tenant.Identity.UID = "new" }, credential.Conflict},
{"旧实例身份", func(target *credential.Target) { target.Instance.Identity.UID = "new" }, credential.Conflict},
{"资源删除", func(target *credential.Target) { target.Database.Deleting = true }, credential.Stopped},
{"申请删除", func(target *credential.Target) { target.Tenant.Deleting = true }, credential.Stopped},
{"Released", func(target *credential.Target) { target.Database.Phase = "Released" }, credential.Stopped},
{"缺少保护", func(target *credential.Target) { target.DatabaseProtected = false }, credential.Stopped},
{"实例未就绪", func(target *credential.Target) { target.Instance.Ready = false }, credential.Unavailable},
{"目标变化", func(target *credential.Target) { target.Database.LoginRole = "other" }, credential.Conflict},
} {
t.Run(test.name, func(t *testing.T) {
tenantID := binding.TenantIdentity{Namespace: "apps", Name: targetTestApplication, UID: "tenant"}
databaseID := binding.Identity{Name: binding.DynamicDatabaseName(tenantID.UID), UID: "database"}
target := credential.Target{
Database: binding.Database{Identity: databaseID, Tenant: &tenantID, Instance: targetTestInstance, InstanceUID: "instance-id", Name: targetTestApplication, LoginRole: targetTestApplication, Source: "Provision"},
Tenant: &binding.Tenant{Identity: tenantID, Database: &databaseID, Phase: binding.Bound, Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: targetTestInstance}}},
Instance: &credential.Instance{Identity: binding.Identity{Name: targetTestInstance, UID: "instance-id"}, Ready: true},
DatabaseProtected: true, TenantProtected: true,
}
test.change(&target)
issue := target.Check()
if test.want == credential.Pending {
if issue != nil {
t.Fatalf("有效绑定被拒绝: %s", issue.Message)
}
} else if issue == nil || issue.Phase != test.want {
t.Fatal("领域资格判定不符")
}
})
}
}