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
@@ -1,123 +0,0 @@
/*
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 application
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)
}
@@ -1,81 +0,0 @@
/*
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 application_test
import (
"encoding/json"
"fmt"
"maps"
"strings"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"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 := application.GenerateApplicationCredential("owner", "app", endpoint)
if err != nil {
t.Fatal(err)
}
second, err := application.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 := application.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 := application.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted missing %s", key)
}
invalid[key] = 42
if _, err := application.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted non-string %s", key)
}
}
if (application.ApplicationCredential{}).Validate() == nil {
t.Fatal("accepted zero credential")
}
}
@@ -5,8 +5,7 @@ import (
"errors"
"fmt"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
)
var (
@@ -17,54 +16,28 @@ var (
ErrCredentialUncertain = errors.New("credential creation outcome is uncertain; manual resolution required")
)
const (
CredentialsReady = "CredentialsReady"
CredentialCreationStarted = "CreationStarted"
CredentialPrepared = "CredentialPrepared"
)
type CredentialLocation struct {
Mount string
Path string
}
// CredentialStore 只表达本用例需要的凭据操作,不提供覆盖或删除。
// version=0 的读取只用于观察是否已有值,成功不能作为认领依据。
type CredentialStore interface {
ProvisionLocation(string) (CredentialLocation, error)
ReadCredential(context.Context, CredentialLocation, int64) (ApplicationCredential, error)
CreateCredential(context.Context, CredentialLocation, ApplicationCredential) (int64, error)
}
type CredentialInstance struct {
binding.Instance
Generation int64
Endpoint instance.Endpoint
ProvisionLocation(string) (credentialdomain.Location, error)
ReadCredential(context.Context, credentialdomain.Location, int64) (credentialdomain.ApplicationCredential, error)
CreateCredential(context.Context, credentialdomain.Location, credentialdomain.ApplicationCredential) (int64, error)
}
// CredentialRecord 是同一轮观察的事实,状态中永远不保存密码。
type CredentialRecord struct {
Database BindingDatabase
Tenant *BindingTenant
Instance *CredentialInstance
DatabaseProtected bool
TenantProtected bool
Status CredentialStatus
}
type CredentialStatus struct {
Location *CredentialLocation
Version int64
Ready bool
Reason string
Message string
credentialdomain.Target
Revision string
TenantGeneration int64
InstanceGeneration int64
Status credentialdomain.State
}
// CredentialResources 的写入必须检查 Database UID/resourceVersion,保留其他状态。
// CheckCurrent 在外部操作前后回读本轮三个资源,拒绝陈旧快照;它不是跨系统事务。
type CredentialResources interface {
Load(context.Context, string) (*CredentialRecord, error)
Save(context.Context, *CredentialRecord, CredentialStatus) (*CredentialRecord, error)
Save(context.Context, *CredentialRecord, credentialdomain.State) (*CredentialRecord, error)
CheckCurrent(context.Context, *CredentialRecord) error
}
@@ -75,37 +48,35 @@ type CredentialPreparation struct {
func (s CredentialPreparation) Reconcile(ctx context.Context, name string) error {
record, err := s.Resources.Load(ctx, name)
if err != nil || record == nil || record.Database.Source != "Provision" {
if err != nil || record == nil || !record.RequiresPreparation() {
return err
}
// 未完成创建的重入不猜测后端结果。即使进程在实际发请求前退出,也需要人工核实。
if record.Status.Version == 0 && record.Status.Reason == binding.Conflict {
return nil // 保留首次冲突的具体原因,不因后端恢复而重入创建。
if state, canContinue := record.Status.Resume(); !canContinue {
if state == record.Status {
return nil
}
return s.report(ctx, record, state.Phase, state.Message)
}
if record.Status.Version == 0 && record.Status.Reason == CredentialCreationStarted {
return s.report(ctx, record, binding.Conflict,
"凭据创建未留下成功确认;请核对固定位置与后端历史并人工处理,未重新生成密码")
}
if issue := record.check(); issue != nil {
return s.report(ctx, record, issue.Reason, issue.Message)
if issue := record.Check(); issue != nil {
return s.report(ctx, record, issue.Phase, issue.Message)
}
location, err := s.Store.ProvisionLocation(record.Database.Identity.UID)
if err != nil {
return s.report(ctx, record, binding.DependencyUnavailable, "凭据存储位置配置无效,未执行外部写入")
return s.report(ctx, record, credentialdomain.Unavailable, "凭据存储位置配置无效,未执行外部写入")
}
if issue := record.Status.CheckLocation(location); issue != nil {
return s.report(ctx, record, issue.Phase, issue.Message)
}
if record.Status.Location == nil {
status := record.Status
status.Location = &location
status.Ready, status.Reason, status.Message = false, "LocationPinned", "凭据位置已固定,等待创建"
status = status.WithPhase(credentialdomain.Pinned, "凭据位置已固定,等待创建")
record, err = s.Resources.Save(ctx, record, status)
if err != nil {
return err
}
} else if *record.Status.Location != location {
return s.report(ctx, record, binding.DependencyUnavailable,
"部署配置与固定凭据位置不一致;请恢复原 mount/path 配置,未迁移或改密")
}
if record.Status.Version > 0 {
if record.Status.Confirmed() {
return s.observe(ctx, record)
}
return s.create(ctx, record)
@@ -113,19 +84,18 @@ func (s CredentialPreparation) Reconcile(ctx context.Context, name string) error
func (s CredentialPreparation) create(ctx context.Context, record *CredentialRecord) error {
_, err := s.Store.ReadCredential(ctx, *record.Status.Location, 0)
if err == nil || errors.Is(err, ErrApplicationCredentialInvalid) {
return s.report(ctx, record, binding.Conflict, "固定位置已有未确认的凭据;请人工核实,未认领或覆盖")
if err == nil || errors.Is(err, credentialdomain.ErrApplicationCredentialInvalid) {
return s.report(ctx, record, credentialdomain.Conflict, "固定位置已有未确认的凭据;请人工核实,未认领或覆盖")
}
if !errors.Is(err, ErrCredentialNotFound) {
return s.report(ctx, record, binding.DependencyUnavailable, "创建前无法确认凭据位置是否为空,等待依赖恢复")
return s.report(ctx, record, credentialdomain.Unavailable, "创建前无法确认凭据位置是否为空,等待依赖恢复")
}
credential, err := GenerateApplicationCredential(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint)
credential, err := credentialdomain.GenerateApplicationCredential(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint)
if err != nil {
return s.report(ctx, record, "InvalidTarget", "应用凭据目标无效,未执行外部写入")
return s.report(ctx, record, credentialdomain.InvalidTarget, "应用凭据目标无效,未执行外部写入")
}
status := record.Status
status.Ready, status.Reason = false, CredentialCreationStarted
status.Message = "凭据创建已开始;尚无成功确认时不得重入创建"
status = status.WithPhase(credentialdomain.Creating, "凭据创建已开始;尚无成功确认时不得重入创建")
record, err = s.Resources.Save(ctx, record, status)
if err != nil {
return err
@@ -136,46 +106,47 @@ func (s CredentialPreparation) create(ctx context.Context, record *CredentialRec
version, err := s.Store.CreateCredential(ctx, *record.Status.Location, credential)
if errors.Is(err, ErrCredentialUnavailable) {
// 适配器只在明确未执行写入(认证拒绝或请求前取消)时返回此错误。
return s.report(ctx, record, binding.DependencyUnavailable, "凭据创建在执行前被拒绝,等待认证或权限恢复")
return s.report(ctx, record, credentialdomain.Unavailable, "凭据创建在执行前被拒绝,等待认证或权限恢复")
}
if err != nil || version != 1 {
return s.report(ctx, record, binding.Conflict,
if err != nil {
return s.report(ctx, record, credentialdomain.Conflict,
"凭据创建冲突或结果不确定;请核对固定位置的版本历史,未认领、覆盖或重新生成密码")
}
confirmed, issue := record.Status.Created(version)
if issue != nil {
return s.report(ctx, record, issue.Phase, issue.Message)
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
status = record.Status
status.Version, status.Ready, status.Reason = version, true, CredentialPrepared
status.Message = "凭据已创建并回读确认;尚未创建 PostgreSQL 资源或交付给 Tenant"
_, err = s.Resources.Save(ctx, record, status)
_, err = s.Resources.Save(ctx, record, confirmed)
return err
}
func (s CredentialPreparation) observe(ctx context.Context, record *CredentialRecord) error {
credential, err := s.Store.ReadCredential(ctx, *record.Status.Location, record.Status.Version)
if errors.Is(err, ErrCredentialConflict) || errors.Is(err, ErrCredentialNotFound) {
return s.report(ctx, record, binding.Conflict, "已确认凭据消失、版本变化或内容无效;请人工核实,未生成替代密码")
return s.report(ctx, record, credentialdomain.Conflict, "已确认凭据消失、版本变化或内容无效;请人工核实,未生成替代密码")
}
if err != nil {
return s.report(ctx, record, binding.DependencyUnavailable, "已确认凭据暂时无法读取;保留确认版本,等待依赖恢复")
return s.report(ctx, record, credentialdomain.Unavailable, "已确认凭据暂时无法读取;保留确认版本,等待依赖恢复")
}
if !credential.MatchesTarget(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint) {
return s.report(ctx, record, binding.Conflict, "已确认凭据与当前 Instance/database/loginRole 不一致;请人工核实,未修改凭据")
if issue := record.CheckCredential(credential); issue != nil {
return s.report(ctx, record, issue.Phase, issue.Message)
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
status := record.Status
status.Ready, status.Reason = true, CredentialPrepared
status.Phase = credentialdomain.Prepared
status.Message = "已确认凭据可读取;尚未验证 PostgreSQL 资源或完成 Tenant 交付"
_, err = s.Resources.Save(ctx, record, status)
return err
}
func (s CredentialPreparation) report(ctx context.Context, record *CredentialRecord, reason, message string) error {
func (s CredentialPreparation) report(ctx context.Context, record *CredentialRecord, phase credentialdomain.Phase, message string) error {
status := record.Status
status.Ready, status.Reason = false, reason
status.Phase = phase
status.Message = fmt.Sprintf("Database %s:%s", record.Database.Identity.Name, message)
_, err := s.Resources.Save(ctx, record, status)
return err
@@ -5,6 +5,8 @@ import (
"errors"
"testing"
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
@@ -25,14 +27,14 @@ func preparationRecord(t *testing.T) *CredentialRecord {
t.Fatal(err)
}
return &CredentialRecord{
Database: BindingDatabase{Database: binding.Database{
Database: binding.Database{
Identity: database, Instance: preparationInstanceName, InstanceUID: preparationInstanceUID, Name: bindingTestName, LoginRole: bindingTestName, Source: "Provision", Tenant: &tenant,
}},
Tenant: &BindingTenant{
},
Tenant: &binding.Tenant{
Identity: tenant, Phase: binding.Bound, Database: &database,
Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: preparationInstanceName}},
},
Instance: &CredentialInstance{Identity: binding.Identity{Name: preparationInstanceName, UID: preparationInstanceUID}, Ready: true, Endpoint: endpoint},
Instance: &credentialdomain.Instance{Identity: binding.Identity{Name: preparationInstanceName, UID: preparationInstanceUID}, Ready: true, Endpoint: endpoint},
DatabaseProtected: true, TenantProtected: true,
}
}
@@ -48,7 +50,7 @@ func (r *memoryCredentialResources) Load(context.Context, string) (*CredentialRe
return &copy, nil
}
func (r *memoryCredentialResources) Save(_ context.Context, record *CredentialRecord, status CredentialStatus) (*CredentialRecord, error) {
func (r *memoryCredentialResources) Save(_ context.Context, record *CredentialRecord, status credentialdomain.State) (*CredentialRecord, error) {
if r.saveError != nil {
return nil, r.saveError
}
@@ -69,16 +71,16 @@ type preparationStore struct {
createError error
}
func (*preparationStore) ProvisionLocation(uid string) (CredentialLocation, error) {
return CredentialLocation{Mount: "applications", Path: "database/" + uid}, nil
func (*preparationStore) ProvisionLocation(uid string) (credentialdomain.Location, error) {
return credentialdomain.Location{Mount: "applications", Path: "database/" + uid}, nil
}
func (s *preparationStore) ReadCredential(context.Context, CredentialLocation, int64) (ApplicationCredential, error) {
func (s *preparationStore) ReadCredential(context.Context, credentialdomain.Location, int64) (credentialdomain.ApplicationCredential, error) {
s.reads++
return ApplicationCredential{}, s.readError
return credentialdomain.ApplicationCredential{}, s.readError
}
func (s *preparationStore) CreateCredential(context.Context, CredentialLocation, ApplicationCredential) (int64, error) {
func (s *preparationStore) CreateCredential(context.Context, credentialdomain.Location, credentialdomain.ApplicationCredential) (int64, error) {
s.creates++
if s.createError != nil {
return 0, s.createError
@@ -159,7 +161,7 @@ func TestCredentialPreparationWriteBoundary(t *testing.T) {
if err := service.Reconcile(t.Context(), resources.record.Database.Identity.Name); err != nil {
t.Fatal(err)
}
if store.creates != test.wantCreates || resources.record.Status.Reason != binding.Conflict {
if store.creates != test.wantCreates || resources.record.Status.Phase != credentialdomain.Conflict {
t.Fatal("未确认创建重入时不得生成替代密码")
}
}
@@ -1,27 +0,0 @@
package application
import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
func (r *CredentialRecord) check() *binding.Issue {
database := r.Database
if database.Deleting || database.Phase == binding.Deleting || database.Phase == "Released" {
return &binding.Issue{Reason: "PreparationStopped", Message: "Database 正在删除或已释放;保留凭据与 finalizer,不执行供应或清理"}
}
if database.Tenant == nil || r.Tenant == nil || r.Tenant.Database == nil {
return &binding.Issue{Reason: binding.DependencyUnavailable, Message: "等待 Database 与 Tenant 双向绑定完成"}
}
if *database.Tenant != r.Tenant.Identity || *r.Tenant.Database != database.Identity {
return &binding.Issue{Reason: binding.Conflict, Message: "双向绑定的名称或 UID 不匹配,未创建凭据"}
}
if r.Tenant.Deleting || r.Tenant.Phase != binding.Bound || !r.DatabaseProtected || !r.TenantProtected {
return &binding.Issue{Reason: "PreparationStopped", Message: "Tenant 未完成绑定、正在删除或缺少 finalizer 保护,未创建凭据"}
}
target, err := r.Tenant.Request.Resolve(r.Tenant.Identity)
if err != nil || (target.Provision != nil && !database.MatchesProvision(target, r.Tenant.Identity)) || target.Name != database.Identity.Name {
return &binding.Issue{Reason: binding.Conflict, Message: "Tenant 申请与 Database 目标不一致,未创建凭据"}
}
if r.Instance == nil || database.InstanceUID == "" {
return &binding.Issue{Reason: binding.DependencyUnavailable, Message: "等待 Instance 与已记录的实例身份"}
}
return r.Instance.Check(&database.Database)
}