feat: 接入管理 Secret 凭据与连接刷新
Verify / test (pull_request) Successful in 4m45s
Verify / lint (pull_request) Successful in 7m5s
Verify / database-integration (pull_request) Failing after 11m1s

This commit is contained in:
2026-09-21 08:35:24 +00:00
parent 341c095a97
commit c110aceb0f
15 changed files with 1399 additions and 1 deletions
@@ -0,0 +1,58 @@
/*
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 定义 Database 用例与适配器之间的边界。
package application
import (
"context"
"errors"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var (
ErrCredentialsUnavailable = errors.New("management credentials unavailable")
ErrCredentialsInvalid = errors.New("management credentials invalid")
)
// Credentials 只存在于应用与连接适配器内存,不进入领域对象或持久化状态。
type Credentials struct {
username string
password string
}
func NewCredentials(username, password string) (Credentials, error) {
if username == "" || password == "" {
return Credentials{}, ErrCredentialsInvalid
}
return Credentials{username: username, password: password}, nil
}
func (c Credentials) Username() string { return c.username }
func (c Credentials) Password() string { return c.password }
func (c Credentials) String() string { return "[redacted management credentials]" }
func (c Credentials) GoString() string { return c.String() }
// MarshalJSON 显式隐藏内容,避免未来字段调整意外改变日志或序列化行为。
func (c Credentials) MarshalJSON() ([]byte, error) {
return []byte(`"[redacted management credentials]"`), nil
}
// CredentialReader 返回本次读取的有效值;metadata 不参与凭据相等比较。
type CredentialReader interface {
Read(context.Context, instance.CredentialReference) (Credentials, error)
}
@@ -0,0 +1,70 @@
/*
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 (
"encoding/json"
"fmt"
"strings"
"testing"
)
const testUsername = "test-user"
func TestCredentialsRejectEmptyValues(t *testing.T) {
for _, values := range [][2]string{
{"", "test-password"},
{testUsername, ""},
{"", ""},
} {
if _, err := NewCredentials(values[0], values[1]); err != ErrCredentialsInvalid {
t.Fatal("empty credential was accepted")
}
}
}
func TestCredentialAndServiceFormattingIsRedacted(t *testing.T) {
const canary = "SECRET-CANARY-never-log-this"
credentials, err := NewCredentials(canary, canary)
if err != nil {
t.Fatal(err)
}
if credentials.Username() != canary || credentials.Password() != canary {
t.Fatal("explicit credential access changed values")
}
service, err := NewInstanceService(&sourceStub{credentials: credentials}, &connectorStub{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(service.Close)
encoded, err := json.Marshal(credentials)
if err != nil {
t.Fatal(err)
}
outputs := []string{
string(encoded),
fmt.Sprintf("%v %+v %#v", credentials, credentials, credentials),
fmt.Sprintf("%v %+v %#v", service, service, service),
}
for _, output := range outputs {
if strings.Contains(output, canary) {
t.Fatal("formatting leaked credential data")
}
}
}
@@ -0,0 +1,171 @@
/*
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 (
"context"
"errors"
"sync"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var (
ErrConnection = errors.New("management connection unavailable")
ErrAuthentication = errors.New("management authentication failed")
ErrObservation = errors.New("management observation failed")
ErrCredentialsChanged = errors.New("management credentials changed during observation")
ErrClosed = errors.New("instance service closed")
)
// Database 与 Connector 复用原项目 internal/instance/service.go 的能力边界。
// 版本查询只是本切片的连通性观察,不能产生领域 Ready。
type Database interface {
Version(context.Context) (string, error)
Close()
}
type Connector interface {
Connect(context.Context, instance.Endpoint, Credentials) (Database, error)
}
type entry struct {
target instance.ObservationTarget
credentials Credentials
database Database
}
// InstanceService 由原 Service 迁移:连接复用与释放属于应用装配,不属于 SQL adapter。
// 保留原实现串行操作的约束,防止 Close 与查询并发;controller 停止 worker 后调用 Close。
// 不缓存能力观察,不把连接存活等同于 Ready。凭据每轮重新读取,而非只在引用变化时读取。
type InstanceService struct {
mu sync.Mutex
source CredentialReader
connector Connector
entries map[string]*entry
closed bool
}
func NewInstanceService(source CredentialReader, connector Connector) (*InstanceService, error) {
if source == nil || connector == nil {
return nil, errors.New("credential source and connector required")
}
return &InstanceService{
source: source,
connector: connector,
entries: make(map[string]*entry),
}, nil
}
func (s *InstanceService) String() string { return "[redacted instance service]" }
func (s *InstanceService) GoString() string { return s.String() }
// ObserveVersion 返回当前目标和凭据下的版本;任何失败均返回空结果。
// 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。
func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.ObservationTarget) (string, error) {
if err := target.Validate(); err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return "", ErrClosed
}
if err := ctx.Err(); err != nil {
return "", err
}
// 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。
name := target.Identity().Name()
credentials, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return "", credentialError(err)
}
if credentials.username == "" || credentials.password == "" {
s.release(name)
return "", ErrCredentialsInvalid
}
// 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。
current := s.entries[name]
if current != nil && (current.target.Identity() != target.Identity() ||
current.target.Definition() != target.Definition() || current.credentials != credentials) {
s.release(name)
current = nil
}
if current == nil {
database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials)
if err != nil {
return "", err
}
current = &entry{
target: target,
credentials: credentials,
database: database,
}
s.entries[name] = current
}
version, err := current.database.Version(ctx)
if err != nil {
s.release(name)
return "", err
}
// 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。
latest, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return "", credentialError(err)
}
if latest != credentials {
s.release(name)
return "", ErrCredentialsChanged
}
return version, nil
}
func credentialError(err error) error {
if errors.Is(err, ErrCredentialsInvalid) {
return ErrCredentialsInvalid
}
return ErrCredentialsUnavailable
}
// Forget 只释放本地连接;不删除数据库或 registry,不替代 Instance finalizer。
func (s *InstanceService) Forget(name string) {
s.mu.Lock()
defer s.mu.Unlock()
s.release(name)
}
func (s *InstanceService) release(name string) {
if current := s.entries[name]; current != nil {
current.database.Close()
}
delete(s.entries, name)
}
func (s *InstanceService) Close() {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = true
for name := range s.entries {
s.release(name)
}
}
@@ -0,0 +1,182 @@
/*
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 (
"context"
"errors"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
// 延续源项目 Service 测试,用于穷举身份与装配失败;真实行为由 adapter 集成测试验证。
type sourceStub struct {
credentials Credentials
err error
}
func (s *sourceStub) Read(context.Context, instance.CredentialReference) (Credentials, error) {
return s.credentials, s.err
}
type databaseStub struct {
closes int
err error
}
func (d *databaseStub) Version(context.Context) (string, error) { return "17", d.err }
func (d *databaseStub) Close() {
d.closes++
}
type connectorStub struct {
databases []*databaseStub
err error
}
func (c *connectorStub) Connect(context.Context, instance.Endpoint, Credentials) (Database, error) {
if c.err != nil {
return nil, c.err
}
db := &databaseStub{}
c.databases = append(c.databases, db)
return db, nil
}
func serviceTarget(t *testing.T, uid, host, secret string, generation int64) instance.ObservationTarget {
t.Helper()
id, err := instance.NewIdentity(uid, "shared")
if err != nil {
t.Fatal(err)
}
revision, err := instance.NewRevision(generation)
if err != nil {
t.Fatal(err)
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: host,
HostAddr: "127.0.0.1",
Port: 5432,
ManagementDatabase: "postgres",
TLSMode: instance.TLSDisable,
})
if err != nil {
t.Fatal(err)
}
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: secret,
UsernameKey: "user",
PasswordKey: "pass",
})
if err != nil {
t.Fatal(err)
}
definition, err := instance.NewDefinition(endpoint, ref)
if err != nil {
t.Fatal(err)
}
target, err := instance.NewObservationTarget(id, revision, definition)
if err != nil {
t.Fatal(err)
}
return target
}
func TestInstanceConnectionIdentity(t *testing.T) {
source := &sourceStub{credentials: Credentials{username: testUsername, password: "test-only"}}
connector := &connectorStub{}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
ctx := context.Background()
cases := []struct {
name string
target instance.ObservationTarget
wantConnections int
}{
{"initial connection", serviceTarget(t, "uid-1", "first", "admin", 1), 1},
{"generation alone", serviceTarget(t, "uid-1", "first", "admin", 2), 1},
{"endpoint changed", serviceTarget(t, "uid-1", "second", "admin", 3), 2},
{"reference changed", serviceTarget(t, "uid-1", "second", "replacement", 4), 3},
{"same name with new UID", serviceTarget(t, "uid-2", "second", "replacement", 1), 4},
}
for _, testCase := range cases {
if _, err := service.ObserveVersion(ctx, testCase.target); err != nil {
t.Fatal(err)
}
if len(connector.databases) != testCase.wantConnections {
t.Fatalf("%s: got %d connections, want %d", testCase.name, len(connector.databases), testCase.wantConnections)
}
}
for _, db := range connector.databases[:3] {
if db.closes != 1 {
t.Fatal("replaced connection not closed exactly once")
}
}
service.Forget("shared")
service.Forget("shared")
if connector.databases[3].closes != 1 {
t.Fatal("forget did not close exactly once")
}
}
func TestInstanceAssemblyFailureRecovery(t *testing.T) {
ctx := context.Background()
target := serviceTarget(t, "uid-1", "first", "admin", 1)
source := &sourceStub{
credentials: Credentials{username: testUsername, password: "test-only"},
err: errors.New("unsafe source error"),
}
connector := &connectorStub{err: ErrConnection}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrCredentialsUnavailable) {
t.Fatal("unsafe source error escaped")
}
source.err = nil
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrConnection) {
t.Fatal("connection failure returned evidence")
}
connector.err = nil
if _, err := service.ObserveVersion(ctx, target); err != nil {
t.Fatal(err)
}
connector.databases[0].err = ErrObservation
if version, err := service.ObserveVersion(ctx, target); version != "" || !errors.Is(err, ErrObservation) {
t.Fatal("failed query returned evidence")
}
if connector.databases[0].closes != 1 {
t.Fatal("failed connection retained")
}
if _, err := service.ObserveVersion(ctx, target); err != nil {
t.Fatal("retry failed", err)
}
service.Close()
service.Close()
if connector.databases[1].closes != 1 {
t.Fatal("shutdown did not close once")
}
if _, err := service.ObserveVersion(ctx, target); !errors.Is(err, ErrClosed) {
t.Fatal("closed service accepted work")
}
}