feat: 接通 PostgreSQL 角色与数据库创建闭环
Verify / test (pull_request) Successful in 9m19s
Verify / lint (pull_request) Successful in 10m19s
Verify / database-integration (pull_request) Successful in 12m12s

This commit is contained in:
2026-09-29 16:04:52 +00:00
parent d71285a3f4
commit e2016d3727
29 changed files with 1494 additions and 58 deletions
@@ -0,0 +1,184 @@
//go:build integration
package postgresql_test
import (
"context"
"os/exec"
"regexp"
"strings"
"testing"
"time"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/provisioning"
bao "github.com/openbao/openbao/api/v2"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
type provisioningFixture struct {
*credentialFixture
api client.Client
scheme *runtime.Scheme
store *openbao.Credentials
resources *kubernetes.ProvisioningResources
usecase application.DatabaseProvisioning
}
func newProvisioningFixture(t *testing.T) *provisioningFixture {
t.Helper()
f := newCredentialFixture(t)
useNativeManager(t, f)
if _, err := envtest.InstallCRDs(f.config, envtest.CRDInstallOptions{Paths: []string{"../../../../config/crd/bases"}, ErrorIfPathMissing: true}); err != nil {
t.Fatal(err)
}
scheme := runtime.NewScheme()
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
api, err := client.New(f.config, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
object := &databasev1alpha1.PostgreSQLInstance{Name: "supply-instance", Spec: databasev1alpha1.PostgreSQLInstanceSpec{
Endpoint: databasev1alpha1.PostgreSQLEndpoint{Host: fixtureHost, HostAddr: fixtureAddress, Port: int32(f.port), SSLMode: "disable"},
AdminCredentialRef: databasev1alpha1.AdminCredentialReference{Name: secretName, UsernameKey: managementUsernameKey, PasswordKey: managementPasswordKey},
}}
if err := api.Create(f.ctx, object); err != nil {
t.Fatal(err)
}
instanceResources := &kubernetes.InstanceResources{Client: api, Reader: api}
instanceService := &application.InstanceReconciliation{Resources: instanceResources, Observer: f.service}
result, err := instanceService.Reconcile(f.ctx, object.Name)
if err != nil {
t.Fatal(err)
}
if err := instanceResources.PresentInstance(f.ctx, result); err != nil {
t.Fatal(err)
}
if err := api.Get(f.ctx, client.ObjectKeyFromObject(object), object); err != nil {
t.Fatal(err)
}
if !meta.IsStatusConditionTrue(object.Status.Conditions, "Ready") {
t.Fatal("实际管理账号未就绪")
}
store := provisioningBao(t)
resources := &kubernetes.ProvisioningResources{Client: api, Reader: api}
return &provisioningFixture{credentialFixture: f, api: api, scheme: scheme, store: store, resources: resources,
usecase: application.DatabaseProvisioning{Resources: resources, Credentials: store, Backend: f.service}}
}
// 三后端组合验收只启动自己的 dev Bao;不读取环境 token 或外部地址。
func provisioningBao(t *testing.T) *openbao.Credentials {
t.Helper()
const image = "openbao/openbao@sha256:5b2486ab0fb90bbc788cc345b0a08616dfb375873ee8be5df3a2fd4d378a67e0"
const token = "AYATORI-TEST-ONLY-supply-token"
prepare, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
defer cancel()
if exec.CommandContext(prepare, "docker", "image", "inspect", image).Run() != nil {
if output, err := exec.CommandContext(prepare, "docker", "pull", image).CombinedOutput(); err != nil {
t.Fatalf("隔离 Bao 镜像准备失败:%s", output)
}
}
ctx, stop := context.WithTimeout(t.Context(), time.Minute)
defer stop()
output, err := exec.CommandContext(ctx, "docker", "run", "--pull=never", "--rm", "-d", "-p", "127.0.0.1::8200", image,
"server", "-dev", "-dev-root-token-id="+token, "-dev-listen-address=0.0.0.0:8200").Output()
if err != nil {
t.Fatal("隔离 Bao 启动失败")
}
id := strings.TrimSpace(string(output))
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
t.Fatal("无效容器 ID")
}
t.Cleanup(func() {
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
t.Error("隔离 Bao 清理失败")
}
})
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format", `{{(index (index .NetworkSettings.Ports "8200/tcp") 0).HostPort}}`, id).Output()
if err != nil {
t.Fatal("隔离 Bao 端口不可读")
}
backend, err := bao.NewClient(&bao.Config{Address: "http://127.0.0.1:" + strings.TrimSpace(string(output)), Timeout: 5 * time.Second})
if err != nil {
t.Fatal("隔离 Bao client 构造失败")
}
backend.SetToken(token)
for {
if _, err := backend.Sys().HealthWithContext(ctx); err == nil {
break
}
select {
case <-ctx.Done():
t.Fatal("隔离 Bao 健康检查超时")
case <-time.After(100 * time.Millisecond):
}
}
store, err := openbao.NewCredentials(backend, "secret", "applications")
if err != nil {
t.Fatal(err)
}
return store
}
func (f *provisioningFixture) bound(t *testing.T, name string) *databasev1alpha1.PostgreSQLDatabase {
t.Helper()
tenant := &databasev1alpha1.PostgreSQLTenant{Name: strings.ReplaceAll(name, "_", "-"), Namespace: controllerNamespace,
Spec: databasev1alpha1.PostgreSQLTenantSpec{Provision: &databasev1alpha1.DatabaseProvisionRequest{
InstanceRef: databasev1alpha1.InstanceReference{Name: "supply-instance"},
Database: databasev1alpha1.PostgreSQLIdentifier(name), LoginRole: databasev1alpha1.PostgreSQLIdentifier(name),
}}}
if err := f.api.Create(f.ctx, tenant); err != nil {
t.Fatal(err)
}
resources := &kubernetes.BindingResources{Client: f.api, Reader: f.api}
binder := databasecontroller.NewBindingReconciler(f.api, &application.BindingService{Resources: resources}, resources)
if _, err := binder.Reconcile(f.ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)}); err != nil {
t.Fatal(err)
}
if err := f.api.Get(f.ctx, client.ObjectKeyFromObject(tenant), tenant); err != nil {
t.Fatal(err)
}
database := &databasev1alpha1.PostgreSQLDatabase{}
if err := f.api.Get(f.ctx, client.ObjectKey{Name: string(tenant.Status.DatabaseRef.Name)}, database); err != nil {
t.Fatal(err)
}
prepare := application.CredentialPreparation{Resources: &kubernetes.CredentialResources{Client: f.api, Reader: f.api}, Store: f.store}
if err := prepare.Reconcile(f.ctx, database.Name); err != nil {
t.Fatal(err)
}
return database
}
func (f *provisioningFixture) reconcile(t *testing.T, database *databasev1alpha1.PostgreSQLDatabase, phase provisioning.Phase) {
t.Helper()
if err := f.usecase.Reconcile(f.ctx, database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, phase)
}
func (f *provisioningFixture) status(t *testing.T, database *databasev1alpha1.PostgreSQLDatabase, phase provisioning.Phase) {
t.Helper()
if err := f.api.Get(f.ctx, client.ObjectKeyFromObject(database), database); err != nil {
t.Fatal(err)
}
condition := meta.FindStatusCondition(database.Status.Conditions, "ResourcesReady")
if condition == nil || condition.Reason != string(phase) {
t.Fatalf("期望资源阶段 %s,实际 %v", phase, condition)
}
if meta.IsStatusConditionTrue(database.Status.Conditions, "Ready") {
t.Fatal("角色建库不代表扩展与交付完成")
}
}
@@ -0,0 +1,243 @@
//go:build integration
package postgresql_test
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/provisioning"
"github.com/jackc/pgx/v5"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
func testProvisioningLogin(t *testing.T, f *provisioningFixture) {
t.Helper()
database := f.bound(t, "supplied")
f.reconcile(t, database, provisioning.Pending)
if database.Status.RoleOID == 0 || database.Status.DatabaseOID != 0 {
t.Fatal("角色确认未独立保存")
}
f.service.Forget("supply-instance")
f.reconcile(t, database, provisioning.Pending)
if database.Status.DatabaseOID == 0 {
t.Fatal("数据库未独立确认")
}
if got := f.queryPostgres(t, "SELECT datallowconn FROM pg_database WHERE datname='supplied'"); got != "f" {
t.Fatal("ACL 收紧前连接入口应关闭")
}
f.reconcile(t, database, provisioning.Available)
beforeRole, beforeDB := database.Status.RoleOID, database.Status.DatabaseOID
for range 2 {
f.reconcile(t, database, provisioning.Available)
}
if beforeRole != database.Status.RoleOID || beforeDB != database.Status.DatabaseOID {
t.Fatal("幂等协调替换了对象")
}
value, err := f.store.ReadCredential(f.ctx, credential.Location{Mount: database.Status.CredentialRef.Mount, Path: database.Status.CredentialRef.Path}, database.Status.CredentialVersion)
if err != nil {
t.Fatal(err)
}
config, err := pgx.ParseConfig("")
if err != nil {
t.Fatal("fixture 配置失败")
}
config.Host, config.Port = fixtureAddress, uint16(f.port)
config.User, config.Database, config.Password = "supplied", "supplied", value.SecretData()["password"].(string)
config.TLSConfig, config.Fallbacks = nil, nil
connection, err := pgx.ConnectConfig(f.ctx, config)
if err != nil {
t.Fatal("实际应用密码不能登录已供应数据库")
}
defer func() { _ = connection.Close(context.Background()) }()
if _, err := connection.Exec(f.ctx, "CREATE TABLE app_data (id integer)"); err != nil {
t.Fatal("应用 owner 不能创建表")
}
if _, err := connection.Exec(f.ctx, "CREATE ROLE should_be_denied"); err == nil {
t.Fatal("应用具有 CREATEROLE")
}
if _, err := connection.Exec(f.ctx, "CREATE DATABASE should_be_denied"); err == nil {
t.Fatal("应用具有 CREATEDB")
}
f.queryPostgres(t, "CREATE ROLE unrelated_login LOGIN PASSWORD '"+fixturePassword+"'")
config.User, config.Password = "unrelated_login", fixturePassword
if outsider, err := pgx.ConnectConfig(f.ctx, config); err == nil {
_ = outsider.Close(f.ctx)
t.Fatal("其他账号可连接受管数据库")
}
}
func TestDatabaseProvisioningWithRealBackends(t *testing.T) {
f := newProvisioningFixture(t)
t.Run("创建重启幂等与实际登录", func(t *testing.T) { testProvisioningLogin(t, f) })
t.Run("未知同名不认领", func(t *testing.T) {
database := f.bound(t, "occupied")
f.queryPostgres(t, "CREATE ROLE occupied LOGIN")
before := f.queryPostgres(t, "SELECT oid FROM pg_roles WHERE rolname='occupied'")
f.reconcile(t, database, provisioning.Conflict)
f.reconcile(t, database, provisioning.Conflict)
if database.Status.RoleOID != 0 || f.queryPostgres(t, "SELECT oid FROM pg_roles WHERE rolname='occupied'") != before {
t.Fatal("未知角色被认领或修改")
}
})
t.Run("未知同名数据库不认领", func(t *testing.T) {
database := f.bound(t, "occupied_database")
f.queryPostgres(t, "CREATE DATABASE occupied_database")
before := f.queryPostgres(t, "SELECT oid FROM pg_database WHERE datname='occupied_database'")
f.reconcile(t, database, provisioning.Conflict)
if database.Status.RoleOID != 0 || database.Status.DatabaseOID != 0 ||
f.queryPostgres(t, "SELECT oid FROM pg_database WHERE datname='occupied_database'") != before ||
f.queryPostgres(t, "SELECT count(*) FROM pg_roles WHERE rolname='occupied_database'") != "0" {
t.Fatal("未知数据库被认领、修改或继续创建了角色")
}
})
t.Run("已确认角色后权限恢复", func(t *testing.T) {
database := f.bound(t, "permission_restore")
f.reconcile(t, database, provisioning.Pending)
f.queryPostgres(t, "ALTER ROLE native_manager NOCREATEDB")
f.reconcile(t, database, provisioning.Unavailable)
f.queryPostgres(t, "ALTER ROLE native_manager CREATEDB")
f.reconcile(t, database, provisioning.Pending)
f.reconcile(t, database, provisioning.Available)
})
t.Run("删除前置阻止写入", func(t *testing.T) {
database := f.bound(t, "deleting_supply")
if err := f.api.Delete(f.ctx, database); err != nil {
t.Fatal(err)
}
f.reconcile(t, database, provisioning.Stopped)
if len(database.Finalizers) == 0 || database.Status.RoleOID != 0 {
t.Fatal("删除边界被供应绕过")
}
})
t.Run("确认保存失败转人工冲突", func(t *testing.T) {
database := f.bound(t, "lost_confirmation")
failed := f.usecase
failed.Resources = &failRoleConfirmation{ProvisioningResources: f.resources}
if err := failed.Reconcile(f.ctx, database.Name); err == nil {
t.Fatal("未注入确认写入故障")
}
f.reconcile(t, database, provisioning.Conflict)
if database.Status.RoleOID != 0 || f.queryPostgres(t, "SELECT count(*) FROM pg_roles WHERE rolname='lost_confirmation'") != "1" {
t.Fatal("失败恢复认领或清理了残留角色")
}
})
t.Run("数据库确认失败保留关闭入口", func(t *testing.T) {
database := f.bound(t, "lost_database_confirmation")
f.reconcile(t, database, provisioning.Pending)
failed := f.usecase
failed.Resources = &failDatabaseConfirmation{ProvisioningResources: f.resources}
if err := failed.Reconcile(f.ctx, database.Name); err == nil {
t.Fatal("未注入数据库确认写入故障")
}
f.reconcile(t, database, provisioning.Conflict)
if database.Status.RoleOID == 0 || database.Status.DatabaseOID != 0 || f.queryPostgres(t, "SELECT datallowconn FROM pg_database WHERE datname='lost_database_confirmation'") != "f" {
t.Fatal("未确认数据库被认领或开放连接")
}
})
t.Run("创建成功但调用方丢失结果", func(t *testing.T) {
database := f.bound(t, "lost_role_response")
failed := f.usecase
failed.Backend = &lostRoleResponse{ProvisioningBackend: f.service}
if err := failed.Reconcile(f.ctx, database.Name); err != nil {
t.Fatal(err)
}
f.reconcile(t, database, provisioning.Conflict)
if database.Status.RoleOID != 0 || f.queryPostgres(t, "SELECT count(*) FROM pg_roles WHERE rolname='lost_role_response'") != "1" {
t.Fatal("丢失结果后应保留未认领角色")
}
})
t.Run("同名角色被重建", func(t *testing.T) {
database := f.bound(t, "replaced_role")
f.reconcile(t, database, provisioning.Pending)
f.queryPostgres(t, "DROP ROLE replaced_role; CREATE ROLE replaced_role LOGIN")
f.reconcile(t, database, provisioning.Conflict)
if database.Status.DatabaseOID != 0 {
t.Fatal("重建角色被当成原 owner 继续供应")
}
})
t.Run("创建步骤并发只有一个获准", func(t *testing.T) {
database := f.bound(t, "concurrent_supply")
service := f.usecase
resources := &concurrentProvisioningResources{ProvisioningResources: f.resources}
resources.loaded.Add(2)
service.Resources = resources
results := make(chan error, 2)
for range 2 {
go func() { results <- service.Reconcile(f.ctx, database.Name) }()
}
success, conflict := 0, 0
for range 2 {
err := <-results
if err == nil {
success++
} else if apierrors.IsConflict(err) {
conflict++
} else {
t.Fatal(err)
}
}
if success != 1 || conflict != 1 {
t.Fatal("同一快照不得重复授权创建")
}
f.status(t, database, provisioning.Pending)
if database.Status.RoleOID == 0 {
t.Fatal("胜方未确认角色")
}
})
t.Run("实际manager观察与重启", func(t *testing.T) { testProvisioningManager(t, f) })
}
type failRoleConfirmation struct {
application.ProvisioningResources
}
func (r *failRoleConfirmation) Save(ctx context.Context, record *application.ProvisioningRecord, state provisioning.State) (*application.ProvisioningRecord, error) {
if state.RoleOID != 0 {
return nil, errors.New("injected confirmation persistence failure")
}
return r.ProvisioningResources.Save(ctx, record, state)
}
type failDatabaseConfirmation struct {
application.ProvisioningResources
}
func (r *failDatabaseConfirmation) Save(ctx context.Context, record *application.ProvisioningRecord, state provisioning.State) (*application.ProvisioningRecord, error) {
if state.DatabaseOID != 0 {
return nil, errors.New("injected database confirmation persistence failure")
}
return r.ProvisioningResources.Save(ctx, record, state)
}
type lostRoleResponse struct {
application.ProvisioningBackend
}
func (b *lostRoleResponse) CreateLoginRole(ctx context.Context, target instance.ObservationTarget, value credential.ApplicationCredential) (uint32, error) {
if _, err := b.ProvisioningBackend.CreateLoginRole(ctx, target, value); err != nil {
return 0, err
}
return 0, application.ErrResourceUncertain
}
type concurrentProvisioningResources struct {
application.ProvisioningResources
readers atomic.Int32
loaded sync.WaitGroup
}
func (r *concurrentProvisioningResources) Load(ctx context.Context, name string) (*application.ProvisioningRecord, error) {
record, err := r.ProvisioningResources.Load(ctx, name)
if r.readers.Add(1) <= 2 {
r.loaded.Done()
r.loaded.Wait()
}
return record, err
}
@@ -0,0 +1,97 @@
//go:build integration
package postgresql_test
import (
"context"
"sync/atomic"
"testing"
"time"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/provisioning"
"k8s.io/apimachinery/pkg/api/meta"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
)
func testProvisioningManager(t *testing.T, f *provisioningFixture) {
t.Helper()
database := f.bound(t, "watch_supply")
// 首次 list/watch 自动供应;第二个新 manager 只观察确认记录,不重新建库。
var roleOID, databaseOID int64
for range 2 {
skipRepeatedName := true
manager, err := ctrl.NewManager(f.config, ctrl.Options{Scheme: f.scheme, Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedName}})
if err != nil {
t.Fatal(err)
}
resources := &kubernetes.ProvisioningResources{Client: manager.GetClient(), Reader: manager.GetAPIReader()}
backend := &countProvisioningObservations{ProvisioningBackend: f.service}
service := &application.DatabaseReconciliation{
Credentials: &application.CredentialPreparation{Resources: &kubernetes.CredentialResources{Client: manager.GetClient(), Reader: manager.GetAPIReader()}, Store: f.store},
Provisioning: &application.DatabaseProvisioning{Resources: resources, Credentials: f.store, Backend: backend},
}
if err := databasecontroller.NewProvisioningReconciler(manager.GetClient(), service).SetupWithManager(manager); err != nil {
t.Fatal(err)
}
ctx, stop := context.WithCancel(f.ctx)
done := make(chan error, 1)
go func() { done <- manager.Start(ctx) }()
// 使用 scope 确保任何失败都先停止 worker,再由 fixture 关闭共享连接。
func() {
defer func() {
stop()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(10 * time.Second):
t.Error("供应 manager 未停止")
}
}()
if !manager.GetCache().WaitForCacheSync(ctx) {
t.Fatal("供应 cache 未同步")
}
deadline := time.After(15 * time.Second)
for {
if err := f.api.Get(f.ctx, client.ObjectKeyFromObject(database), database); err != nil {
t.Fatal(err)
}
if meta.IsStatusConditionTrue(database.Status.Conditions, "ResourcesReady") && backend.reads.Load() > 0 {
break
}
select {
case <-deadline:
t.Fatal("watch 未推动资源创建")
case <-time.After(100 * time.Millisecond):
}
}
if roleOID != 0 && (roleOID != database.Status.RoleOID || databaseOID != database.Status.DatabaseOID) {
t.Fatal("manager 重启替换已确认资源")
}
roleOID, databaseOID = database.Status.RoleOID, database.Status.DatabaseOID
}()
}
f.status(t, database, provisioning.Available)
}
type countProvisioningObservations struct {
application.ProvisioningBackend
reads atomic.Int32
}
func (b *countProvisioningObservations) InspectResources(ctx context.Context, target instance.ObservationTarget, name, role string) (provisioning.Observation, error) {
observation, err := b.ProvisioningBackend.InspectResources(ctx, target, name, role)
if err == nil && name == "watch_supply" {
b.reads.Add(1)
}
return observation, err
}
@@ -0,0 +1,159 @@
package postgresql
import (
"context"
"errors"
"regexp"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/provisioning"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var resourceIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
const inspectResourcesSQL = `
SELECT COALESCE(r.oid, 0), COALESCE(d.oid, 0),
COALESCE(r.rolcanlogin AND NOT (r.rolsuper OR r.rolcreatedb OR r.rolcreaterole OR r.rolreplication OR r.rolbypassrls)
AND NOT EXISTS (SELECT FROM pg_catalog.pg_auth_members m WHERE m.member = r.oid), false),
COALESCE(d.datdba, 0), COALESCE(d.datallowconn, false),
EXISTS (SELECT FROM pg_catalog.aclexplode(COALESCE(d.datacl, pg_catalog.acldefault('d', d.datdba))) a
WHERE a.grantee = 0 AND a.privilege_type = 'CONNECT')
FROM (SELECT 1) seed
LEFT JOIN pg_catalog.pg_roles r ON r.rolname = $1
LEFT JOIN pg_catalog.pg_database d ON d.datname = $2`
type resourceReader interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func inspectResources(ctx context.Context, reader resourceReader, name, role string) (provisioning.Observation, error) {
var result provisioning.Observation
if !resourceIdentifier.MatchString(name) || !resourceIdentifier.MatchString(role) {
return result, application.ErrResourceConflict
}
err := reader.QueryRow(ctx, inspectResourcesSQL, role, name).Scan(
&result.RoleOID, &result.DatabaseOID, &result.RoleSafe, &result.OwnerOID, &result.AllowConnections, &result.PublicConnect,
)
if err != nil {
return provisioning.Observation{}, application.ErrResourceUnavailable
}
return result, nil
}
func (d *database) InspectResources(ctx context.Context, name, role string) (provisioning.Observation, error) {
return inspectResources(ctx, d.pool, name, role)
}
// CREATE ROLE 与 membership 在一个原生事务提交;不修改任何已有角色。
// simple protocol 使用 pgx 的参数转义,避免自行拼接密码字面量;错误不带 SQL 或驱动响应。
func (d *database) CreateLoginRole(ctx context.Context, value credential.ApplicationCredential) (uint32, error) {
if value.Validate() != nil {
return 0, application.ErrResourceConflict
}
data := value.SecretData()
role := data["username"].(string)
transaction, err := d.pool.Begin(ctx)
if err != nil {
return 0, application.ErrResourceUnavailable
}
defer func() { _ = transaction.Rollback(ctx) }()
statement := "CREATE ROLE " + pgx.Identifier{role}.Sanitize() + " LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT PASSWORD $1"
if _, err := transaction.Exec(ctx, statement, pgx.QueryExecModeSimpleProtocol, data["password"]); err != nil {
return 0, creationError(err)
}
if _, err := transaction.Exec(ctx, "GRANT "+pgx.Identifier{role}.Sanitize()+" TO CURRENT_USER WITH SET TRUE, INHERIT FALSE"); err != nil {
return 0, application.ErrResourceUncertain
}
var oid uint32
if err := transaction.QueryRow(ctx, "SELECT oid FROM pg_catalog.pg_roles WHERE rolname=$1", role).Scan(&oid); err != nil {
return 0, application.ErrResourceUncertain
}
if err := transaction.Commit(ctx); err != nil {
return 0, application.ErrResourceUncertain
}
observed, err := d.InspectResources(ctx, data["database"].(string), role)
if err != nil || observed.RoleOID != oid || !observed.RoleSafe {
return 0, application.ErrResourceUncertain
}
return oid, nil
}
func (d *database) CreateOwnedDatabase(ctx context.Context, name, role string, roleOID uint32) (uint32, error) {
observed, err := d.InspectResources(ctx, name, role)
if err != nil {
return 0, err
}
if roleOID == 0 || observed.RoleOID != roleOID || !observed.RoleSafe || observed.DatabaseOID != 0 {
return 0, application.ErrResourceConflict
}
// 不能放入事务。先关闭连接入口,避免默认 PUBLIC CONNECT 暴露未收紧的数据库。
statement := "CREATE DATABASE " + pgx.Identifier{name}.Sanitize() + " OWNER " + pgx.Identifier{role}.Sanitize() + " ALLOW_CONNECTIONS false"
if _, err := d.pool.Exec(ctx, statement); err != nil {
return 0, creationError(err)
}
observed, err = d.InspectResources(ctx, name, role)
if err != nil || observed.DatabaseOID == 0 || observed.OwnerOID != roleOID || observed.RoleOID != roleOID {
return 0, application.ErrResourceUncertain
}
return observed.DatabaseOID, nil
}
func (d *database) ConfigureAccess(ctx context.Context, name, role string, state provisioning.State) error {
transaction, err := d.pool.Begin(ctx)
if err != nil {
return application.ErrResourceUnavailable
}
defer func() { _ = transaction.Rollback(ctx) }()
observed, err := inspectResources(ctx, transaction, name, role)
if err != nil {
return err
}
if state.RoleOID == 0 || state.DatabaseOID == 0 || state.Check(observed) != nil {
return application.ErrResourceConflict
}
if _, err := transaction.Exec(ctx, "SET LOCAL ROLE "+pgx.Identifier{role}.Sanitize()); err != nil {
return application.ErrResourceUnavailable
}
for _, statement := range []string{
"REVOKE CONNECT ON DATABASE " + pgx.Identifier{name}.Sanitize() + " FROM PUBLIC",
"GRANT CONNECT ON DATABASE " + pgx.Identifier{name}.Sanitize() + " TO " + pgx.Identifier{role}.Sanitize(),
"ALTER DATABASE " + pgx.Identifier{name}.Sanitize() + " ALLOW_CONNECTIONS true",
} {
if _, err := transaction.Exec(ctx, statement); err != nil {
return application.ErrResourceUnavailable
}
}
// SET LOCAL 不污染池内连接;ACL 失败可在已确认对象上幂等重试,不改密码或 owner。
if err := transaction.Commit(ctx); err != nil {
return application.ErrResourceUnavailable
}
observed, err = d.InspectResources(ctx, name, role)
if err != nil {
return err
}
if state.Check(observed) != nil {
return application.ErrResourceConflict
}
if observed.PublicConnect || !observed.AllowConnections {
return application.ErrResourceUnavailable
}
return nil
}
func creationError(err error) error {
if serverError, ok := errors.AsType[*pgconn.PgError](err); ok {
switch serverError.Code {
case "42710", "42P04", "23505":
return application.ErrResourceConflict
case "42501", "25006", "28P01", "28000":
return application.ErrResourceUnavailable
}
}
if pgconn.SafeToRetry(err) {
return application.ErrResourceUnavailable
}
return application.ErrResourceUncertain
}
@@ -0,0 +1,35 @@
package postgresql
import (
"errors"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"github.com/jackc/pgx/v5/pgconn"
)
func TestCreationErrorsDoNotExposeServerDetails(t *testing.T) {
for _, test := range []struct {
code string
want error
}{
{"42710", application.ErrResourceConflict},
{"42P04", application.ErrResourceConflict},
{"23505", application.ErrResourceConflict},
{"42501", application.ErrResourceUnavailable},
{"25006", application.ErrResourceUnavailable},
{"57014", application.ErrResourceUncertain},
{"XX000", application.ErrResourceUncertain},
} {
actual := creationError(&pgconn.PgError{Code: test.code, Message: "unsafe SQL and credential detail"})
if !errors.Is(actual, test.want) {
t.Fatalf("SQLSTATE %s 分类错误", test.code)
}
if actual.Error() != test.want.Error() {
t.Fatal("后端错误携带原始响应")
}
}
if !errors.Is(creationError(errors.New("connection lost after send")), application.ErrResourceUncertain) {
t.Fatal("未知网络结果不允许自动重试创建")
}
}