feat: 接通 Database 凭据准备闭环
Verify / test (pull_request) Successful in 11m23s
Verify / lint (pull_request) Successful in 12m18s
Verify / database-integration (pull_request) Successful in 13m49s

This commit is contained in:
2026-09-27 18:35:13 +00:00
parent 371248659b
commit 35ada6d7eb
24 changed files with 1385 additions and 37 deletions
@@ -88,6 +88,13 @@ func (c ApplicationCredential) Validate() error {
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)
@@ -0,0 +1,182 @@
package application
import (
"context"
"errors"
"fmt"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var (
ErrCredentialLocation = errors.New("credential location is outside the configured scope")
ErrCredentialUnavailable = errors.New("credential backend unavailable")
ErrCredentialNotFound = errors.New("application credential not found")
ErrCredentialConflict = errors.New("credential creation requires manual conflict resolution")
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
}
// 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
}
// CredentialResources 的写入必须检查 Database UID/resourceVersion,保留其他状态。
// CheckCurrent 在外部操作前后回读本轮三个资源,拒绝陈旧快照;它不是跨系统事务。
type CredentialResources interface {
Load(context.Context, string) (*CredentialRecord, error)
Save(context.Context, *CredentialRecord, CredentialStatus) (*CredentialRecord, error)
CheckCurrent(context.Context, *CredentialRecord) error
}
type CredentialPreparation struct {
Resources CredentialResources
Store CredentialStore
}
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" {
return err
}
// 未完成创建的重入不猜测后端结果。即使进程在实际发请求前退出,也需要人工核实。
if record.Status.Version == 0 && record.Status.Reason == binding.Conflict {
return nil // 保留首次冲突的具体原因,不因后端恢复而重入创建。
}
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)
}
location, err := s.Store.ProvisionLocation(record.Database.Identity.UID)
if err != nil {
return s.report(ctx, record, binding.DependencyUnavailable, "凭据存储位置配置无效,未执行外部写入")
}
if record.Status.Location == nil {
status := record.Status
status.Location = &location
status.Ready, status.Reason, status.Message = false, "LocationPinned", "凭据位置已固定,等待创建"
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 {
return s.observe(ctx, record)
}
return s.create(ctx, record)
}
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 !errors.Is(err, ErrCredentialNotFound) {
return s.report(ctx, record, binding.DependencyUnavailable, "创建前无法确认凭据位置是否为空,等待依赖恢复")
}
credential, err := GenerateApplicationCredential(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint)
if err != nil {
return s.report(ctx, record, "InvalidTarget", "应用凭据目标无效,未执行外部写入")
}
status := record.Status
status.Ready, status.Reason = false, CredentialCreationStarted
status.Message = "凭据创建已开始;尚无成功确认时不得重入创建"
record, err = s.Resources.Save(ctx, record, status)
if err != nil {
return err
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
version, err := s.Store.CreateCredential(ctx, *record.Status.Location, credential)
if errors.Is(err, ErrCredentialUnavailable) {
// 适配器只在明确未执行写入(认证拒绝或请求前取消)时返回此错误。
return s.report(ctx, record, binding.DependencyUnavailable, "凭据创建在执行前被拒绝,等待认证或权限恢复")
}
if err != nil || version != 1 {
return s.report(ctx, record, binding.Conflict,
"凭据创建冲突或结果不确定;请核对固定位置的版本历史,未认领、覆盖或重新生成密码")
}
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)
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, "已确认凭据消失、版本变化或内容无效;请人工核实,未生成替代密码")
}
if err != nil {
return s.report(ctx, record, binding.DependencyUnavailable, "已确认凭据暂时无法读取;保留确认版本,等待依赖恢复")
}
if !credential.MatchesTarget(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint) {
return s.report(ctx, record, binding.Conflict, "已确认凭据与当前 Instance/database/loginRole 不一致;请人工核实,未修改凭据")
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
status := record.Status
status.Ready, status.Reason = true, CredentialPrepared
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 {
status := record.Status
status.Ready, status.Reason = false, reason
status.Message = fmt.Sprintf("Database %s:%s", record.Database.Identity.Name, message)
_, err := s.Resources.Save(ctx, record, status)
return err
}
@@ -0,0 +1,168 @@
package application
import (
"context"
"errors"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const (
preparationInstanceName = "credential-instance"
preparationInstanceUID = "credential-instance-uid"
)
func preparationRecord(t *testing.T) *CredentialRecord {
t.Helper()
tenant := binding.TenantIdentity{Namespace: bindingTestNamespace, Name: bindingTestName, UID: "credential-tenant-uid"}
database := binding.Identity{Name: binding.DynamicDatabaseName(tenant.UID), UID: "credential-database-uid"}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432, ManagementDatabase: "management", TLSMode: instance.TLSVerifyFull,
})
if err != nil {
t.Fatal(err)
}
return &CredentialRecord{
Database: BindingDatabase{Database: binding.Database{
Identity: database, Instance: preparationInstanceName, InstanceUID: preparationInstanceUID, Name: bindingTestName, LoginRole: bindingTestName, Source: "Provision", Tenant: &tenant,
}},
Tenant: &BindingTenant{
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},
DatabaseProtected: true, TenantProtected: true,
}
}
type memoryCredentialResources struct {
record *CredentialRecord
saveError error
checkError error
}
func (r *memoryCredentialResources) Load(context.Context, string) (*CredentialRecord, error) {
copy := *r.record
return &copy, nil
}
func (r *memoryCredentialResources) Save(_ context.Context, record *CredentialRecord, status CredentialStatus) (*CredentialRecord, error) {
if r.saveError != nil {
return nil, r.saveError
}
copy := *record
copy.Status = status
r.record = &copy
return &copy, nil
}
func (r *memoryCredentialResources) CheckCurrent(context.Context, *CredentialRecord) error {
return r.checkError
}
type preparationStore struct {
reads int
creates int
readError error
createError error
}
func (*preparationStore) ProvisionLocation(uid string) (CredentialLocation, error) {
return CredentialLocation{Mount: "applications", Path: "database/" + uid}, nil
}
func (s *preparationStore) ReadCredential(context.Context, CredentialLocation, int64) (ApplicationCredential, error) {
s.reads++
return ApplicationCredential{}, s.readError
}
func (s *preparationStore) CreateCredential(context.Context, CredentialLocation, ApplicationCredential) (int64, error) {
s.creates++
if s.createError != nil {
return 0, s.createError
}
return 1, nil
}
func TestCredentialPreparationGates(t *testing.T) {
for _, test := range []struct {
name string
change func(*CredentialRecord)
}{
{"导入不供应", func(r *CredentialRecord) { r.Database.Source = "Import" }},
{"删除中的资源", func(r *CredentialRecord) { r.Database.Deleting = true }},
{"已释放资源", func(r *CredentialRecord) { r.Database.Phase = "Released" }},
{"缺少资源保护", func(r *CredentialRecord) { r.DatabaseProtected = false }},
{"缺少申请保护", func(r *CredentialRecord) { r.TenantProtected = false }},
{"单向绑定", func(r *CredentialRecord) { r.Tenant.Database = nil }},
{"删除中的申请", func(r *CredentialRecord) { r.Tenant.Deleting = true }},
{"申请尚未Bound", func(r *CredentialRecord) { r.Tenant.Phase = binding.Binding }},
{"旧申请身份", func(r *CredentialRecord) { r.Tenant.Identity.UID = "new-tenant" }},
{"旧资源身份", func(r *CredentialRecord) { r.Tenant.Database.UID = "new-database" }},
{"Instance未出现", func(r *CredentialRecord) { r.Instance = nil }},
{"Instance正在删除", func(r *CredentialRecord) { r.Instance.Deleting = true }},
{"Instance未Ready", func(r *CredentialRecord) { r.Instance.Ready = false }},
{"Instance身份未记录", func(r *CredentialRecord) { r.Database.InstanceUID = "" }},
{"Instance同名重建", func(r *CredentialRecord) { r.Instance.Identity.UID = "new-instance" }},
{"目标不一致", func(r *CredentialRecord) { r.Database.LoginRole = "another_owner" }},
} {
t.Run(test.name, func(t *testing.T) {
record := preparationRecord(t)
test.change(record)
resources := &memoryCredentialResources{record: record}
store := &preparationStore{readError: ErrCredentialNotFound}
if err := (CredentialPreparation{Resources: resources, Store: store}).Reconcile(t.Context(), record.Database.Identity.Name); err != nil {
t.Fatal(err)
}
if store.reads != 0 || store.creates != 0 || resources.record.Status.Version != 0 {
t.Fatal("前置条件不满足时不得读取、创建或确认凭据")
}
})
}
}
func TestCredentialPreparationWriteBoundary(t *testing.T) {
for _, test := range []struct {
name string
saveError bool
stale bool
createError error
wantError bool
wantCreates int
wantVersion int64
}{
{name: "位置无法保存", saveError: true, wantError: true},
{name: "外部操作前快照变化", stale: true, wantError: true},
{name: "明确权限拒绝", createError: ErrCredentialUnavailable, wantCreates: 1},
{name: "创建结果不确定", createError: ErrCredentialUncertain, wantCreates: 1},
{name: "并发创建冲突", createError: ErrCredentialConflict, wantCreates: 1},
{name: "创建与回读成功", wantCreates: 1, wantVersion: 1},
} {
t.Run(test.name, func(t *testing.T) {
resources := &memoryCredentialResources{record: preparationRecord(t)}
if test.saveError {
resources.saveError = errors.New("fixture write failure")
}
if test.stale {
resources.checkError = errors.New("fixture stale observation")
}
store := &preparationStore{readError: ErrCredentialNotFound, createError: test.createError}
service := CredentialPreparation{Resources: resources, Store: store}
err := service.Reconcile(t.Context(), resources.record.Database.Identity.Name)
if (err != nil) != test.wantError || store.creates != test.wantCreates || resources.record.Status.Version != test.wantVersion {
t.Fatal("外部写入边界或确认时机不符合预期")
}
if test.createError == ErrCredentialUncertain || test.stale {
resources.checkError = nil
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 {
t.Fatal("未确认创建重入时不得生成替代密码")
}
}
})
}
}
@@ -0,0 +1,27 @@
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)
}