feat: PostgreSQL 角色与数据库创建闭环 #16

Open
panxiao81 wants to merge 1 commits from feat/database-resource-provisioning into main
29 changed files with 1494 additions and 58 deletions
Showing only changes of commit e2016d3727 - Show all commits
@@ -38,6 +38,16 @@ type PostgreSQLDatabaseStatus struct {
// +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Minimum=1
// +optional // +optional
CredentialVersion int64 `json:"credentialVersion,omitempty"` CredentialVersion int64 `json:"credentialVersion,omitempty"`
// RoleOID 只在角色创建提交并回读成功后保存,后续不按同名认领。
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=4294967295
// +optional
RoleOID int64 `json:"roleOID,omitempty"`
// DatabaseOID 只在数据库创建并回读成功后保存;缺失不代表允许认领已有对象。
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=4294967295
// +optional
DatabaseOID int64 `json:"databaseOID,omitempty"`
// Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。 // Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
// +optional // +optional
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
@@ -50,6 +60,10 @@ type PostgreSQLDatabaseStatus struct {
// +kubebuilder:object:root=true // +kubebuilder:object:root=true
// +kubebuilder:subresource:status // +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster // +kubebuilder:resource:scope=Cluster
// +kubebuilder:validation:XValidation:rule="!has(self.status) || !has(self.status.roleOID) || (has(self.status.credentialVersion) && has(self.status.instanceUID))",message="roleOID requires confirmed credentials and instanceUID"
// +kubebuilder:validation:XValidation:rule="!has(self.status) || !has(self.status.databaseOID) || has(self.status.roleOID)",message="databaseOID requires roleOID"
// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.roleOID)) || (has(self.status) && has(self.status.roleOID) && self.status.roleOID == oldSelf.status.roleOID)",message="confirmed roleOID cannot change or be removed"
// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.databaseOID)) || (has(self.status) && has(self.status.databaseOID) && self.status.databaseOID == oldSelf.status.databaseOID)",message="confirmed databaseOID cannot change or be removed"
// +kubebuilder:validation:XValidation:rule="!has(self.status) || !has(self.status.credentialVersion) || has(self.status.credentialRef)",message="credentialVersion requires credentialRef" // +kubebuilder:validation:XValidation:rule="!has(self.status) || !has(self.status.credentialVersion) || has(self.status.credentialRef)",message="credentialVersion requires credentialRef"
// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.credentialRef)) || (has(self.status) && has(self.status.credentialRef) && self.status.credentialRef == oldSelf.status.credentialRef)",message="recorded credentialRef cannot change or be removed" // +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.credentialRef)) || (has(self.status) && has(self.status.credentialRef) && self.status.credentialRef == oldSelf.status.credentialRef)",message="recorded credentialRef cannot change or be removed"
// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.credentialVersion)) || (has(self.status) && has(self.status.credentialVersion) && self.status.credentialVersion == oldSelf.status.credentialVersion)",message="confirmed credentialVersion cannot change or be removed" // +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.credentialVersion)) || (has(self.status) && has(self.status.credentialVersion) && self.status.credentialVersion == oldSelf.status.credentialVersion)",message="confirmed credentialVersion cannot change or be removed"
@@ -0,0 +1,40 @@
package v1alpha1_test
import (
"testing"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func testResourceStatus(t *testing.T, api client.Client) {
object := validDatabase("resource-status")
if err := api.Create(t.Context(), object); err != nil {
t.Fatal(err)
}
invalid := object.DeepCopy()
invalid.Status.RoleOID = 11
if err := api.Status().Update(t.Context(), invalid); !apierrors.IsInvalid(err) {
t.Fatal("无凭据不能确认角色", err)
}
object.Status.InstanceUID = "instance-uid"
object.Status.CredentialRef = &databasev1alpha1.CredentialReference{Mount: "application-secrets", Path: "applications/test"}
object.Status.CredentialVersion = 1
object.Status.RoleOID, object.Status.DatabaseOID = 11, 22
if err := api.Status().Update(t.Context(), object); err != nil {
t.Fatal(err)
}
for _, change := range []func(*databasev1alpha1.PostgreSQLDatabase){
func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.RoleOID = 0 },
func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.RoleOID = 12 },
func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.DatabaseOID = 0 },
func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.DatabaseOID = 23 },
} {
invalid = object.DeepCopy()
change(invalid)
if err := api.Status().Update(t.Context(), invalid); !apierrors.IsInvalid(err) {
t.Fatal("已确认 OID 不得覆盖或移除", err)
}
}
}
+1
View File
@@ -62,6 +62,7 @@ func TestDatabaseAPI(t *testing.T) {
t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(t, client) }) t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(t, client) })
t.Run("status隔离和绑定并发", func(t *testing.T) { testBindingWrites(t, client) }) t.Run("status隔离和绑定并发", func(t *testing.T) { testBindingWrites(t, client) })
t.Run("凭据位置与确认版本", func(t *testing.T) { testCredentialStatus(t, client) }) t.Run("凭据位置与确认版本", func(t *testing.T) { testCredentialStatus(t, client) })
t.Run("资源创建确认", func(t *testing.T) { testResourceStatus(t, client) })
t.Run("仓库示例", func(t *testing.T) { testSamples(t, client, scheme) }) t.Run("仓库示例", func(t *testing.T) { testSamples(t, client, scheme) })
} }
@@ -221,6 +221,12 @@ spec:
format: int64 format: int64
minimum: 1 minimum: 1
type: integer type: integer
databaseOID:
description: DatabaseOID 只在数据库创建并回读成功后保存;缺失不代表允许认领已有对象。
format: int64
maximum: 4294967295
minimum: 1
type: integer
instanceUID: instanceUID:
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。 description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
type: string type: string
@@ -230,11 +236,28 @@ spec:
phase: phase:
description: Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。 description: Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
type: string type: string
roleOID:
description: RoleOID 只在角色创建提交并回读成功后保存,后续不按同名认领。
format: int64
maximum: 4294967295
minimum: 1
type: integer
type: object type: object
required: required:
- spec - spec
type: object type: object
x-kubernetes-validations: x-kubernetes-validations:
- message: roleOID requires confirmed credentials and instanceUID
rule: '!has(self.status) || !has(self.status.roleOID) || (has(self.status.credentialVersion)
&& has(self.status.instanceUID))'
- message: databaseOID requires roleOID
rule: '!has(self.status) || !has(self.status.databaseOID) || has(self.status.roleOID)'
- message: confirmed roleOID cannot change or be removed
rule: '!(has(oldSelf.status) && has(oldSelf.status.roleOID)) || (has(self.status)
&& has(self.status.roleOID) && self.status.roleOID == oldSelf.status.roleOID)'
- message: confirmed databaseOID cannot change or be removed
rule: '!(has(oldSelf.status) && has(oldSelf.status.databaseOID)) || (has(self.status)
&& has(self.status.databaseOID) && self.status.databaseOID == oldSelf.status.databaseOID)'
- message: credentialVersion requires credentialRef - message: credentialVersion requires credentialRef
rule: '!has(self.status) || !has(self.status.credentialVersion) || has(self.status.credentialRef)' rule: '!has(self.status) || !has(self.status.credentialVersion) || has(self.status.credentialRef)'
- message: recorded credentialRef cannot change or be removed - message: recorded credentialRef cannot change or be removed
+47 -4
View File
@@ -1,7 +1,7 @@
# Database 模块 # Database 模块
Database 是 Ayatori 首批实际产品领域之一。当前已包含三资源 API、分层绑定与 Instance 原生 Database 是 Ayatori 首批实际产品领域之一。当前已包含三资源 API、分层绑定与 Instance 原生
管理能力观测;尚未完成 Database 供应/导入、Tenant 凭据交付与资源回收链路。 管理能力观测、凭据准备及显式启用的角色/数据库创建;扩展、导入、Tenant 交付与回收尚未完成。
## 当前设计(2026-09-24) ## 当前设计(2026-09-24)
@@ -12,7 +12,7 @@ Retain 后人工重新绑定与资源侧 Delete。撤销 PostgreSQL ownership re
依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见 依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见
[系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。 [系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。
registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理 registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理
能力观察直接判定 Ready。Database 资源与绑定已接入,导入、角色/凭据供应及回收仍未完成。wiki 同步位置见 能力观察直接判定 Ready。Database 资源与绑定、角色/凭据供应已接入,导入及回收仍未完成。wiki 同步位置见
`homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。 `homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。
## 来源基线 ## 来源基线
@@ -201,14 +201,57 @@ application 保留 I/O 顺序、消费方接口及并发快照,不再复用绑
同样可能保守地要求人工处理,不承诺无损接续;多副本部署应启用既有 leader election。 同样可能保守地要求人工处理,不承诺无损接续;多副本部署应启用既有 leader election。
成功只设置 `CredentialsReady=True`,Database Ready 仍为 False/ProvisioningIncomplete, 成功只设置 `CredentialsReady=True`,Database Ready 仍为 False/ProvisioningIncomplete,
Tenant 仍未完成交付。本切片没有 PostgreSQL role/database 创建、扩展安装、ESO 投射、 Tenant 仍未完成交付。凭据准备本身不创建 PostgreSQL 资源;后续创建需另行启用下述用例。
Retain 释放或 Delete 清理,也不会解除 finalizer。不要作为完整 DBaaS 部署。 扩展安装、ESO 投射、Retain 释放或 Delete 清理尚未接入,也不会解除 finalizer。不要作为完整 DBaaS 部署。
单元测试穷举前置条件;真实 API server + 隔离 Bao 验证创建、状态确认、幂等、重启、并发、 单元测试穷举前置条件;真实 API server + 隔离 Bao 验证创建、状态确认、幂等、重启、并发、
依赖恢复、固定位置、不确定结果、确认保存失败和删除边界;实际 manager 验证 watch 驱动 依赖恢复、固定位置、不确定结果、确认保存失败和删除边界;实际 manager 验证 watch 驱动
及重启。该 fixture 只声明 Instance 前置 Ready,真实 PostgreSQL 管理能力由既有 Instance 及重启。该 fixture 只声明 Instance 前置 Ready,真实 PostgreSQL 管理能力由既有 Instance
集成测试覆盖,不把凭据准备验收当成实际建库或应用登录验收。 集成测试覆盖,不把凭据准备验收当成实际建库或应用登录验收。
## PostgreSQL 资源创建闭环
在管理 Secret、OpenBao 认证和凭据准备配置之上,显式设置 `--database-provision-resources`
才启用外部角色/数据库写入;默认关闭。升级时先安装新 CRD,供应字段见
[API 合同](api-reference.md#当前-api-切片)。只处理 Provision 来源,导入资源不进入创建流程。
`DatabaseProvisioning` 在每轮验证双向绑定、UID、删除/Released 状态、保护和当前 Instance
Ready,读取固定位置的已确认凭据。所有 SQL 操作复用 InstanceService 已有的管理连接及
Secret 刷新机制,不再开一个管理连接池。执行前重新查询非 superuser 管理能力;外部操作
前后直接回读 API 快照,发生并发变更则停止确认,不盲目覆盖 status。
启用资源创建时,`DatabaseReconciliation` 在同一条 reconcile 中先运行凭据准备/检查,
再运行资源供应用例,不同时注册独立凭据 controller,避免两个 worker 争写同一资源状态。
创建分为三个可观察步骤:
1. 保存 CreatingRole,事务内创建无管理特权的 LOGIN owner 并授予管理账号 SET 权限;
提交并回读成功后保存 `status.roleOID`。
2. 保存 CreatingDatabase,创建以已确认角色为 owner 且 `ALLOW_CONNECTIONS false` 的数据库;
回读成功后保存 `status.databaseOID`。
3. 在已确认对象上以 owner 收紧 ACL:撤销 PUBLIC CONNECT、授予 owner CONNECT,再开放
连接入口。ACL 收敛可幂等重试,不重置密码或更换 owner。
OID 只保存在 CR,表示本次成功创建的回读身份,不是 registry 或认领机制。未知同名对象、
已确认对象消失/被重建、owner/角色特权漂移均报 Conflict。CreatingRole/CreatingDatabase
未留下对应 OID 时,下轮保守停止;即使请求可能尚未发出,也不尝试推断或自动补记。
网络结果不确定、创建成功后确认写入失败同样交给人工处理。诊断包含 Database、Instance、
实际 database/role、失败步骤;确认 OID 独立保留。依赖或明确权限拒绝可等待恢复。
OID 不是跨集群/备份恢复的稳定身份,恢复后须人工核对,不能靠匹配 OID 推导管理权。
`ResourcesReady=True` 仅表示角色、数据库及连接 ACL 已确认,不代表扩展或 Tenant 交付完成;
Database/Tenant 总体 Ready 仍为 False。当前不轮换密码、不做运行期应用登录健康检查,也不
撤销其他数据库的 PUBLIC 权限。管理员须保证共享实例中其他数据库的接入策略满足隔离要求。
真实 API server + PostgreSQL + OpenBao 测试覆盖实际密码登录、owner 建表、应用管理权限拒绝、
无关账号连接拒绝、重试/重启、权限恢复、同名冲突、角色重建、并发授权、创建结果丢失、
确认持久化失败、删除停止与实际 manager 的观察链路。保留外部操作/API 写入间的非原子边界,
不承诺控制面与数据库之间的事务或对任意管理员并发 DDL 的无损恢复。
遵循 PostgreSQL 官方的
[CREATE DATABASE 非事务与 owner 权限合同](https://www.postgresql.org/docs/17/sql-createdatabase.html)
和 [CREATEROLE 的成员授权](https://www.postgresql.org/docs/17/role-attributes.html)。
不新增 controller 通用生命周期框架,继续采用既有直接观察、resourceVersion 保护和条件报告。
## OpenBao Kubernetes 认证会话 ## OpenBao Kubernetes 认证会话
公共 `internal/infra/openbao.KubernetesSession` 复用官方 Kubernetes auth helper 和 `LifetimeWatcher` 公共 `internal/infra/openbao.KubernetesSession` 复用官方 Kubernetes auth helper 和 `LifetimeWatcher`
+11 -3
View File
@@ -2,9 +2,9 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 | | 状态 | API、绑定、凭据准备及角色/数据库创建已实现;扩展、导入、交付与删除清理未接入 |
| API group/version | `database.ayatori.ddupan.top/v1alpha1` | | API group/version | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-27 | | 最后更新 | 2026-09-29 |
以 [系统规格](specification.md) 与 以 [系统规格](specification.md) 与
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码, [ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码,
@@ -26,6 +26,7 @@ Go 类型位于 `api/database/v1alpha1`,CRD 随 `config/crd` 发布;manager
| Database | `status.instanceUID` | 观察时的 Instance 身份 | | Database | `status.instanceUID` | 观察时的 Instance 身份 |
| Database | `status.credentialRef.mount/path` | 首次写入前固定的 KV v2 位置,不随部署配置迁移 | | Database | `status.credentialRef.mount/path` | 首次写入前固定的 KV v2 位置,不随部署配置迁移 |
| Database | `status.credentialVersion` | 创建并回读成功后确认的正整数版本;省略表示未确认 | | Database | `status.credentialVersion` | 创建并回读成功后确认的正整数版本;省略表示未确认 |
| Database | `status.roleOID`、`status.databaseOID` | 分别创建并回读成功后确认的 PostgreSQL OID;不能凭同名补记 |
| Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 | | Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 |
| Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 | | Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 |
| Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance | | Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance |
@@ -47,6 +48,13 @@ KV 版本,删除或版本漂移均需人工处理,不回退旧版本或生
`CreationStarted` 且无确认版本表示创建未完成确认,重入时停在 Conflict;不尝试推断 `CreationStarted` 且无确认版本表示创建未完成确认,重入时停在 Conflict;不尝试推断
进程中断前请求是否发出。完整执行和测试边界见[凭据准备闭环](README.md#凭据准备闭环)。 进程中断前请求是否发出。完整执行和测试边界见[凭据准备闭环](README.md#凭据准备闭环)。
角色/数据库 OID 为 1–4294967295,写入后不可修改或移除。角色确认要求已有凭据确认与
Instance UID;数据库确认要求先有角色确认。`ResourcesReady` 的 CreatingRole/CreatingDatabase
没有相应 OID 时,下轮转 Conflict;依赖故障不清空确认。旧 CRD 裁剪 OID 会阻止继续下一步,
但不能回滚已发出的外部创建,因此必须先升级 CRD 再启用供应。
`ResourcesReady=True/Available` 只表示角色、数据库和 ACL 已确认,总体 Ready 仍不放行。
详见[资源创建闭环](README.md#postgresql-资源创建闭环)。
示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、 示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、
[导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、 [导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、
[动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。 [动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。
@@ -131,7 +139,7 @@ Database 是平台管理的集群级资源,不归属于应用 namespace,也
普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放; 普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放;
这些资源管理操作由平台管理员授权。controller 的绑定协调权限与用户申请权限分别配置。 这些资源管理操作由平台管理员授权。controller 的绑定协调权限与用户申请权限分别配置。
以下是行为合同,具体 schema 见当前 API 切片与生成的 CRD;后端行为尚未实现: 以下是行为合同,具体 schema 见当前 API 切片与生成的 CRD;导入、交付与回收尚未实现:
| 内容 | 合同 | | 内容 | 合同 |
| --- | --- | | --- | --- |
+2 -1
View File
@@ -1,6 +1,6 @@
# 部署与配置 # 部署与配置
> 本页区分已实现的 Instance 观测、认证和凭据准备配置,与尚未接入的 PostgreSQL 供应/交付合同。 > 本页区分已实现的 Instance 观测、认证、凭据准备和资源创建配置,与尚未接入的完整供应/交付合同。
> 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。 > 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。
| 项目 | 内容 | | 项目 | 内容 |
@@ -46,6 +46,7 @@ Instance 观测)与 `--database-root-cert`(公开 PostgreSQL CA PEM 路径
| `--openbao-auth-role` | 已实现,启用时必填 | OpenBao 登录 role | | `--openbao-auth-role` | 已实现,启用时必填 | OpenBao 登录 role |
| `--openbao-ca-cert` | 已实现,默认系统信任根 | OpenBao 公开 CA PEM 路径 | | `--openbao-ca-cert` | 已实现,默认系统信任根 | OpenBao 公开 CA PEM 路径 |
| `--database-credential-mount` | 已实现,默认空 | 显式设置后启用应用凭据准备,要求已配置 OpenBao 认证 | | `--database-credential-mount` | 已实现,默认空 | 显式设置后启用应用凭据准备,要求已配置 OpenBao 认证 |
| `--database-provision-resources` | 已实现,默认 false | 启用角色/数据库及 ACL 创建;要求管理 Secret namespace、凭据 mount 和认证配置,先升级 CRD |
| `--openbao-service-account-namespace` | 已实现,启用时必填 | TokenRequest 目标 SA 的固定 namespace | | `--openbao-service-account-namespace` | 已实现,启用时必填 | TokenRequest 目标 SA 的固定 namespace |
| `--openbao-service-account-name` | 已实现,启用时必填 | TokenRequest 目标 SA 名称 | | `--openbao-service-account-name` | 已实现,启用时必填 | TokenRequest 目标 SA 名称 |
| `--openbao-token-audience` | 已实现,`openbao` | SA JWT audience,必须匹配 OpenBao role | | `--openbao-token-audience` | 已实现,`openbao` | SA JWT audience,必须匹配 OpenBao role |
+13 -3
View File
@@ -21,6 +21,7 @@ type databaseOptions struct {
rootCert string rootCert string
credentialMount string credentialMount string
credentialPrefix string credentialPrefix string
provisionResources bool
} }
func (o *databaseOptions) bindFlags(flags *flag.FlagSet) { func (o *databaseOptions) bindFlags(flags *flag.FlagSet) {
@@ -29,6 +30,7 @@ func (o *databaseOptions) bindFlags(flags *flag.FlagSet) {
flags.StringVar(&o.rootCert, "database-root-cert", "", "PostgreSQL 管理连接信任的公开 CA bundle 路径") flags.StringVar(&o.rootCert, "database-root-cert", "", "PostgreSQL 管理连接信任的公开 CA bundle 路径")
flags.StringVar(&o.credentialMount, "database-credential-mount", "", "应用凭据 KV v2 mount;为空时不启用凭据准备") flags.StringVar(&o.credentialMount, "database-credential-mount", "", "应用凭据 KV v2 mount;为空时不启用凭据准备")
flags.StringVar(&o.credentialPrefix, "database-credential-prefix", "applications", "应用凭据路径前缀;已有固定位置不随配置变化迁移") flags.StringVar(&o.credentialPrefix, "database-credential-prefix", "applications", "应用凭据路径前缀;已有固定位置不随配置变化迁移")
flags.BoolVar(&o.provisionResources, "database-provision-resources", false, "启用 PostgreSQL 角色和数据库创建;需要 Instance 管理凭据及 OpenBao 凭据准备")
} }
func (o databaseOptions) configureManager(options *ctrl.Options) { func (o databaseOptions) configureManager(options *ctrl.Options) {
@@ -40,25 +42,30 @@ func (o databaseOptions) configureManager(options *ctrl.Options) {
// registerDatabaseControllers 封装 Database 的内部装配,并返回在 manager 停止后执行的清理。 // registerDatabaseControllers 封装 Database 的内部装配,并返回在 manager 停止后执行的清理。
func registerDatabaseControllers(ctx context.Context, manager ctrl.Manager, options databaseOptions, baoClient *bao.Client) (func(), error) { func registerDatabaseControllers(ctx context.Context, manager ctrl.Manager, options databaseOptions, baoClient *bao.Client) (func(), error) {
closeDatabaseConnections := func() {} closeDatabaseConnections := func() {}
var backend *application.InstanceService
if options.secretNamespace != "" { if options.secretNamespace != "" {
service, err := setupInstanceObservation(manager, options.secretNamespace, options.rootCert) service, err := setupInstanceObservation(manager, options.secretNamespace, options.rootCert)
if err != nil { if err != nil {
return nil, fmt.Errorf("set up Instance observation: %w", err) return nil, fmt.Errorf("set up Instance observation: %w", err)
} }
closeDatabaseConnections = service.Close closeDatabaseConnections = service.Close
backend = service
} }
if err := wireBindingController(manager.GetClient(), manager.GetAPIReader()).SetupWithManager(ctx, manager); err != nil { if err := wireBindingController(manager.GetClient(), manager.GetAPIReader()).SetupWithManager(ctx, manager); err != nil {
closeDatabaseConnections() closeDatabaseConnections()
return nil, fmt.Errorf("set up Database binding controller: %w", err) return nil, fmt.Errorf("set up Database binding controller: %w", err)
} }
if err := setupCredentialPreparation(manager, options, baoClient); err != nil { if err := registerDatabaseSupplyController(manager, options, baoClient, backend); err != nil {
closeDatabaseConnections() closeDatabaseConnections()
return nil, fmt.Errorf("set up Database credential preparation: %w", err) return nil, fmt.Errorf("register Database supply controller: %w", err)
} }
return closeDatabaseConnections, nil return closeDatabaseConnections, nil
} }
func setupCredentialPreparation(manager ctrl.Manager, options databaseOptions, baoClient *bao.Client) error { func registerDatabaseSupplyController(manager ctrl.Manager, options databaseOptions, baoClient *bao.Client, backend *application.InstanceService) error {
if options.provisionResources && (backend == nil || options.credentialMount == "") {
return fmt.Errorf("database resource provisioning requires management Secret namespace and credential mount")
}
if options.credentialMount == "" { if options.credentialMount == "" {
return nil return nil
} }
@@ -69,6 +76,9 @@ func setupCredentialPreparation(manager ctrl.Manager, options databaseOptions, b
if err != nil { if err != nil {
return err return err
} }
if options.provisionResources {
return wireProvisioningController(manager.GetClient(), manager.GetAPIReader(), store, backend).SetupWithManager(manager)
}
return wireCredentialController(manager.GetClient(), manager.GetAPIReader(), store).SetupWithManager(manager) return wireCredentialController(manager.GetClient(), manager.GetAPIReader(), store).SetupWithManager(manager)
} }
+10
View File
@@ -26,3 +26,13 @@ func wireInstanceController(writer client.Client, reader client.Reader, observer
service := &application.InstanceReconciliation{Resources: resources, Observer: observer} service := &application.InstanceReconciliation{Resources: resources, Observer: observer}
return databasecontroller.NewInstanceReconciler(writer, service, resources, namespace) return databasecontroller.NewInstanceReconciler(writer, service, resources, namespace)
} }
func wireProvisioningController(writer client.Client, reader client.Reader, store application.CredentialStore, backend application.ProvisioningBackend) *databasecontroller.ProvisioningReconciler {
resources := &kubernetes.ProvisioningResources{Client: writer, Reader: reader}
credentialResources := &kubernetes.CredentialResources{Client: writer, Reader: reader}
service := &application.DatabaseReconciliation{
Credentials: &application.CredentialPreparation{Resources: credentialResources, Store: store},
Provisioning: &application.DatabaseProvisioning{Resources: resources, Credentials: store, Backend: backend},
}
return databasecontroller.NewProvisioningReconciler(writer, service)
}
@@ -39,6 +39,11 @@ func TestDatabaseExplicitWiring(t *testing.T) {
if !ok || resources.Reader != directReader || resources.Client != writer || reconciler.Service.Observer != observer || reconciler.Presenter != resources { if !ok || resources.Reader != directReader || resources.Client != writer || reconciler.Service.Observer != observer || reconciler.Presenter != resources {
t.Fatal("Instance 的服务或读取边界未按依赖图注入") t.Fatal("Instance 的服务或读取边界未按依赖图注入")
} }
provisioner := wireProvisioningController(writer, directReader, nil, observer)
provisioningResources, ok := provisioner.Service.Provisioning.Resources.(*kubernetes.ProvisioningResources)
if !ok || provisioningResources.Client != writer || provisioningResources.Reader != directReader || provisioner.Service.Provisioning.Backend != observer {
t.Fatal("资源供应必须复用已有 Instance 管理服务和 API 客户端")
}
} }
// 防止领域重新依赖用例/存储,也防止 controller 再次私自构造具体 adapter。 // 防止领域重新依赖用例/存储,也防止 controller 再次私自构造具体 adapter。
@@ -67,7 +67,7 @@ func TestBootstrapWithRealAPIServer(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
options.database.credentialMount = "secret" options.database.credentialMount = "secret"
if err := setupCredentialPreparation(manager, options.database, fixtureClient); err != nil { if err := registerDatabaseSupplyController(manager, options.database, fixtureClient, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
done := make(chan error, 1) done := make(chan error, 1)
+5 -2
View File
@@ -63,12 +63,15 @@ func TestCredentialPreparationOptions(t *testing.T) {
if options.database.credentialMount != "applications-kv" || options.database.credentialPrefix != "database" { if options.database.credentialMount != "applications-kv" || options.database.credentialPrefix != "database" {
t.Fatal("凭据准备参数未传入领域装配") t.Fatal("凭据准备参数未传入领域装配")
} }
if err := setupCredentialPreparation(nil, options.database, nil); err == nil { if err := registerDatabaseSupplyController(nil, options.database, nil, nil); err == nil {
t.Fatal("启用凭据准备必须有显式配置的认证 client") t.Fatal("启用凭据准备必须有显式配置的认证 client")
} }
if err := setupCredentialPreparation(nil, databaseOptions{}, nil); err != nil { if err := registerDatabaseSupplyController(nil, databaseOptions{}, nil, nil); err != nil {
t.Fatal("默认停用凭据准备不应要求后端") t.Fatal("默认停用凭据准备不应要求后端")
} }
if err := registerDatabaseSupplyController(nil, databaseOptions{provisionResources: true}, nil, nil); err == nil {
t.Fatal("启用资源供应必须有管理连接及凭据准备")
}
} }
func TestManagerFlagOverrides(t *testing.T) { func TestManagerFlagOverrides(t *testing.T) {
@@ -0,0 +1,111 @@
package kubernetes
import (
"context"
"fmt"
"reflect"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/provisioning"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const resourcesReadyCondition = "ResourcesReady"
type ProvisioningResources struct {
Client client.Client
Reader client.Reader
}
func (r *ProvisioningResources) Load(ctx context.Context, name string) (*application.ProvisioningRecord, error) {
// 同一组 API 事实复用映射;供应用例不持有凭据准备用例的快照或 repository。
facts, err := (&CredentialResources{Client: r.Client, Reader: r.Reader}).Load(ctx, name)
if err != nil || facts == nil {
return nil, err
}
object := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil {
return nil, err
}
if object.ResourceVersion != facts.Revision {
return nil, provisioningSnapshotConflict(name)
}
record := &application.ProvisioningRecord{Target: facts.Target, Revision: facts.Revision, TenantGeneration: facts.TenantGeneration,
InstanceGeneration: facts.InstanceGeneration, Credentials: facts.Status, State: provisioningStatus(object)}
if facts.Instance == nil {
return record, nil
}
instance := &databasev1alpha1.PostgreSQLInstance{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: facts.Database.Instance}, instance); err != nil {
return nil, err
}
if string(instance.UID) != facts.Instance.Identity.UID || instance.Generation != facts.InstanceGeneration ||
(!instance.DeletionTimestamp.IsZero()) != facts.Instance.Deleting || currentReady(instance.Generation, instance.Status.Conditions) != facts.Instance.Ready {
return nil, provisioningSnapshotConflict(name)
}
mapped, err := instanceRecord(instance)
if err != nil {
return nil, err
}
record.InstanceTarget = mapped.Target
return record, nil
}
func provisioningStatus(object *databasev1alpha1.PostgreSQLDatabase) provisioning.State {
state := provisioning.State{RoleOID: uint32(object.Status.RoleOID), DatabaseOID: uint32(object.Status.DatabaseOID), Phase: provisioning.Pending}
if condition := meta.FindStatusCondition(object.Status.Conditions, resourcesReadyCondition); condition != nil {
state.Phase, state.Message = provisioning.Phase(condition.Reason), condition.Message
}
return state
}
func (r *ProvisioningResources) Save(ctx context.Context, record *application.ProvisioningRecord, state provisioning.State) (*application.ProvisioningRecord, error) {
object := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: record.Database.Identity.Name}, object); err != nil {
return nil, err
}
if object.ResourceVersion != record.Revision || string(object.UID) != record.Database.Identity.UID {
return nil, provisioningSnapshotConflict(object.Name)
}
previous := object.Status.DeepCopy()
object.Status.RoleOID, object.Status.DatabaseOID = int64(state.RoleOID), int64(state.DatabaseOID)
status := metav1.ConditionFalse
if state.Phase == provisioning.Available {
status = metav1.ConditionTrue
}
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{Type: resourcesReadyCondition, Status: status,
Reason: string(state.Phase), Message: state.Message, ObservedGeneration: object.Generation})
if !equality.Semantic.DeepEqual(*previous, object.Status) {
if err := r.Client.Status().Update(ctx, object); err != nil {
return nil, err
}
}
if object.Status.RoleOID != int64(state.RoleOID) || object.Status.DatabaseOID != int64(state.DatabaseOID) {
return nil, fmt.Errorf("资源 OID 未被 API 保留;请先升级 Database CRD,未继续供应")
}
updated := *record
updated.Revision, updated.State = object.ResourceVersion, provisioningStatus(object)
return &updated, nil
}
func (r *ProvisioningResources) CheckCurrent(ctx context.Context, record *application.ProvisioningRecord) error {
current, err := r.Load(ctx, record.Database.Identity.Name)
if err != nil {
return err
}
if current == nil || !reflect.DeepEqual(current, record) {
return provisioningSnapshotConflict(record.Database.Identity.Name)
}
return nil
}
func provisioningSnapshotConflict(name string) error {
return apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource("postgresqldatabases").GroupResource(), name,
fmt.Errorf("供应快照发生变化;停止本轮并重新观察"))
}
@@ -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("未知网络结果不允许自动重试创建")
}
}
@@ -0,0 +1,152 @@
package application
import (
"context"
"errors"
"fmt"
"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"
)
type ProvisioningRecord struct {
credential.Target
Revision string
TenantGeneration int64
InstanceGeneration int64
InstanceTarget instance.ObservationTarget
Credentials credential.State
State provisioning.State
}
type ProvisioningResources interface {
Load(context.Context, string) (*ProvisioningRecord, error)
Save(context.Context, *ProvisioningRecord, provisioning.State) (*ProvisioningRecord, error)
CheckCurrent(context.Context, *ProvisioningRecord) error
}
type DatabaseProvisioning struct {
Resources ProvisioningResources
Credentials CredentialStore
Backend ProvisioningBackend
}
func (s DatabaseProvisioning) Reconcile(ctx context.Context, name string) error {
record, err := s.Resources.Load(ctx, name)
if err != nil || record == nil || !record.RequiresPreparation() {
return err
}
if state, resume := record.State.Resume(); !resume {
if state == record.State {
return nil
}
_, err := s.Resources.Save(ctx, record, state)
return err
}
if issue := record.Check(); issue != nil {
phase := provisioning.Unavailable
if issue.Phase == credential.Conflict {
phase = provisioning.Conflict
}
if issue.Phase == credential.Stopped {
phase = provisioning.Stopped
}
return s.report(ctx, record, phase, issue.Message)
}
if !record.Credentials.Confirmed() || record.Credentials.Location == nil || record.Credentials.Phase != credential.Prepared {
return s.report(ctx, record, provisioning.Unavailable, "等待已确认且当前可用的应用凭据")
}
value, err := s.Credentials.ReadCredential(ctx, *record.Credentials.Location, record.Credentials.Version)
if err != nil {
return s.report(ctx, record, provisioning.Unavailable, "无法读取已确认凭据;未执行 PostgreSQL 写入")
}
if issue := record.CheckCredential(value); issue != nil {
return s.report(ctx, record, provisioning.Conflict, issue.Message)
}
observed, err := s.Backend.InspectResources(ctx, record.InstanceTarget, record.Database.Name, record.Database.LoginRole)
if err != nil {
return s.report(ctx, record, provisioning.Unavailable, "无法验证当前 PostgreSQL 资源和管理权限")
}
if err := record.State.Check(observed); err != nil {
return s.report(ctx, record, provisioning.Conflict, err.Error()+";请人工核对,未认领或覆盖")
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
if record.State.RoleOID == 0 {
return s.createRole(ctx, record, value)
}
if record.State.DatabaseOID == 0 {
return s.createDatabase(ctx, record)
}
if err := s.Backend.ConfigureAccess(ctx, record.InstanceTarget, record.Database.Name, record.Database.LoginRole, record.State); err != nil {
return s.backendFailure(ctx, record, err, "数据库访问权限收敛")
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
return s.report(ctx, record, provisioning.Available, "角色和数据库已确认,PUBLIC CONNECT 已撤销;扩展与 Tenant 交付尚未完成")
}
func (s DatabaseProvisioning) createRole(ctx context.Context, record *ProvisioningRecord, value credential.ApplicationCredential) error {
record, err := s.Resources.Save(ctx, record, record.State.WithPhase(provisioning.CreatingRole, "开始创建登录角色;尚未持久确认"))
if err != nil {
return err
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
oid, err := s.Backend.CreateLoginRole(ctx, record.InstanceTarget, value)
if err != nil {
return s.backendFailure(ctx, record, err, "登录角色创建")
}
if oid == 0 {
return s.backendFailure(ctx, record, ErrResourceUncertain, "登录角色创建")
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
state := record.State.WithPhase(provisioning.Pending, "登录角色已创建并回读,等待创建数据库")
state.RoleOID = oid
_, err = s.Resources.Save(ctx, record, state)
return err
}
func (s DatabaseProvisioning) createDatabase(ctx context.Context, record *ProvisioningRecord) error {
record, err := s.Resources.Save(ctx, record, record.State.WithPhase(provisioning.CreatingDatabase, "开始创建数据库;尚未持久确认"))
if err != nil {
return err
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
oid, err := s.Backend.CreateOwnedDatabase(ctx, record.InstanceTarget, record.Database.Name, record.Database.LoginRole, record.State.RoleOID)
if err != nil {
return s.backendFailure(ctx, record, err, "数据库创建")
}
if oid == 0 {
return s.backendFailure(ctx, record, ErrResourceUncertain, "数据库创建")
}
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
return err
}
state := record.State.WithPhase(provisioning.Pending, "数据库已创建并回读,等待收紧访问权限;连接入口仍关闭")
state.DatabaseOID = oid
_, err = s.Resources.Save(ctx, record, state)
return err
}
func (s DatabaseProvisioning) backendFailure(ctx context.Context, record *ProvisioningRecord, err error, step string) error {
if errors.Is(err, ErrResourceUnavailable) {
return s.report(ctx, record, provisioning.Unavailable, step+"被拒绝或暂不可用,保留已确认步骤并等待依赖恢复")
}
return s.report(ctx, record, provisioning.Conflict, step+"冲突或结果不确定;请核对目标与已确认 OID,未自动认领、改密或清理")
}
func (s DatabaseProvisioning) report(ctx context.Context, record *ProvisioningRecord, phase provisioning.Phase, message string) error {
message = fmt.Sprintf("Database %s / Instance %s / database %s / role %s:%s", record.Database.Identity.Name,
record.Database.Instance, record.Database.Name, record.Database.LoginRole, message)
_, err := s.Resources.Save(ctx, record, record.State.WithPhase(phase, message))
return err
}
@@ -0,0 +1,17 @@
package application
import "context"
// DatabaseReconciliation 顺序编排两个已有用例,不复制它们的领域判断。
// 启用资源创建时由同一个 controller 驱动,避免两个 worker 争写同一 Database status。
type DatabaseReconciliation struct {
Credentials *CredentialPreparation
Provisioning *DatabaseProvisioning
}
func (s DatabaseReconciliation) Reconcile(ctx context.Context, name string) error {
if err := s.Credentials.Reconcile(ctx, name); err != nil {
return err
}
return s.Provisioning.Reconcile(ctx, name)
}
@@ -35,6 +35,7 @@ var (
// Database 与 Connector 复用原项目 internal/instance/service.go 的能力边界。 // Database 与 Connector 复用原项目 internal/instance/service.go 的能力边界。
// Metadata 只查询版本与可用扩展,不能产生领域 Ready。 // Metadata 只查询版本与可用扩展,不能产生领域 Ready。
type Database interface { type Database interface {
ResourceDatabase
InspectMetadata(context.Context) (DatabaseMetadata, error) InspectMetadata(context.Context) (DatabaseMetadata, error)
InspectManagement(context.Context) (DatabaseMetadata, error) InspectManagement(context.Context) (DatabaseMetadata, error)
Close() Close()
@@ -93,16 +94,45 @@ func (s *InstanceService) ObserveManagement(ctx context.Context, target instance
} }
func (s *InstanceService) observe(ctx context.Context, target instance.ObservationTarget, management bool) (InstanceObservation, error) { func (s *InstanceService) observe(ctx context.Context, target instance.ObservationTarget, management bool) (InstanceObservation, error) {
if err := target.Validate(); err != nil {
return InstanceObservation{}, err
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
current, err := s.connection(ctx, target)
if err != nil {
return InstanceObservation{}, err
}
name := target.Identity().Name()
var metadata DatabaseMetadata
if management {
metadata, err = current.database.InspectManagement(ctx)
} else {
metadata, err = current.database.InspectMetadata(ctx)
metadata.Management = instance.ManagementChecks{}
}
if err != nil {
s.release(name)
return InstanceObservation{}, err
}
if metadata.Version == "" {
s.release(name)
return InstanceObservation{}, ErrObservation
}
if err := s.checkCredentials(ctx, target, current.credentials); err != nil {
return InstanceObservation{}, err
}
return InstanceObservation{target: target, version: metadata.Version,
extensions: instance.ObserveExtensionSupport(metadata.AvailableExtensions), management: metadata.Management}, nil
}
// connection 由持有 mu 的调用者使用;不缓存观察结果或管理权限。
func (s *InstanceService) connection(ctx context.Context, target instance.ObservationTarget) (*entry, error) {
if err := target.Validate(); err != nil {
return nil, err
}
if s.closed { if s.closed {
return InstanceObservation{}, ErrClosed return nil, ErrClosed
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return InstanceObservation{}, err return nil, err
} }
// 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。 // 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。
@@ -110,11 +140,11 @@ func (s *InstanceService) observe(ctx context.Context, target instance.Observati
credentials, err := s.source.Read(ctx, target.Definition().AdminCredential()) credentials, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil { if err != nil {
s.release(name) s.release(name)
return InstanceObservation{}, credentialError(err) return nil, credentialError(err)
} }
if credentials.username == "" || credentials.password == "" { if credentials.username == "" || credentials.password == "" {
s.release(name) s.release(name)
return InstanceObservation{}, ErrCredentialsInvalid return nil, ErrCredentialsInvalid
} }
// 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。 // 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。
@@ -127,7 +157,7 @@ func (s *InstanceService) observe(ctx context.Context, target instance.Observati
if current == nil { if current == nil {
database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials) database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials)
if err != nil { if err != nil {
return InstanceObservation{}, err return nil, err
} }
current = &entry{ current = &entry{
target: target, target: target,
@@ -137,39 +167,22 @@ func (s *InstanceService) observe(ctx context.Context, target instance.Observati
s.entries[name] = current s.entries[name] = current
} }
var metadata DatabaseMetadata return current, nil
if management {
metadata, err = current.database.InspectManagement(ctx)
} else {
metadata, err = current.database.InspectMetadata(ctx)
// 即使 adapter 误填权限,也不能把只读 metadata 入口升级为 Ready。
metadata.Management = instance.ManagementChecks{}
}
if err != nil {
s.release(name)
return InstanceObservation{}, err
}
if metadata.Version == "" {
s.release(name)
return InstanceObservation{}, ErrObservation
} }
func (s *InstanceService) checkCredentials(ctx context.Context, target instance.ObservationTarget, credentials Credentials) error {
name := target.Identity().Name()
// 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。 // 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。
latest, err := s.source.Read(ctx, target.Definition().AdminCredential()) latest, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil { if err != nil {
s.release(name) s.release(name)
return InstanceObservation{}, credentialError(err) return credentialError(err)
} }
if latest != credentials { if latest != credentials {
s.release(name) s.release(name)
return InstanceObservation{}, ErrCredentialsChanged return ErrCredentialsChanged
} }
return InstanceObservation{ return nil
target: target,
version: metadata.Version,
extensions: instance.ObserveExtensionSupport(metadata.AvailableExtensions),
management: metadata.Management,
}, nil
} }
func credentialError(err error) error { func credentialError(err error) error {
@@ -37,6 +37,7 @@ func (s *sourceStub) Read(context.Context, instance.CredentialReference) (Creden
} }
type databaseStub struct { type databaseStub struct {
ResourceDatabase
closes int closes int
err error err error
metadata DatabaseMetadata metadata DatabaseMetadata
@@ -0,0 +1,102 @@
package application
import (
"context"
"errors"
"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"
)
var (
ErrResourceConflict = errors.New("PostgreSQL resource conflict; manual resolution required")
ErrResourceUncertain = errors.New("PostgreSQL creation outcome uncertain; manual resolution required")
ErrResourceUnavailable = errors.New("PostgreSQL operation unavailable before creation")
)
// ResourceDatabase 是供应实际需要的后端能力,不暴露 SQL 或任意回调给用例。
type ResourceDatabase interface {
InspectResources(context.Context, string, string) (provisioning.Observation, error)
CreateLoginRole(context.Context, credential.ApplicationCredential) (uint32, error)
CreateOwnedDatabase(context.Context, string, string, uint32) (uint32, error)
ConfigureAccess(context.Context, string, string, provisioning.State) error
}
type ProvisioningBackend interface {
InspectResources(context.Context, instance.ObservationTarget, string, string) (provisioning.Observation, error)
CreateLoginRole(context.Context, instance.ObservationTarget, credential.ApplicationCredential) (uint32, error)
CreateOwnedDatabase(context.Context, instance.ObservationTarget, string, string, uint32) (uint32, error)
ConfigureAccess(context.Context, instance.ObservationTarget, string, string, provisioning.State) error
}
func (s *InstanceService) InspectResources(ctx context.Context, target instance.ObservationTarget, name, role string) (provisioning.Observation, error) {
var result provisioning.Observation
err := s.withManagementConnection(ctx, target, false, func(database Database) (err error) {
result, err = database.InspectResources(ctx, name, role)
return err
})
if err != nil {
return provisioning.Observation{}, err
}
return result, nil
}
func (s *InstanceService) CreateLoginRole(ctx context.Context, target instance.ObservationTarget, value credential.ApplicationCredential) (uint32, error) {
var oid uint32
err := s.withManagementConnection(ctx, target, true, func(database Database) (err error) {
oid, err = database.CreateLoginRole(ctx, value)
return err
})
if err != nil {
return 0, err
}
return oid, nil
}
func (s *InstanceService) CreateOwnedDatabase(ctx context.Context, target instance.ObservationTarget, name, role string, roleOID uint32) (uint32, error) {
var oid uint32
err := s.withManagementConnection(ctx, target, true, func(database Database) (err error) {
oid, err = database.CreateOwnedDatabase(ctx, name, role, roleOID)
return err
})
if err != nil {
return 0, err
}
return oid, nil
}
func (s *InstanceService) ConfigureAccess(ctx context.Context, target instance.ObservationTarget, name, role string, state provisioning.State) error {
return s.withManagementConnection(ctx, target, false, func(database Database) error {
return database.ConfigureAccess(ctx, name, role, state)
})
}
// 所有供应操作与观察共用同一个连接登记和 Secret 刷新边界。
// 创建后凭据回读失败不能冒充明确未执行;用例必须保留不确定诊断。
func (s *InstanceService) withManagementConnection(ctx context.Context, target instance.ObservationTarget, creating bool, operation func(Database) error) error {
s.mu.Lock()
defer s.mu.Unlock()
current, err := s.connection(ctx, target)
if err != nil {
return ErrResourceUnavailable
}
metadata, err := current.database.InspectManagement(ctx)
checks := metadata.Management
if err != nil || checks.Connection != instance.CheckPassed || checks.Roles != instance.CheckPassed || checks.Databases != instance.CheckPassed || checks.Grants != instance.CheckPassed {
return ErrResourceUnavailable
}
if err := s.checkCredentials(ctx, target, current.credentials); err != nil {
return ErrResourceUnavailable
}
if err := operation(current.database); err != nil {
return err
}
if err := s.checkCredentials(ctx, target, current.credentials); err != nil {
if creating {
return ErrResourceUncertain
}
return ErrResourceUnavailable
}
return nil
}
@@ -35,12 +35,14 @@ func (r *CredentialReconciler) SetupWithManager(manager ctrl.Manager) error {
} }
return ctrl.NewControllerManagedBy(manager). return ctrl.NewControllerManagedBy(manager).
Named("database-credentials").For(&databasev1alpha1.PostgreSQLDatabase{}). Named("database-credentials").For(&databasev1alpha1.PostgreSQLDatabase{}).
Watches(&databasev1alpha1.PostgreSQLTenant{}, handler.EnqueueRequestsFromMapFunc(r.requestsForTenant)). Watches(&databasev1alpha1.PostgreSQLTenant{}, handler.EnqueueRequestsFromMapFunc(databaseRequestsForTenant)).
Watches(&databasev1alpha1.PostgreSQLInstance{}, handler.EnqueueRequestsFromMapFunc(r.requestsForInstance)). Watches(&databasev1alpha1.PostgreSQLInstance{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, object client.Object) []ctrl.Request {
return databaseRequestsForInstance(ctx, r.Client, object)
})).
Complete(r) Complete(r)
} }
func (r *CredentialReconciler) requestsForTenant(_ context.Context, object client.Object) []ctrl.Request { func databaseRequestsForTenant(_ context.Context, object client.Object) []ctrl.Request {
tenant := object.(*databasev1alpha1.PostgreSQLTenant) tenant := object.(*databasev1alpha1.PostgreSQLTenant)
if tenant.Status.DatabaseRef == nil { if tenant.Status.DatabaseRef == nil {
return nil return nil
@@ -48,9 +50,9 @@ func (r *CredentialReconciler) requestsForTenant(_ context.Context, object clien
return []ctrl.Request{{Name: string(tenant.Status.DatabaseRef.Name)}} return []ctrl.Request{{Name: string(tenant.Status.DatabaseRef.Name)}}
} }
func (r *CredentialReconciler) requestsForInstance(ctx context.Context, object client.Object) []ctrl.Request { func databaseRequestsForInstance(ctx context.Context, cache client.Client, object client.Object) []ctrl.Request {
databases := &databasev1alpha1.PostgreSQLDatabaseList{} databases := &databasev1alpha1.PostgreSQLDatabaseList{}
if err := r.Client.List(ctx, databases); err != nil { if err := cache.List(ctx, databases); err != nil {
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Instance 凭据准备事件;等待低频重试") ctrl.LoggerFrom(ctx).Error(err, "无法映射 Instance 凭据准备事件;等待低频重试")
return nil return nil
} }
@@ -18,6 +18,7 @@ import (
) )
type instanceBackend struct { type instanceBackend struct {
application.ResourceDatabase
checks instance.ManagementChecks checks instance.ManagementChecks
err error err error
inspect func() inspect func()
@@ -0,0 +1,43 @@
package controller
import (
"context"
"errors"
"time"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
)
type ProvisioningReconciler struct {
Client client.Client
Service *application.DatabaseReconciliation
}
func NewProvisioningReconciler(cache client.Client, service *application.DatabaseReconciliation) *ProvisioningReconciler {
return &ProvisioningReconciler{Client: cache, Service: service}
}
func (r *ProvisioningReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
operationContext, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := r.Service.Reconcile(operationContext, request.Name); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
}
func (r *ProvisioningReconciler) SetupWithManager(manager ctrl.Manager) error {
if r.Client == nil || r.Service == nil {
return errors.New("provisioning controller requires injected client and use case")
}
return ctrl.NewControllerManagedBy(manager).Named("database-provisioning").
For(&databasev1alpha1.PostgreSQLDatabase{}).
Watches(&databasev1alpha1.PostgreSQLTenant{}, handler.EnqueueRequestsFromMapFunc(databaseRequestsForTenant)).
Watches(&databasev1alpha1.PostgreSQLInstance{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, object client.Object) []ctrl.Request {
return databaseRequestsForInstance(ctx, r.Client, object)
})).Complete(r)
}
@@ -106,14 +106,14 @@ func (t Target) Check() *Issue {
return &Issue{Unavailable, "等待 Database 与 Tenant 双向绑定完成"} return &Issue{Unavailable, "等待 Database 与 Tenant 双向绑定完成"}
} }
if *database.Tenant != t.Tenant.Identity || *t.Tenant.Database != database.Identity { if *database.Tenant != t.Tenant.Identity || *t.Tenant.Database != database.Identity {
return &Issue{Conflict, "双向绑定的名称或 UID 不匹配,未创建凭据"} return &Issue{Conflict, "双向绑定的名称或 UID 不匹配,未继续供应"}
} }
if t.Tenant.Deleting || t.Tenant.Phase != binding.Bound || !t.DatabaseProtected || !t.TenantProtected { if t.Tenant.Deleting || t.Tenant.Phase != binding.Bound || !t.DatabaseProtected || !t.TenantProtected {
return &Issue{Stopped, "Tenant 未完成绑定、正在删除或缺少 finalizer 保护,未创建凭据"} return &Issue{Stopped, "Tenant 未完成绑定、正在删除或缺少 finalizer 保护,未继续供应"}
} }
request, err := t.Tenant.Request.Resolve(t.Tenant.Identity) 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 { if err != nil || (request.Provision != nil && !database.MatchesProvision(request, t.Tenant.Identity)) || request.Name != database.Identity.Name {
return &Issue{Conflict, "Tenant 申请与 Database 目标不一致,未创建凭据"} return &Issue{Conflict, "Tenant 申请与 Database 目标不一致,未继续供应"}
} }
if t.Instance == nil || database.InstanceUID == "" { if t.Instance == nil || database.InstanceUID == "" {
return &Issue{Unavailable, "等待 Instance 与已记录的实例身份"} return &Issue{Unavailable, "等待 Instance 与已记录的实例身份"}
@@ -0,0 +1,66 @@
// Package provisioning 保存 PostgreSQL 资源供应的确认与恢复规则,不依赖后端或 API。
package provisioning
import "fmt"
type Phase string
const (
Pending Phase = "Pending"
CreatingRole Phase = "CreatingRole"
CreatingDatabase Phase = "CreatingDatabase"
Available Phase = "Available"
Conflict Phase = "Conflict"
Unavailable Phase = "Unavailable"
Stopped Phase = "Stopped"
)
// OID 是已成功创建并回读的对象身份,不是从名称推导出的管理授权。
// 仅保存在 CR;不建立 PostgreSQL registry,也不承诺备份还原后的自动认领。
type State struct {
RoleOID uint32
DatabaseOID uint32
Phase Phase
Message string
}
type Observation struct {
RoleOID uint32
DatabaseOID uint32
RoleSafe bool
OwnerOID uint32
PublicConnect bool
AllowConnections bool
}
func (s State) WithPhase(phase Phase, message string) State {
s.Phase, s.Message = phase, message
return s
}
func (s State) Resume() (State, bool) {
if s.Phase == Conflict {
return s, false
}
if (s.Phase == CreatingRole && s.RoleOID == 0) || (s.Phase == CreatingDatabase && s.DatabaseOID == 0) {
return s.WithPhase(Conflict, "外部创建未留下成功确认;请核对目标角色和数据库,不自动认领或重复创建"), false
}
return s, true
}
// Check 既阻止未知同名对象,也拒绝已确认对象消失、被重建或权限漂移。
func (s State) Check(o Observation) error {
if s.RoleOID != o.RoleOID {
return fmt.Errorf("角色身份不匹配:记录 OID=%d,观察 OID=%d", s.RoleOID, o.RoleOID)
}
if s.DatabaseOID != o.DatabaseOID {
return fmt.Errorf("数据库身份不匹配:记录 OID=%d,观察 OID=%d", s.DatabaseOID, o.DatabaseOID)
}
if o.RoleOID != 0 && !o.RoleSafe {
return fmt.Errorf("已确认角色的登录属性、特权或成员关系发生变化")
}
if o.DatabaseOID != 0 && o.OwnerOID != s.RoleOID {
return fmt.Errorf("数据库 owner 与已确认角色不匹配")
}
return nil
}
@@ -0,0 +1,52 @@
package provisioning
import "testing"
func TestResume(t *testing.T) {
for _, test := range []struct {
name string
state State
resume bool
}{
{"初次", State{}, true},
{"角色创建不确定", State{Phase: CreatingRole}, false},
{"库创建不确定", State{RoleOID: 11, Phase: CreatingDatabase}, false},
{"已确认角色", State{RoleOID: 11, Phase: Pending}, true},
{"已确认资源暂不可用", State{RoleOID: 11, DatabaseOID: 22, Phase: Unavailable}, true},
{"冲突保持", State{RoleOID: 11, Phase: Conflict, Message: "首次诊断"}, false},
} {
t.Run(test.name, func(t *testing.T) {
state, resume := test.state.Resume()
if resume != test.resume || state.RoleOID != test.state.RoleOID || state.DatabaseOID != test.state.DatabaseOID {
t.Fatal("恢复决策丢失确认或错误放行")
}
if !resume && state.Phase != Conflict {
t.Fatal("不确定创建未转冲突")
}
if test.state.Phase == Conflict && state != test.state {
t.Fatal("冲突诊断被覆盖")
}
})
}
}
func TestObservedIdentity(t *testing.T) {
state := State{RoleOID: 11, DatabaseOID: 22}
good := Observation{RoleOID: 11, DatabaseOID: 22, RoleSafe: true, OwnerOID: 11}
if err := state.Check(good); err != nil {
t.Fatal(err)
}
for _, observed := range []Observation{
{}, {RoleOID: 12, DatabaseOID: 22, RoleSafe: true, OwnerOID: 12},
{RoleOID: 11, DatabaseOID: 23, RoleSafe: true, OwnerOID: 11},
{RoleOID: 11, DatabaseOID: 22, RoleSafe: false, OwnerOID: 11},
{RoleOID: 11, DatabaseOID: 22, RoleSafe: true, OwnerOID: 99},
} {
if state.Check(observed) == nil {
t.Fatal("对象身份或权限漂移被接受")
}
}
if (State{}).Check(good) == nil {
t.Fatal("未确认同名对象被认领")
}
}