Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7834cab97f
|
||
|
|
72ce3eda40 | ||
|
|
35ada6d7eb
|
||
|
|
371248659b
|
||
|
|
6b2808ce91 |
@@ -54,8 +54,8 @@ const (
|
|||||||
ReclaimDelete ReclaimPolicy = "Delete"
|
ReclaimDelete ReclaimPolicy = "Delete"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
|
// CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。
|
||||||
// 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
|
// 管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。
|
||||||
type CredentialReference struct {
|
type CredentialReference struct {
|
||||||
// +kubebuilder:validation:MinLength=1
|
// +kubebuilder:validation:MinLength=1
|
||||||
// +kubebuilder:validation:MaxLength=253
|
// +kubebuilder:validation:MaxLength=253
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package v1alpha1_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCredentialStatus(t *testing.T, client ctrlclient.Client) {
|
||||||
|
database := validDatabase("credential-status")
|
||||||
|
if err := client.Create(t.Context(), database); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withoutLocation := database.DeepCopy()
|
||||||
|
withoutLocation.Status.CredentialVersion = 1
|
||||||
|
if err := client.Status().Update(t.Context(), withoutLocation); !apierrors.IsInvalid(err) {
|
||||||
|
t.Fatalf("没有位置不能确认版本: %v", err)
|
||||||
|
}
|
||||||
|
database.Status.CredentialRef = &databasev1alpha1.CredentialReference{
|
||||||
|
Mount: "application-secrets", Path: "applications/" + string(database.UID),
|
||||||
|
}
|
||||||
|
if err := client.Status().Update(t.Context(), database); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
testCredentialStatusRemoval(t, client, database)
|
||||||
|
changedTarget := database.DeepCopy()
|
||||||
|
changedTarget.Spec.Database = "another_database"
|
||||||
|
if err := client.Update(t.Context(), changedTarget); !apierrors.IsInvalid(err) {
|
||||||
|
t.Fatalf("凭据位置固定后不得更换实际目标: %v", err)
|
||||||
|
}
|
||||||
|
stale := database.DeepCopy()
|
||||||
|
database.Status.CredentialVersion = 1
|
||||||
|
if err := client.Status().Update(t.Context(), database); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stale.Status.Phase = "Binding"
|
||||||
|
if err := client.Status().Update(t.Context(), stale); !apierrors.IsConflict(err) {
|
||||||
|
t.Fatalf("旧 resourceVersion 不得覆盖确认结果: %v", err)
|
||||||
|
}
|
||||||
|
testCredentialStatusRemoval(t, client, database)
|
||||||
|
for _, version := range []int64{0, -1, 2} {
|
||||||
|
changed := database.DeepCopy()
|
||||||
|
changed.Status.CredentialVersion = version
|
||||||
|
if err := client.Status().Update(t.Context(), changed); !apierrors.IsInvalid(err) {
|
||||||
|
t.Fatalf("不可移除或更改确认版本 %d: %v", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database.Status.Conditions = []metav1.Condition{{
|
||||||
|
Type: "CredentialsReady", Status: metav1.ConditionFalse,
|
||||||
|
Reason: "DependencyUnavailable", Message: "凭据暂时无法读取",
|
||||||
|
ObservedGeneration: database.Generation, LastTransitionTime: metav1.Now(),
|
||||||
|
}}
|
||||||
|
if err := client.Status().Update(t.Context(), database); err != nil {
|
||||||
|
t.Fatalf("当前不可用不应阻止保留确认记录: %v", err)
|
||||||
|
}
|
||||||
|
reloaded := &databasev1alpha1.PostgreSQLDatabase{}
|
||||||
|
if err := client.Get(t.Context(), ctrlclient.ObjectKeyFromObject(database), reloaded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reloaded.Status.CredentialVersion != 1 || reloaded.Status.CredentialRef == nil {
|
||||||
|
t.Fatal("重读丢失凭据确认记录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCredentialStatusRemoval(t *testing.T, client ctrlclient.Client, database *databasev1alpha1.PostgreSQLDatabase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, change := range []struct {
|
||||||
|
name string
|
||||||
|
edit func(*databasev1alpha1.PostgreSQLDatabase)
|
||||||
|
}{
|
||||||
|
{"移除整个 status", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status = databasev1alpha1.PostgreSQLDatabaseStatus{} }},
|
||||||
|
{"移除位置", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef = nil }},
|
||||||
|
{"修改 mount", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef.Mount = "other" }},
|
||||||
|
{"修改 path", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef.Path = "applications/other" }},
|
||||||
|
} {
|
||||||
|
t.Run(change.name, func(t *testing.T) {
|
||||||
|
changed := database.DeepCopy()
|
||||||
|
change.edit(changed)
|
||||||
|
if err := client.Status().Update(t.Context(), changed); !apierrors.IsInvalid(err) {
|
||||||
|
t.Fatalf("不应接受已固定凭据位置的更改: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ type PostgreSQLDatabaseStatus struct {
|
|||||||
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
||||||
// +optional
|
// +optional
|
||||||
InstanceUID types.UID `json:"instanceUID,omitempty"`
|
InstanceUID types.UID `json:"instanceUID,omitempty"`
|
||||||
|
// CredentialRef 在首次外部写入前固定凭据位置;部署配置变化不迁移此位置。
|
||||||
|
// +optional
|
||||||
|
CredentialRef *CredentialReference `json:"credentialRef,omitempty"`
|
||||||
|
// CredentialVersion 只在创建并回读成功后记录,不表示凭据当前仍可用。
|
||||||
|
// 省略表示尚未确认;已有凭据不能仅凭读取成功补记确认。
|
||||||
|
// +kubebuilder:validation:Minimum=1
|
||||||
|
// +optional
|
||||||
|
CredentialVersion int64 `json:"credentialVersion,omitempty"`
|
||||||
// Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
|
// Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
|
||||||
// +optional
|
// +optional
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
@@ -42,7 +50,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(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID))) || (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database == oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) || self.spec.credentialRef == oldSelf.spec.credentialRef))",message="managed database target cannot change after observation or binding starts"
|
// +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.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.spec.tenantRef) || (has(oldSelf.status) && (has(oldSelf.status.instanceUID) || has(oldSelf.status.credentialRef)))) || (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database == oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) || self.spec.credentialRef == oldSelf.spec.credentialRef))",message="managed database target cannot change after observation or binding starts"
|
||||||
// +kubebuilder:printcolumn:name="Instance",type=string,JSONPath=`.spec.instanceRef.name`
|
// +kubebuilder:printcolumn:name="Instance",type=string,JSONPath=`.spec.instanceRef.name`
|
||||||
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.database`
|
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.database`
|
||||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
|
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func TestDatabaseAPI(t *testing.T) {
|
|||||||
t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) })
|
t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) })
|
||||||
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) { testSamples(t, client, scheme) })
|
t.Run("仓库示例", func(t *testing.T) { testSamples(t, client, scheme) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,11 @@ func (in *PostgreSQLDatabaseSpec) DeepCopy() *PostgreSQLDatabaseSpec {
|
|||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) {
|
func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.CredentialRef != nil {
|
||||||
|
in, out := &in.CredentialRef, &out.CredentialRef
|
||||||
|
*out = new(CredentialReference)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
if in.Conditions != nil {
|
if in.Conditions != nil {
|
||||||
in, out := &in.Conditions, &out.Conditions
|
in, out := &in.Conditions, &out.Conditions
|
||||||
*out = make([]v1.Condition, len(*in))
|
*out = make([]v1.Condition, len(*in))
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ spec:
|
|||||||
properties:
|
properties:
|
||||||
credentialRef:
|
credentialRef:
|
||||||
description: |-
|
description: |-
|
||||||
CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
|
CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。
|
||||||
只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
|
管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。
|
||||||
properties:
|
properties:
|
||||||
mount:
|
mount:
|
||||||
maxLength: 253
|
maxLength: 253
|
||||||
@@ -198,6 +198,29 @@ spec:
|
|||||||
x-kubernetes-list-map-keys:
|
x-kubernetes-list-map-keys:
|
||||||
- type
|
- type
|
||||||
x-kubernetes-list-type: map
|
x-kubernetes-list-type: map
|
||||||
|
credentialRef:
|
||||||
|
description: CredentialRef 在首次外部写入前固定凭据位置;部署配置变化不迁移此位置。
|
||||||
|
properties:
|
||||||
|
mount:
|
||||||
|
maxLength: 253
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
description: Path 是 mount 内的逻辑路径,不含 KV v2 的 data/ API 前缀。
|
||||||
|
maxLength: 1024
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- mount
|
||||||
|
- path
|
||||||
|
type: object
|
||||||
|
credentialVersion:
|
||||||
|
description: |-
|
||||||
|
CredentialVersion 只在创建并回读成功后记录,不表示凭据当前仍可用。
|
||||||
|
省略表示尚未确认;已有凭据不能仅凭读取成功补记确认。
|
||||||
|
format: int64
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
instanceUID:
|
instanceUID:
|
||||||
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
||||||
type: string
|
type: string
|
||||||
@@ -212,13 +235,22 @@ spec:
|
|||||||
- spec
|
- spec
|
||||||
type: object
|
type: object
|
||||||
x-kubernetes-validations:
|
x-kubernetes-validations:
|
||||||
|
- message: credentialVersion requires credentialRef
|
||||||
|
rule: '!has(self.status) || !has(self.status.credentialVersion) || has(self.status.credentialRef)'
|
||||||
|
- message: recorded credentialRef cannot change or be removed
|
||||||
|
rule: '!(has(oldSelf.status) && has(oldSelf.status.credentialRef)) || (has(self.status)
|
||||||
|
&& has(self.status.credentialRef) && self.status.credentialRef == oldSelf.status.credentialRef)'
|
||||||
|
- message: confirmed credentialVersion cannot change or be removed
|
||||||
|
rule: '!(has(oldSelf.status) && has(oldSelf.status.credentialVersion)) ||
|
||||||
|
(has(self.status) && has(self.status.credentialVersion) && self.status.credentialVersion
|
||||||
|
== oldSelf.status.credentialVersion)'
|
||||||
- message: managed database target cannot change after observation or binding
|
- message: managed database target cannot change after observation or binding
|
||||||
starts
|
starts
|
||||||
rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID)))
|
rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && (has(oldSelf.status.instanceUID)
|
||||||
|| (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database
|
|| has(oldSelf.status.credentialRef)))) || (self.spec.instanceRef == oldSelf.spec.instanceRef
|
||||||
== oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole
|
&& self.spec.database == oldSelf.spec.database && self.spec.loginRole
|
||||||
&& self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef)
|
== oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source &&
|
||||||
== has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef)
|
has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef)
|
||||||
|| self.spec.credentialRef == oldSelf.spec.credentialRef))'
|
|| self.spec.credentialRef == oldSelf.spec.credentialRef))'
|
||||||
served: true
|
served: true
|
||||||
storage: true
|
storage: true
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ Makefile 与 Dockerfile 均继续构建 `cmd/main.go`。
|
|||||||
条件分支,也不为此引入插件注册框架。组件启动失败时释放已装配资源,正常退出则先停止
|
条件分支,也不为此引入插件注册框架。组件启动失败时释放已装配资源,正常退出则先停止
|
||||||
manager worker,再释放连接。
|
manager worker,再释放连接。
|
||||||
|
|
||||||
|
Database 使用 Wire 式的显式构造函数注入,目前手写装配,不引入 Wire 生成器、Dig/Fx
|
||||||
|
容器或运行时服务查找。`database_wiring.go` 集中展示 repository → 用例 → controller 的
|
||||||
|
对象构造;`registerDatabaseControllers` 负责注册,返回的 `closeDatabaseConnections`
|
||||||
|
只在 worker 停止后关闭 Instance 连接。controller 在启动时接收完整依赖,不在 Reconcile
|
||||||
|
或 SetupWithManager 中补建 adapter/service;缺失依赖在注册时失败。
|
||||||
|
|
||||||
|
domain 持有凭据值对象、准备资格与创建恢复规则;application 只组织读写、调用领域判断
|
||||||
|
和保存结果。用例需要的 repository 接口与并发快照仍由消费方定义,不把所有类型都强塞
|
||||||
|
进领域。Kubernetes adapter 将领域阶段映射为已有 Conditions,领域不依赖其字符串协议。
|
||||||
|
单测检查 domain/controller 的依赖边界,并验证共享 writer、直连 reader 和服务的注入。
|
||||||
|
|
||||||
基础设施能力属于整个 controller-manager,不因首个消费者是 Database 就归入该领域。
|
基础设施能力属于整个 controller-manager,不因首个消费者是 Database 就归入该领域。
|
||||||
`internal/infra/openbao` 管理官方 SDK client 的 TLS 配置、Kubernetes 认证及 token 生命周期,
|
`internal/infra/openbao` 管理官方 SDK client 的 TLS 配置、Kubernetes 认证及 token 生命周期,
|
||||||
不依赖 Database 或其他产品领域。Bao client 默认禁用自动重试,写入结果不确定时由用例处理;
|
不依赖 Database 或其他产品领域。Bao client 默认禁用自动重试,写入结果不确定时由用例处理;
|
||||||
|
|||||||
+42
-5
@@ -43,8 +43,8 @@ registry 准备决策;这一依赖现已从代码移除,不能把旧运行
|
|||||||
- 领域层不依赖 Kubernetes types、数据库 driver 或凭据 provider。
|
- 领域层不依赖 Kubernetes types、数据库 driver 或凭据 provider。
|
||||||
- CredentialReference 只携带管理 Secret 的名称与字段映射,不包含 Secret 内容或 OpenBao path。
|
- CredentialReference 只携带管理 Secret 的名称与字段映射,不包含 Secret 内容或 OpenBao path。
|
||||||
- Instance checkpoint 不是外部事实;实际能力必须由 application/adapter 观察后交给领域对象判断。
|
- Instance checkpoint 不是外部事实;实际能力必须由 application/adapter 观察后交给领域对象判断。
|
||||||
- 当前代码只检查 Instance 供应前置条件,不授予 Tenant 所有权或外部写入权限,也不表示
|
- Instance 观察只检查供应前置条件,不单独授予 Tenant 所有权或外部写入权限;凭据准备
|
||||||
Database API 已经可用。
|
另行校验双向绑定和保护,尚不表示完整 Database API 已经可用。
|
||||||
|
|
||||||
## 管理凭据与连接切片
|
## 管理凭据与连接切片
|
||||||
|
|
||||||
@@ -168,9 +168,46 @@ Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Data
|
|||||||
真实后端覆盖创建/回读、并发唯一创建、重建适配器读取、软删除冲突、固定前缀 token
|
真实后端覆盖创建/回读、并发唯一创建、重建适配器读取、软删除冲突、固定前缀 token
|
||||||
拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。
|
拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。
|
||||||
|
|
||||||
认证会话已按下面的显式参数接入 manager;凭据存储尚未接入供应用例。
|
认证会话和凭据准备用例已接入 manager,但默认不启用外部写入。
|
||||||
Database 状态中的稳定位置和已确认步骤、供应 service/controller、PostgreSQL 创建以及 ESO 交付仍未完成。
|
Database 已有 `status.credentialRef` 和 `status.credentialVersion` 的字段与 CEL 校验:
|
||||||
测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。
|
固定位置、只在创建并回读成功后确认版本,二者写入后不可清空或修改。
|
||||||
|
`ReadConfirmed` 按已确认版本检查最新 KV 值,删除或版本漂移报 Conflict,不读取旧版本掩盖变化。
|
||||||
|
测试 token 只用于临时 fixture,不是生产静态 token 配置接口。
|
||||||
|
|
||||||
|
### 凭据准备闭环
|
||||||
|
|
||||||
|
配置认证后,显式设置 `--database-credential-mount` 启用准备;路径默认
|
||||||
|
`applications/<Database UID>`,前缀由 `--database-credential-prefix` 配置。
|
||||||
|
`CredentialPreparation` 用例检查 Provision 来源、双向绑定 UID、申请目标、finalizer、
|
||||||
|
删除/Released 状态及当前 Instance Ready;导入资源不执行本流程。
|
||||||
|
顺序为固定位置 → 确认后端尚无凭据 → 保存 CreationStarted → 创建并回读 → 保存确认版本。
|
||||||
|
`CredentialReconciler` 只负责 Database/Tenant/Instance watch 和 30 秒依赖重查;
|
||||||
|
Kubernetes repository 负责直接读取及有 resourceVersion 保护的状态更新。
|
||||||
|
三类 controller 的依赖均在 bootstrap 显式组装,不在 Reconcile 中构造服务。
|
||||||
|
`domain/credential` 保存应用凭据值对象、准备资格、固定位置与未确认创建的恢复规则;
|
||||||
|
application 保留 I/O 顺序、消费方接口及并发快照,不再复用绑定用例的快照类型。
|
||||||
|
领域阶段与 `CredentialsReady`/Reason 的转换由 Kubernetes adapter 负责,已有 API 保持兼容。
|
||||||
|
|
||||||
|
外部操作前后回查目标:Database 必须仍是同一 UID/resourceVersion,Tenant/Instance 必须
|
||||||
|
保持绑定、spec generation、删除状态、finalizer 保护与有效 Ready;无关 Conditions 刷新
|
||||||
|
不构成目标变化。不存在跨 Kubernetes/OpenBao 原子事务:中途发生相关变化时停止确认,
|
||||||
|
留下可观察状态,交给后续协调或人工核实,不盲目重试过期的确认写入。
|
||||||
|
|
||||||
|
`CredentialsReady=False/CreationStarted` 是正在进行而未确认的诊断,不是成功证明。
|
||||||
|
任何新一轮协调遇到该状态且没有确认版本,都转为 Conflict;即使进程在发出请求前退出
|
||||||
|
也采用这一保守边界。明确的认证/权限拒绝可等待恢复;响应丢失、回读失败、确认保存失败
|
||||||
|
及未确认的已有值均不会自动认领或重新生成密码。Conflict 保留首次原因,普通依赖错误
|
||||||
|
保留已确认版本;配置变化停止在原位置,不搬迁或覆盖。并行实例中途撞上已开始的创建
|
||||||
|
同样可能保守地要求人工处理,不承诺无损接续;多副本部署应启用既有 leader election。
|
||||||
|
|
||||||
|
成功只设置 `CredentialsReady=True`,Database Ready 仍为 False/ProvisioningIncomplete,
|
||||||
|
Tenant 仍未完成交付。本切片没有 PostgreSQL role/database 创建、扩展安装、ESO 投射、
|
||||||
|
Retain 释放或 Delete 清理,也不会解除 finalizer。不要作为完整 DBaaS 部署。
|
||||||
|
|
||||||
|
单元测试穷举前置条件;真实 API server + 隔离 Bao 验证创建、状态确认、幂等、重启、并发、
|
||||||
|
依赖恢复、固定位置、不确定结果、确认保存失败和删除边界;实际 manager 验证 watch 驱动
|
||||||
|
及重启。该 fixture 只声明 Instance 前置 Ready,真实 PostgreSQL 管理能力由既有 Instance
|
||||||
|
集成测试覆盖,不把凭据准备验收当成实际建库或应用登录验收。
|
||||||
|
|
||||||
## OpenBao Kubernetes 认证会话
|
## OpenBao Kubernetes 认证会话
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 |
|
| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 |
|
||||||
| API group/version | `database.ayatori.ddupan.top/v1alpha1` |
|
| API group/version | `database.ayatori.ddupan.top/v1alpha1` |
|
||||||
| 最后更新 | 2026-09-25 |
|
| 最后更新 | 2026-09-27 |
|
||||||
|
|
||||||
以 [系统规格](specification.md) 与
|
以 [系统规格](specification.md) 与
|
||||||
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码,
|
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码,
|
||||||
@@ -24,6 +24,8 @@ Go 类型位于 `api/database/v1alpha1`,CRD 随 `config/crd` 发布;manager
|
|||||||
| Database | `spec.reclaimPolicy` | Retain 默认或 Delete |
|
| Database | `spec.reclaimPolicy` | Retain 默认或 Delete |
|
||||||
| Database | `spec.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 |
|
| Database | `spec.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 |
|
||||||
| Database | `status.instanceUID` | 观察时的 Instance 身份 |
|
| Database | `status.instanceUID` | 观察时的 Instance 身份 |
|
||||||
|
| Database | `status.credentialRef.mount/path` | 首次写入前固定的 KV v2 位置,不随部署配置迁移 |
|
||||||
|
| Database | `status.credentialVersion` | 创建并回读成功后确认的正整数版本;省略表示未确认 |
|
||||||
| 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 |
|
||||||
@@ -36,6 +38,15 @@ Instance phase 沿用已批准枚举;Database/Tenant phase 暂不冻结供应
|
|||||||
`credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。
|
`credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。
|
||||||
其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。
|
其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。
|
||||||
|
|
||||||
|
凭据位置与确认版本一旦写入便不可更改或移除;确认版本必须有对应位置。Conditions 描述
|
||||||
|
当前可用性,不替代确认记录,也不能因读取暂时失败而清空记录。上述 schema 已有真实 API
|
||||||
|
server 校验;独立的凭据准备用例在显式启用后填写这些字段,绑定 controller 不负责外部写入。
|
||||||
|
未确认的已有值必须报 Conflict,不能用读取成功补记版本;已确认版本的恢复读取检查最新
|
||||||
|
KV 版本,删除或版本漂移均需人工处理,不回退旧版本或生成替代密码。第一版不提供轮换入口。
|
||||||
|
`CredentialsReady` 条件只描述凭据准备结果;它不授权实际数据库交付。
|
||||||
|
`CreationStarted` 且无确认版本表示创建未完成确认,重入时停在 Conflict;不尝试推断
|
||||||
|
进程中断前请求是否发出。完整执行和测试边界见[凭据准备闭环](README.md#凭据准备闭环)。
|
||||||
|
|
||||||
示例:[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)。
|
||||||
@@ -47,7 +58,7 @@ API 接受两个 Tenant 引用同一 Database 不表示允许双重绑定;排
|
|||||||
已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。
|
已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。
|
||||||
同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。
|
同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。
|
||||||
|
|
||||||
Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察或
|
Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察、凭据位置或
|
||||||
绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。
|
绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。
|
||||||
读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。
|
读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。
|
||||||
绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照
|
绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 部署与配置
|
# 部署与配置
|
||||||
|
|
||||||
> 本页区分已实现的 Instance 观测配置与尚未接入的供应/交付目标合同。
|
> 本页区分已实现的 Instance 观测、认证和凭据准备配置,与尚未接入的 PostgreSQL 供应/交付合同。
|
||||||
> 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。
|
> 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。
|
||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
@@ -45,11 +45,11 @@ Instance 观测)与 `--database-root-cert`(公开 PostgreSQL CA PEM 路径
|
|||||||
| `--openbao-auth-mount` | 已实现,`kubernetes` | Kubernetes auth mount 名称 |
|
| `--openbao-auth-mount` | 已实现,`kubernetes` | Kubernetes auth mount 名称 |
|
||||||
| `--openbao-auth-role` | 已实现,启用时必填 | OpenBao 登录 role |
|
| `--openbao-auth-role` | 已实现,启用时必填 | OpenBao 登录 role |
|
||||||
| `--openbao-ca-cert` | 已实现,默认系统信任根 | OpenBao 公开 CA PEM 路径 |
|
| `--openbao-ca-cert` | 已实现,默认系统信任根 | OpenBao 公开 CA PEM 路径 |
|
||||||
| `--openbao-kv-mount` | `kv` | KV v2 mount;开发可显式用 `secret` |
|
| `--database-credential-mount` | 已实现,默认空 | 显式设置后启用应用凭据准备,要求已配置 OpenBao 认证 |
|
||||||
| `--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 |
|
||||||
| `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 |
|
| `--database-credential-prefix` | 已实现,默认 `applications` | Database UID 路径的 mount-relative 前缀;不迁移已有位置 |
|
||||||
| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 |
|
| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 |
|
||||||
| `--database-root-cert` | 已实现 | 只读 PEM trust bundle,不含私钥;沿用 Instance 连接配置 |
|
| `--database-root-cert` | 已实现 | 只读 PEM trust bundle,不含私钥;沿用 Instance 连接配置 |
|
||||||
| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 |
|
| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 |
|
||||||
@@ -122,8 +122,20 @@ base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空
|
|||||||
|
|
||||||
## OpenBao 与 ESO
|
## OpenBao 与 ESO
|
||||||
|
|
||||||
controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2
|
当前凭据准备只需固定前缀下 KV v2 data 的 create/read/update 权限,不需要 metadata 或
|
||||||
data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。
|
delete 权限,也不读取管理凭据路径。示例(`secret` 为测试 mount,需替换为实际配置):
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
path "secret/data/applications/*" {
|
||||||
|
capabilities = ["create", "read", "update"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在上面的认证启动参数基础上增加 `--database-credential-mount=secret` 即启用凭据准备;
|
||||||
|
需要不同前缀时同时配置 `--database-credential-prefix` 和对应 policy。仅设置
|
||||||
|
`--openbao-address` 不会启用凭据创建。缺少认证配置或非法 mount/prefix 在启动时失败。
|
||||||
|
新字段 CRD 必须先升级再启用 controller,否则 API pruning 会使确认记录无法保存。
|
||||||
|
未来 Delete 清理才需要永久删除全部版本及 metadata 的权限,本切片不提前授予。
|
||||||
|
|
||||||
管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份
|
管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份
|
||||||
只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得
|
只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得
|
||||||
|
|||||||
@@ -5,13 +5,22 @@
|
|||||||
|
|
||||||
## 当前绑定切片的限制
|
## 当前绑定切片的限制
|
||||||
|
|
||||||
源码已接入绑定 controller,未接入 PostgreSQL 供应、OpenBao/ESO 交付或删除清理。
|
源码已接入绑定、Instance 观测与可选的 Bao 凭据准备,未接入 PostgreSQL 供应、ESO 交付或删除清理。
|
||||||
Bound/BindingComplete 只表示 Kubernetes 双向记录一致,Ready 仍为 False。
|
Bound/BindingComplete 只表示 Kubernetes 双向记录一致,Ready 仍为 False。
|
||||||
Tenant 删除会保留 `database.ayatori.ddupan.top/tenant-protection` 并报告 DeletionPending;
|
Tenant 删除会保留 `database.ayatori.ddupan.top/tenant-protection` 并报告 DeletionPending;
|
||||||
Database 的 `database.ayatori.ddupan.top/database-protection` 也尚无清理后移除路径。
|
Database 的 `database.ayatori.ddupan.top/database-protection` 也尚无清理后移除路径。
|
||||||
这是未完成能力的明确边界,不是已经实现的 Retain/Delete 恢复逻辑。不要将此切片部署为
|
这是未完成能力的明确边界,不是已经实现的 Retain/Delete 恢复逻辑。不要将此切片部署为
|
||||||
业务 DBaaS,也不要为了消除等待状态直接移除 finalizer;后续必须补齐清理与验收。
|
业务 DBaaS,也不要为了消除等待状态直接移除 finalizer;后续必须补齐清理与验收。
|
||||||
|
|
||||||
|
凭据准备现在可单独启用,见[部署参数](deployment.md)。观察 Database 的
|
||||||
|
`CredentialsReady`、固定 `status.credentialRef` 和 `status.credentialVersion`,不要导出
|
||||||
|
凭据内容。Prepared 只代表 Bao 凭据可用,不代表已建库或 Tenant Ready。
|
||||||
|
`CreationStarted` 在创建结果确认前持久化;重启或失败留下该状态时报告 Conflict。
|
||||||
|
这包括“状态已写但请求还没发出”的保守停止;不要因为当前位置暂时为空就重试生成密码。
|
||||||
|
无确认版本的 Conflict 不会自行消失,后端恢复也不自动重入;应暂停该 controller、等待
|
||||||
|
在途请求结束后核实版本历史、残留与绑定,再决定清理或显式导入。不要清空整个 status
|
||||||
|
或更改固定引用来绕过保护。导入和完整人工恢复入口仍待对应切片实现。
|
||||||
|
|
||||||
## 日常检查
|
## 日常检查
|
||||||
|
|
||||||
Instance 观察已实现:先确认 manager 配置了 `--database-secret-namespace` 或 `POD_NAMESPACE`,
|
Instance 观察已实现:先确认 manager 配置了 `--database-secret-namespace` 或 `POD_NAMESPACE`,
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenan
|
|||||||
位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。
|
位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。
|
||||||
不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。
|
不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。
|
||||||
|
|
||||||
|
2026-09-27 确认最小凭据记录:Database `status.credentialRef` 在首次外部写入前固定
|
||||||
|
mount/path;`status.credentialVersion` 仅在成功创建并回读后保存 KV 版本。位置和确认版本
|
||||||
|
不得自动更改或清空,Conditions 只描述当前可用性。已有值但无确认版本时报告 Conflict,
|
||||||
|
不能靠读取成功认领;已确认凭据消失或最新版本不一致同样停止,等待人工核实。
|
||||||
|
部署参数变化不得搬迁旧位置;确认记录写入的 resourceVersion 冲突不能通过盲目重试覆盖。
|
||||||
|
|
||||||
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
|
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
|
||||||
见 [安全模型](security.md)。这些安全约束继续适用。
|
见 [安全模型](security.md)。这些安全约束继续适用。
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||||
|
databasebao "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
||||||
@@ -14,14 +17,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type databaseOptions struct {
|
type databaseOptions struct {
|
||||||
secretNamespace string
|
secretNamespace string
|
||||||
rootCert string
|
rootCert string
|
||||||
|
credentialMount string
|
||||||
|
credentialPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *databaseOptions) bindFlags(flags *flag.FlagSet) {
|
func (o *databaseOptions) bindFlags(flags *flag.FlagSet) {
|
||||||
flags.StringVar(&o.secretNamespace, "database-secret-namespace", os.Getenv("POD_NAMESPACE"),
|
flags.StringVar(&o.secretNamespace, "database-secret-namespace", os.Getenv("POD_NAMESPACE"),
|
||||||
"固定管理 Secret namespace;为空时不启用 Instance 观测")
|
"固定管理 Secret namespace;为空时不启用 Instance 观测")
|
||||||
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.credentialPrefix, "database-credential-prefix", "applications", "应用凭据路径前缀;已有固定位置不随配置变化迁移")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o databaseOptions) configureManager(options *ctrl.Options) {
|
func (o databaseOptions) configureManager(options *ctrl.Options) {
|
||||||
@@ -30,21 +37,39 @@ func (o databaseOptions) configureManager(options *ctrl.Options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupDatabase 封装 Database 的内部装配,并返回在 manager 停止后执行的清理。
|
// registerDatabaseControllers 封装 Database 的内部装配,并返回在 manager 停止后执行的清理。
|
||||||
func setupDatabase(ctx context.Context, manager ctrl.Manager, options databaseOptions) (func(), error) {
|
func registerDatabaseControllers(ctx context.Context, manager ctrl.Manager, options databaseOptions, baoClient *bao.Client) (func(), error) {
|
||||||
cleanup := func() {}
|
closeDatabaseConnections := func() {}
|
||||||
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)
|
||||||
}
|
}
|
||||||
cleanup = service.Close
|
closeDatabaseConnections = service.Close
|
||||||
}
|
}
|
||||||
if err := (&databasecontroller.BindingReconciler{}).SetupWithManager(ctx, manager); err != nil {
|
if err := wireBindingController(manager.GetClient(), manager.GetAPIReader()).SetupWithManager(ctx, manager); err != nil {
|
||||||
cleanup()
|
closeDatabaseConnections()
|
||||||
return nil, fmt.Errorf("set up Database binding controller: %w", err)
|
return nil, fmt.Errorf("set up Database binding controller: %w", err)
|
||||||
}
|
}
|
||||||
return cleanup, nil
|
if err := setupCredentialPreparation(manager, options, baoClient); err != nil {
|
||||||
|
closeDatabaseConnections()
|
||||||
|
return nil, fmt.Errorf("set up Database credential preparation: %w", err)
|
||||||
|
}
|
||||||
|
return closeDatabaseConnections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCredentialPreparation(manager ctrl.Manager, options databaseOptions, baoClient *bao.Client) error {
|
||||||
|
if options.credentialMount == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if baoClient == nil {
|
||||||
|
return fmt.Errorf("database credential preparation requires OpenBao authentication configuration")
|
||||||
|
}
|
||||||
|
store, err := databasebao.NewCredentials(baoClient, options.credentialMount, options.credentialPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return wireCredentialController(manager.GetClient(), manager.GetAPIReader(), store).SetupWithManager(manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupInstanceObservation(manager ctrl.Manager, namespace, rootCert string) (*application.InstanceService, error) {
|
func setupInstanceObservation(manager ctrl.Manager, namespace, rootCert string) (*application.InstanceService, error) {
|
||||||
@@ -56,7 +81,7 @@ func setupInstanceObservation(manager ctrl.Manager, namespace, rootCert string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: namespace}
|
reconciler := wireInstanceController(manager.GetClient(), manager.GetAPIReader(), service, namespace)
|
||||||
if err := reconciler.SetupWithManager(manager); err != nil {
|
if err := reconciler.SetupWithManager(manager); err != nil {
|
||||||
service.Close()
|
service.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件是 Database 的显式依赖图:adapter → 用例 → controller。
|
||||||
|
// 构造只在启动时执行;用例和 controller 都不持有容器或动态查找依赖。
|
||||||
|
func wireBindingController(writer client.Client, reader client.Reader) *databasecontroller.BindingReconciler {
|
||||||
|
resources := &kubernetes.BindingResources{Client: writer, Reader: reader}
|
||||||
|
service := &application.BindingService{Resources: resources}
|
||||||
|
return databasecontroller.NewBindingReconciler(writer, service, resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wireCredentialController(writer client.Client, reader client.Reader, store application.CredentialStore) *databasecontroller.CredentialReconciler {
|
||||||
|
resources := &kubernetes.CredentialResources{Client: writer, Reader: reader}
|
||||||
|
service := &application.CredentialPreparation{Resources: resources, Store: store}
|
||||||
|
return databasecontroller.NewCredentialReconciler(writer, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wireInstanceController(writer client.Client, reader client.Reader, observer application.InstanceObserver, namespace string) *databasecontroller.InstanceReconciler {
|
||||||
|
resources := &kubernetes.InstanceResources{Client: writer, Reader: reader}
|
||||||
|
service := &application.InstanceReconciliation{Resources: resources, Observer: observer}
|
||||||
|
return databasecontroller.NewInstanceReconciler(writer, service, resources, namespace)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
const controllerLayer = "controller"
|
||||||
|
|
||||||
|
func TestDatabaseExplicitWiring(t *testing.T) {
|
||||||
|
writer := fake.NewClientBuilder().Build()
|
||||||
|
directReader := fake.NewClientBuilder().Build()
|
||||||
|
binder := wireBindingController(writer, directReader)
|
||||||
|
bindingResources, ok := binder.Service.Resources.(*kubernetes.BindingResources)
|
||||||
|
if !ok || binder.Client != writer || bindingResources.Reader != directReader || bindingResources.Client != writer || binder.Presenter != bindingResources {
|
||||||
|
t.Fatal("绑定用例没有共享显式注入的 writer、直连 reader 与 presenter")
|
||||||
|
}
|
||||||
|
credentials, err := kubernetes.NewSecretCredentials(directReader, "wiring-tests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
observer, err := application.NewInstanceService(credentials, postgresql.Connector{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(observer.Close)
|
||||||
|
reconciler := wireInstanceController(writer, directReader, observer, "wiring-tests")
|
||||||
|
resources, ok := reconciler.Service.Resources.(*kubernetes.InstanceResources)
|
||||||
|
if !ok || resources.Reader != directReader || resources.Client != writer || reconciler.Service.Observer != observer || reconciler.Presenter != resources {
|
||||||
|
t.Fatal("Instance 的服务或读取边界未按依赖图注入")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止领域重新依赖用例/存储,也防止 controller 再次私自构造具体 adapter。
|
||||||
|
func TestDatabaseLayerImports(t *testing.T) {
|
||||||
|
for _, layer := range []string{"domain", controllerLayer} {
|
||||||
|
err := filepath.WalkDir(filepath.Join("../database", layer), func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, dependency := range file.Imports {
|
||||||
|
name, err := strconv.Unquote(dependency.Path.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/database/adapter/") || (layer == "domain" &&
|
||||||
|
(strings.Contains(name, "/database/application") || strings.Contains(name, "k8s.io/") || strings.Contains(name, "/internal/infra/"))) {
|
||||||
|
t.Errorf("%s 不得导入 %s", path, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,16 +48,28 @@ func TestBootstrapWithRealAPIServer(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := setupOpenBaoAuthentication(manager, options.openBao); err != nil {
|
baoClient, err := setupOpenBaoAuthentication(manager, options.openBao)
|
||||||
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cleanup, err := setupDatabase(ctx, manager, options.database)
|
closeDatabaseConnections, err := registerDatabaseControllers(ctx, manager, options.database, baoClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer closeDatabaseConnections()
|
||||||
|
// 空 API 中没有供应目标;此处验证启用路径确实注册 controller,不访问外部 Bao。
|
||||||
|
fixtureConfig := bao.NewConfig()
|
||||||
|
fixtureConfig.Address = "http://127.0.0.1:1"
|
||||||
|
fixtureClient, err := bao.NewClient(fixtureConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
options.database.credentialMount = "secret"
|
||||||
|
if err := setupCredentialPreparation(manager, options.database, fixtureClient); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- manager.Start(ctx) }()
|
go func() { done <- manager.Start(ctx) }()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -66,7 +80,7 @@ func TestBootstrapWithRealAPIServer(t *testing.T) {
|
|||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
case <-time.After(20 * time.Second):
|
case <-time.After(20 * time.Second):
|
||||||
t.Error("manager did not stop before component cleanup")
|
t.Error("manager did not stop before component closeDatabaseConnections")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if !manager.GetCache().WaitForCacheSync(ctx) {
|
if !manager.GetCache().WaitForCacheSync(ctx) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/infra/openbao"
|
"git.ddupan.top/panxiao81/ayatori/internal/infra/openbao"
|
||||||
@@ -45,28 +47,32 @@ func (o *openBaoOptions) bindFlags(flags *flag.FlagSet) {
|
|||||||
flags.StringVar(&o.identity.Audience, "openbao-token-audience", "openbao", "SA JWT audience,须匹配 OpenBao role")
|
flags.StringVar(&o.identity.Audience, "openbao-token-audience", "openbao", "SA JWT audience,须匹配 OpenBao role")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupOpenBaoAuthentication(manager ctrl.Manager, options openBaoOptions) error {
|
func setupOpenBaoAuthentication(manager ctrl.Manager, options openBaoOptions) (*bao.Client, error) {
|
||||||
if options.address == "" {
|
if options.address == "" {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
client, err := openbao.NewClient(options.address, options.caCert)
|
client, err := openbao.NewClient(options.address, options.caCert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 复用 manager 已装配的 Kubernetes client,不重复加载配置或创建客户端。
|
// 复用 manager 已装配的 Kubernetes client,不重复加载配置或创建客户端。
|
||||||
session, err := openbao.NewKubernetesSession(
|
session, err := openbao.NewKubernetesSession(
|
||||||
client, manager.GetClient(), options.mount, options.role, options.identity,
|
client, manager.GetClient(), options.mount, options.role, options.identity,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := manager.Add(session); err != nil {
|
if err := manager.Add(session); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
return manager.AddReadyzCheck("openbao-auth", func(_ *http.Request) error {
|
err = manager.AddReadyzCheck("openbao-auth", func(_ *http.Request) error {
|
||||||
if !session.Ready() {
|
if !session.Ready() {
|
||||||
return errors.New("OpenBao Kubernetes authentication unavailable")
|
return errors.New("OpenBao Kubernetes authentication unavailable")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestDefaultConfiguration(t *testing.T) {
|
|||||||
if options.manager.webhookOptions().Port != 9443 || !options.logging.Development {
|
if options.manager.webhookOptions().Port != 9443 || !options.logging.Development {
|
||||||
t.Fatal("webhook or logging defaults changed")
|
t.Fatal("webhook or logging defaults changed")
|
||||||
}
|
}
|
||||||
if options.database.secretNamespace != "" || options.openBao.address != "" {
|
if options.database.secretNamespace != "" || options.database.credentialMount != "" || options.openBao.address != "" {
|
||||||
t.Fatal("optional backends enabled by default")
|
t.Fatal("optional backends enabled by default")
|
||||||
}
|
}
|
||||||
if options.openBao.mount != "kubernetes" || options.openBao.identity.Audience != "openbao" {
|
if options.openBao.mount != "kubernetes" || options.openBao.identity.Audience != "openbao" {
|
||||||
@@ -58,6 +58,19 @@ func TestDefaultConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCredentialPreparationOptions(t *testing.T) {
|
||||||
|
options := parseTestOptions(t, "--database-credential-mount=applications-kv", "--database-credential-prefix=database")
|
||||||
|
if options.database.credentialMount != "applications-kv" || options.database.credentialPrefix != "database" {
|
||||||
|
t.Fatal("凭据准备参数未传入领域装配")
|
||||||
|
}
|
||||||
|
if err := setupCredentialPreparation(nil, options.database, nil); err == nil {
|
||||||
|
t.Fatal("启用凭据准备必须有显式配置的认证 client")
|
||||||
|
}
|
||||||
|
if err := setupCredentialPreparation(nil, databaseOptions{}, nil); err != nil {
|
||||||
|
t.Fatal("默认停用凭据准备不应要求后端")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManagerFlagOverrides(t *testing.T) {
|
func TestManagerFlagOverrides(t *testing.T) {
|
||||||
options := parseTestOptions(t,
|
options := parseTestOptions(t,
|
||||||
"--metrics-bind-address=:9090", "--metrics-secure=false", "--health-probe-bind-address=:9091",
|
"--metrics-bind-address=:9090", "--metrics-secure=false", "--health-probe-bind-address=:9091",
|
||||||
|
|||||||
@@ -23,15 +23,16 @@ func Run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := setupOpenBaoAuthentication(manager, options.openBao); err != nil {
|
baoClient, err := setupOpenBaoAuthentication(manager, options.openBao)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("set up OpenBao authentication: %w", err)
|
return fmt.Errorf("set up OpenBao authentication: %w", err)
|
||||||
}
|
}
|
||||||
cleanup, err := setupDatabase(context.Background(), manager, options.database)
|
closeDatabaseConnections, err := registerDatabaseControllers(context.Background(), manager, options.database, baoClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// manager 的 worker 完全停止后才释放组件持有的资源。
|
// manager 的 worker 完全停止后才释放组件持有的资源。
|
||||||
defer cleanup()
|
defer closeDatabaseConnections()
|
||||||
|
|
||||||
setupLog.Info("Starting manager")
|
setupLog.Info("Starting manager")
|
||||||
if err := manager.Start(ctrl.SetupSignalHandler()); err != nil {
|
if err := manager.Start(ctrl.SetupSignalHandler()); err != nil {
|
||||||
|
|||||||
@@ -51,11 +51,3 @@ func tenantReference(tenant binding.TenantIdentity) *databasev1alpha1.TenantRefe
|
|||||||
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: types.UID(tenant.UID),
|
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: types.UID(tenant.UID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindingTargetName 供 informer 索引使用;不把无效请求丢出事件映射。
|
|
||||||
func BindingTargetName(tenant *databasev1alpha1.PostgreSQLTenant) string {
|
|
||||||
if tenant.Spec.DatabaseRef != nil {
|
|
||||||
return string(tenant.Spec.DatabaseRef.Name)
|
|
||||||
}
|
|
||||||
return binding.DynamicDatabaseName(string(tenant.UID))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
TenantFinalizer = "database.ayatori.ddupan.top/tenant-protection"
|
TenantFinalizer = "database.ayatori.ddupan.top/tenant-protection"
|
||||||
DatabaseFinalizer = "database.ayatori.ddupan.top/database-protection"
|
DatabaseFinalizer = "database.ayatori.ddupan.top/database-protection"
|
||||||
|
readyCondition = "Ready"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BindingResources 读取领域所需事实,并把用例结果呈现为 CR、finalizer 与 Conditions。
|
// BindingResources 读取领域所需事实,并把用例结果呈现为 CR、finalizer 与 Conditions。
|
||||||
@@ -148,7 +149,7 @@ func (r *BindingResources) presentStatus(ctx context.Context, object *databasev1
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
|
||||||
Type: "Ready", Status: metav1.ConditionFalse, Reason: status.Reason, Message: status.Message,
|
Type: readyCondition, Status: metav1.ConditionFalse, Reason: status.Reason, Message: status.Message,
|
||||||
ObservedGeneration: object.Generation,
|
ObservedGeneration: object.Generation,
|
||||||
})
|
})
|
||||||
if equality.Semantic.DeepEqual(*previous, object.Status) {
|
if equality.Semantic.DeepEqual(*previous, object.Status) {
|
||||||
@@ -186,6 +187,6 @@ func bindingVersionConflict(resource, name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func currentReady(generation int64, conditions []metav1.Condition) bool {
|
func currentReady(generation int64, conditions []metav1.Condition) bool {
|
||||||
condition := meta.FindStatusCondition(conditions, "Ready")
|
condition := meta.FindStatusCondition(conditions, readyCondition)
|
||||||
return condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == generation
|
return condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == generation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
|
||||||
|
const credentialsReadyCondition = "CredentialsReady"
|
||||||
|
|
||||||
|
// 持久化 API 的 Reason 保持兼容,领域内部只使用准备阶段。
|
||||||
|
var credentialReasons = map[credentialdomain.Phase]string{
|
||||||
|
credentialdomain.Pending: "PreparationPending",
|
||||||
|
credentialdomain.Pinned: "LocationPinned",
|
||||||
|
credentialdomain.Creating: "CreationStarted",
|
||||||
|
credentialdomain.Prepared: "CredentialPrepared",
|
||||||
|
credentialdomain.Conflict: "Conflict",
|
||||||
|
credentialdomain.Unavailable: "DependencyUnavailable",
|
||||||
|
credentialdomain.Stopped: "PreparationStopped",
|
||||||
|
credentialdomain.InvalidTarget: "InvalidTarget",
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialReason(phase credentialdomain.Phase) string { return credentialReasons[phase] }
|
||||||
|
|
||||||
|
func credentialPhase(reason string) credentialdomain.Phase {
|
||||||
|
for phase, value := range credentialReasons {
|
||||||
|
if value == reason {
|
||||||
|
return phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return credentialdomain.Pending
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
|
||||||
|
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/binding"
|
||||||
|
"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"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialResources 只映射资源事实与状态写入,供应资格和执行顺序由用例决定。
|
||||||
|
type CredentialResources struct {
|
||||||
|
Client client.Client
|
||||||
|
Reader client.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ application.CredentialResources = (*CredentialResources)(nil)
|
||||||
|
|
||||||
|
func (r *CredentialResources) Load(ctx context.Context, name string) (*application.CredentialRecord, error) {
|
||||||
|
database := &databasev1alpha1.PostgreSQLDatabase{}
|
||||||
|
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, database); err != nil {
|
||||||
|
return nil, client.IgnoreNotFound(err)
|
||||||
|
}
|
||||||
|
record := &application.CredentialRecord{
|
||||||
|
Database: bindingDatabase(database).Database, Revision: database.ResourceVersion,
|
||||||
|
DatabaseProtected: controllerutil.ContainsFinalizer(database, DatabaseFinalizer),
|
||||||
|
Status: credentialStatus(database),
|
||||||
|
}
|
||||||
|
if database.Spec.Source != "Provision" {
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
if ref := database.Spec.TenantRef; ref != nil {
|
||||||
|
tenant := &databasev1alpha1.PostgreSQLTenant{}
|
||||||
|
err := r.Reader.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: string(ref.Name)}, tenant)
|
||||||
|
if err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
record.Tenant = &bindingTenant(tenant).Tenant
|
||||||
|
record.TenantGeneration = tenant.Generation
|
||||||
|
record.TenantProtected = controllerutil.ContainsFinalizer(tenant, TenantFinalizer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instance := &databasev1alpha1.PostgreSQLInstance{}
|
||||||
|
err := r.Reader.Get(ctx, types.NamespacedName{Name: string(database.Spec.InstanceRef.Name)}, instance)
|
||||||
|
if err != nil {
|
||||||
|
return record, client.IgnoreNotFound(err)
|
||||||
|
}
|
||||||
|
observed, err := instanceRecord(instance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record.Instance = &credentialdomain.Instance{
|
||||||
|
Identity: binding.Identity{Name: instance.Name, UID: string(instance.UID)},
|
||||||
|
Deleting: !instance.DeletionTimestamp.IsZero(), Ready: currentReady(instance.Generation, instance.Status.Conditions),
|
||||||
|
Endpoint: observed.Target.Definition().Endpoint(),
|
||||||
|
}
|
||||||
|
record.InstanceGeneration = instance.Generation
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialStatus(database *databasev1alpha1.PostgreSQLDatabase) credentialdomain.State {
|
||||||
|
status := credentialdomain.State{Version: database.Status.CredentialVersion}
|
||||||
|
if ref := database.Status.CredentialRef; ref != nil {
|
||||||
|
status.Location = &credentialdomain.Location{Mount: ref.Mount, Path: ref.Path}
|
||||||
|
}
|
||||||
|
if condition := meta.FindStatusCondition(database.Status.Conditions, credentialsReadyCondition); condition != nil {
|
||||||
|
status.Phase, status.Message = credentialPhase(condition.Reason), condition.Message
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CredentialResources) Save(ctx context.Context, record *application.CredentialRecord, status credentialdomain.State) (*application.CredentialRecord, error) {
|
||||||
|
bindingResources := &BindingResources{Client: r.Client, Reader: r.Reader}
|
||||||
|
database, err := bindingResources.databaseAtVersion(ctx, &application.BindingDatabase{Database: record.Database, Revision: record.Revision})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
previous := database.Status.DeepCopy()
|
||||||
|
if status.Location != nil {
|
||||||
|
database.Status.CredentialRef = &databasev1alpha1.CredentialReference{Mount: status.Location.Mount, Path: status.Location.Path}
|
||||||
|
}
|
||||||
|
database.Status.CredentialVersion = status.Version
|
||||||
|
conditionStatus := metav1.ConditionFalse
|
||||||
|
if status.Phase == credentialdomain.Prepared {
|
||||||
|
conditionStatus = metav1.ConditionTrue
|
||||||
|
}
|
||||||
|
meta.SetStatusCondition(&database.Status.Conditions, metav1.Condition{
|
||||||
|
Type: credentialsReadyCondition, Status: conditionStatus, Reason: credentialReason(status.Phase), Message: status.Message,
|
||||||
|
ObservedGeneration: database.Generation,
|
||||||
|
})
|
||||||
|
meta.SetStatusCondition(&database.Status.Conditions, metav1.Condition{
|
||||||
|
Type: readyCondition, Status: metav1.ConditionFalse, Reason: "ProvisioningIncomplete",
|
||||||
|
Message: "凭据准备不代表 PostgreSQL 供应、应用登录或 Tenant 交付已经完成", ObservedGeneration: database.Generation,
|
||||||
|
})
|
||||||
|
database.Status.ObservedGeneration = database.Generation
|
||||||
|
if !equality.Semantic.DeepEqual(*previous, database.Status) {
|
||||||
|
if err := r.Client.Status().Update(ctx, database); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated := *record
|
||||||
|
updated.Database = bindingDatabase(database).Database
|
||||||
|
updated.Revision = database.ResourceVersion
|
||||||
|
updated.Status = credentialStatus(database)
|
||||||
|
// 旧 CRD 会裁剪未知 status 字段。不能把 HTTP 成功当作位置/版本已保存后继续写后端。
|
||||||
|
if !equality.Semantic.DeepEqual(updated.Status.Location, status.Location) || updated.Status.Version != status.Version {
|
||||||
|
return nil, fmt.Errorf("凭据位置或版本未被 API 保留;请先升级 Database CRD,未继续外部操作")
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CredentialResources) CheckCurrent(ctx context.Context, record *application.CredentialRecord) error {
|
||||||
|
current, err := r.Load(ctx, record.Database.Identity.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current == nil || current.Database.Identity != record.Database.Identity || current.Revision != record.Revision ||
|
||||||
|
!sameCredentialDependencies(current, record) {
|
||||||
|
return apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource("postgresqldatabases").GroupResource(), record.Database.Identity.Name,
|
||||||
|
fmt.Errorf("凭据准备的资源快照已变化;停止本轮操作并重新观察"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCredentialDependencies(current, previous *application.CredentialRecord) bool {
|
||||||
|
if current.Tenant == nil || previous.Tenant == nil || current.Instance == nil || previous.Instance == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Tenant Ready 的诊断变化、Instance 对同一 generation 的观测刷新不改变写入目标。
|
||||||
|
// 仍检查申请 spec generation、完整绑定身份、删除状态、保护和当前 Instance Ready。
|
||||||
|
return current.TenantGeneration == previous.TenantGeneration &&
|
||||||
|
equality.Semantic.DeepEqual(current.Tenant, previous.Tenant) &&
|
||||||
|
current.TenantProtected == previous.TenantProtected && current.DatabaseProtected == previous.DatabaseProtected &&
|
||||||
|
current.Instance.Instance == previous.Instance.Instance && current.InstanceGeneration == previous.InstanceGeneration &&
|
||||||
|
current.Instance.Endpoint == previous.Instance.Endpoint
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ func (r *InstanceResources) PresentInstance(ctx context.Context, result applicat
|
|||||||
ready = metav1.ConditionTrue
|
ready = metav1.ConditionTrue
|
||||||
}
|
}
|
||||||
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
|
||||||
Type: "Ready", Status: ready, ObservedGeneration: object.Generation,
|
Type: readyCondition, Status: ready, ObservedGeneration: object.Generation,
|
||||||
Reason: result.Reason, Message: result.Message,
|
Reason: result.Reason, Message: result.Message,
|
||||||
})
|
})
|
||||||
if !equality.Semantic.DeepEqual(*previous, object.Status) {
|
if !equality.Semantic.DeepEqual(*previous, object.Status) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package openbao_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfirmedCredentialRecoveryWithRealOpenBao(t *testing.T) {
|
||||||
|
root := baoFixture(t)
|
||||||
|
store := fixtureStore(t, root)
|
||||||
|
credential := fixtureCredential(t)
|
||||||
|
if err := store.Create(t.Context(), credentialPath, credential); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 新 adapter 模拟进程重启:只有已持久化的版本才能恢复读取。
|
||||||
|
restarted := fixtureStore(t, root)
|
||||||
|
if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 0); err != openbao.ErrConflict {
|
||||||
|
t.Fatal("已有值不能替代缺失的确认记录")
|
||||||
|
}
|
||||||
|
observed, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1)
|
||||||
|
if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) {
|
||||||
|
t.Fatal("确认版本的凭据不能恢复读取")
|
||||||
|
}
|
||||||
|
// 即使内容相同,新增版本也不属于已确认写入;历史版本仍在不能掩盖改写。
|
||||||
|
if _, err := root.KVv2("secret").Put(t.Context(), credentialPath, credential.SecretData(), bao.WithCheckAndSet(1)); err != nil {
|
||||||
|
t.Fatal("无法准备测试中的版本变化")
|
||||||
|
}
|
||||||
|
if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1); err != openbao.ErrConflict {
|
||||||
|
t.Fatal("最新版本漂移必须报冲突")
|
||||||
|
}
|
||||||
|
if err := root.KVv2("secret").Delete(t.Context(), credentialPath); err != nil {
|
||||||
|
t.Fatal("无法准备测试中的软删除")
|
||||||
|
}
|
||||||
|
if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1); err != openbao.ErrConflict {
|
||||||
|
t.Fatal("已确认凭据被删除必须报冲突")
|
||||||
|
}
|
||||||
|
metadata, err := root.KVv2("secret").GetMetadata(t.Context(), credentialPath)
|
||||||
|
if err != nil || metadata.CurrentVersion != 2 {
|
||||||
|
t.Fatal("恢复检查不得生成或覆盖凭据")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package openbao_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||||
|
)
|
||||||
|
|
||||||
|
func credentialVersionMetadata(version int) map[string]any {
|
||||||
|
return map[string]any{kvVersionKey: version}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmedCredentialRead(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
version int64
|
||||||
|
status int
|
||||||
|
meta map[string]any
|
||||||
|
invalid bool
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{name: "已确认", version: 1, status: 200, meta: credentialVersionMetadata(1)},
|
||||||
|
{name: "未确认禁止读取", version: 0, want: openbao.ErrConflict},
|
||||||
|
{name: "版本已变化", version: 1, status: 200, meta: credentialVersionMetadata(2), want: openbao.ErrConflict},
|
||||||
|
{name: "版本为零", version: 1, status: 200, meta: credentialVersionMetadata(0), want: openbao.ErrConflict},
|
||||||
|
{name: "响应无法解析", version: 1, status: 200, want: openbao.ErrUnavailable},
|
||||||
|
{name: "内容损坏", version: 1, status: 200, meta: credentialVersionMetadata(1), invalid: true, want: openbao.ErrConflict},
|
||||||
|
{name: "凭据丢失", version: 1, status: 404, want: openbao.ErrConflict},
|
||||||
|
{name: "读取被拒绝", version: 1, status: 403, want: openbao.ErrUnavailable},
|
||||||
|
{name: "后端不可用", version: 1, status: 503, want: openbao.ErrUnavailable},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
credential := fixtureCredential(t)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if test.version == 0 {
|
||||||
|
t.Error("没有持久化确认记录不能读取已有值")
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet || r.URL.RawQuery != "" {
|
||||||
|
t.Error("只允许读取最新值,不能写入或回退历史版本")
|
||||||
|
}
|
||||||
|
w.WriteHeader(test.status)
|
||||||
|
if test.status != http.StatusOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := credential.SecretData()
|
||||||
|
if test.invalid {
|
||||||
|
delete(data, "password")
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
kvDataKey: map[string]any{kvDataKey: data, "metadata": test.meta},
|
||||||
|
}); err != nil {
|
||||||
|
t.Error("测试响应编码失败")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
store := fixtureStore(t, fixtureClient(t, server.URL))
|
||||||
|
observed, err := store.ReadConfirmed(t.Context(), credentialPath, test.version)
|
||||||
|
if err != test.want {
|
||||||
|
t.Fatalf("期望 %v,得到 %v", test.want, err)
|
||||||
|
}
|
||||||
|
if err != nil && observed.Validate() == nil {
|
||||||
|
t.Fatal("失败时不能返回可用凭据")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package openbao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ application.CredentialStore = (*Credentials)(nil)
|
||||||
|
|
||||||
|
func (c *Credentials) ProvisionLocation(uid string) (credentialdomain.Location, error) {
|
||||||
|
path, err := c.ProvisionPath(uid)
|
||||||
|
if err != nil {
|
||||||
|
return credentialdomain.Location{}, err
|
||||||
|
}
|
||||||
|
return credentialdomain.Location{Mount: c.mount, Path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credentials) ReadCredential(ctx context.Context, location credentialdomain.Location, version int64) (credentialdomain.ApplicationCredential, error) {
|
||||||
|
if location.Mount != c.mount {
|
||||||
|
return credentialdomain.ApplicationCredential{}, ErrInvalidLocation
|
||||||
|
}
|
||||||
|
if version == 0 {
|
||||||
|
return c.Read(ctx, location.Path)
|
||||||
|
}
|
||||||
|
return c.ReadConfirmed(ctx, location.Path, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credentials) CreateCredential(ctx context.Context, location credentialdomain.Location, credential credentialdomain.ApplicationCredential) (int64, error) {
|
||||||
|
if location.Mount != c.mount {
|
||||||
|
return 0, ErrInvalidLocation
|
||||||
|
}
|
||||||
|
if err := c.Create(ctx, location.Path, credential); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Create 的合同是 CAS=0 且回读版本 1 和七键完全一致。
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
@@ -26,17 +26,19 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
|
||||||
bao "github.com/openbao/openbao/api/v2"
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidLocation = errors.New("credential location is outside the configured scope")
|
ErrInvalidLocation = application.ErrCredentialLocation
|
||||||
ErrUnavailable = errors.New("credential backend unavailable")
|
ErrUnavailable = application.ErrCredentialUnavailable
|
||||||
ErrNotFound = errors.New("application credential not found")
|
ErrNotFound = application.ErrCredentialNotFound
|
||||||
ErrConflict = errors.New("credential creation requires manual conflict resolution")
|
ErrConflict = application.ErrCredentialConflict
|
||||||
ErrUncertain = errors.New("credential creation outcome is uncertain; manual resolution required")
|
ErrUncertain = application.ErrCredentialUncertain
|
||||||
)
|
)
|
||||||
|
|
||||||
var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
@@ -45,6 +47,7 @@ var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|||||||
// 本适配器既不自动认领已有值,也不提供覆盖、轮换或删除操作。
|
// 本适配器既不自动认领已有值,也不提供覆盖、轮换或删除操作。
|
||||||
type Credentials struct {
|
type Credentials struct {
|
||||||
kv *bao.KVv2
|
kv *bao.KVv2
|
||||||
|
mount string
|
||||||
basePath string
|
basePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ func NewCredentials(client *bao.Client, mount, basePath string) (*Credentials, e
|
|||||||
if client == nil || !validPath(mount) || !validPath(basePath) {
|
if client == nil || !validPath(mount) || !validPath(basePath) {
|
||||||
return nil, ErrInvalidLocation
|
return nil, ErrInvalidLocation
|
||||||
}
|
}
|
||||||
return &Credentials{kv: client.KVv2(mount), basePath: basePath}, nil
|
return &Credentials{kv: client.KVv2(mount), mount: mount, basePath: basePath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validPath(value string) bool {
|
func validPath(value string) bool {
|
||||||
@@ -79,26 +82,57 @@ func (c *Credentials) accepts(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
|
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
|
||||||
func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) {
|
func (c *Credentials) Read(ctx context.Context, path string) (credentialdomain.ApplicationCredential, error) {
|
||||||
|
secret, err := c.read(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return credentialdomain.ApplicationCredential{}, err
|
||||||
|
}
|
||||||
|
return credentialdomain.ParseApplicationCredential(secret.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadConfirmed 读取最新值并核对已持久化的确认版本,不回退读取历史版本。
|
||||||
|
// 确认后的删除或改写需要人工处理,不能因此重新生成密码。
|
||||||
|
func (c *Credentials) ReadConfirmed(ctx context.Context, path string, version int64) (credentialdomain.ApplicationCredential, error) {
|
||||||
|
if version < 1 {
|
||||||
|
return credentialdomain.ApplicationCredential{}, ErrConflict
|
||||||
|
}
|
||||||
|
secret, err := c.read(ctx, path)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return credentialdomain.ApplicationCredential{}, ErrConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return credentialdomain.ApplicationCredential{}, err
|
||||||
|
}
|
||||||
|
if secret.VersionMetadata == nil || int64(secret.VersionMetadata.Version) != version {
|
||||||
|
return credentialdomain.ApplicationCredential{}, ErrConflict
|
||||||
|
}
|
||||||
|
credential, err := credentialdomain.ParseApplicationCredential(secret.Data)
|
||||||
|
if err != nil {
|
||||||
|
return credentialdomain.ApplicationCredential{}, ErrConflict
|
||||||
|
}
|
||||||
|
return credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credentials) read(ctx context.Context, path string) (*bao.KVSecret, error) {
|
||||||
if !c.accepts(path) {
|
if !c.accepts(path) {
|
||||||
return application.ApplicationCredential{}, ErrInvalidLocation
|
return nil, ErrInvalidLocation
|
||||||
}
|
}
|
||||||
secret, err := c.kv.Get(ctx, path)
|
secret, err := c.kv.Get(ctx, path)
|
||||||
if errors.Is(err, bao.ErrSecretNotFound) {
|
if errors.Is(err, bao.ErrSecretNotFound) {
|
||||||
return application.ApplicationCredential{}, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return application.ApplicationCredential{}, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
if secret == nil || secret.Data == nil {
|
if secret == nil || secret.Data == nil {
|
||||||
return application.ApplicationCredential{}, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return application.ParseApplicationCredential(secret.Data)
|
return secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
|
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
|
||||||
// 任何不确定写入都不返回凭据;上层必须停止供应并持久化冲突,不能重新生成密码。
|
// 任何不确定写入都不返回凭据;上层必须停止供应并持久化冲突,不能重新生成密码。
|
||||||
func (c *Credentials) Create(ctx context.Context, path string, credential application.ApplicationCredential) error {
|
func (c *Credentials) Create(ctx context.Context, path string, credential credentialdomain.ApplicationCredential) error {
|
||||||
if !c.accepts(path) {
|
if !c.accepts(path) {
|
||||||
return ErrInvalidLocation
|
return ErrInvalidLocation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
|
||||||
bao "github.com/openbao/openbao/api/v2"
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,11 +37,12 @@ const (
|
|||||||
fixturePassword = "AYATORI-TEST-ONLY-application-password"
|
fixturePassword = "AYATORI-TEST-ONLY-application-password"
|
||||||
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
|
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
|
||||||
kvDataKey = "data"
|
kvDataKey = "data"
|
||||||
|
kvVersionKey = "version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func fixtureCredential(t *testing.T) application.ApplicationCredential {
|
func fixtureCredential(t *testing.T) credentialdomain.ApplicationCredential {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
credential, err := application.ParseApplicationCredential(map[string]any{
|
credential, err := credentialdomain.ParseApplicationCredential(map[string]any{
|
||||||
"username": "app_owner", "password": fixturePassword, "database": "app",
|
"username": "app_owner", "password": fixturePassword, "database": "app",
|
||||||
"host": "postgres.example", "hostaddr": "192.0.2.1", "port": "5432", "sslmode": "verify-full",
|
"host": "postgres.example", "hostaddr": "192.0.2.1", "port": "5432", "sslmode": "verify-full",
|
||||||
})
|
})
|
||||||
@@ -66,7 +68,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
|
|||||||
if json.NewDecoder(r.Body).Decode(&request) != nil || request.Options.CAS == nil || *request.Options.CAS != 0 {
|
if json.NewDecoder(r.Body).Decode(&request) != nil || request.Options.CAS == nil || *request.Options.CAS != 0 {
|
||||||
t.Error("create request must explicitly require CAS=0")
|
t.Error("create request must explicitly require CAS=0")
|
||||||
}
|
}
|
||||||
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{"version": 1}}); err != nil {
|
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{kvVersionKey: 1}}); err != nil {
|
||||||
t.Error("cannot encode fixture write response")
|
t.Error("cannot encode fixture write response")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -85,7 +87,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
response := map[string]any{kvDataKey: data}
|
response := map[string]any{kvDataKey: data}
|
||||||
if scenario != "missing metadata" {
|
if scenario != "missing metadata" {
|
||||||
response["metadata"] = map[string]any{"version": version}
|
response["metadata"] = map[string]any{kvVersionKey: version}
|
||||||
}
|
}
|
||||||
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil {
|
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil {
|
||||||
t.Error("cannot encode fixture read response")
|
t.Error("cannot encode fixture read response")
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package openbao_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 两个用例都先拿到同一真实 resourceVersion,再竞争固定位置;后续写入仍经过 API server。
|
||||||
|
type concurrentCredentialResources struct {
|
||||||
|
application.CredentialResources
|
||||||
|
readers atomic.Int32
|
||||||
|
loaded sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *concurrentCredentialResources) Load(ctx context.Context, name string) (*application.CredentialRecord, error) {
|
||||||
|
record, err := r.CredentialResources.Load(ctx, name)
|
||||||
|
if r.readers.Add(1) <= 2 {
|
||||||
|
r.loaded.Done()
|
||||||
|
r.loaded.Wait()
|
||||||
|
}
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationConcurrency(t *testing.T, f *preparationFixture) {
|
||||||
|
database, _ := f.bound(t, "concurrent")
|
||||||
|
service := f.service(t)
|
||||||
|
resources := &concurrentCredentialResources{CredentialResources: service.Resources}
|
||||||
|
resources.loaded.Add(2)
|
||||||
|
service.Resources = resources
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for range 2 {
|
||||||
|
go func() { results <- service.Reconcile(t.Context(), database.Name) }()
|
||||||
|
}
|
||||||
|
succeeded, conflicted := 0, 0
|
||||||
|
for range 2 {
|
||||||
|
err := <-results
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
succeeded++
|
||||||
|
case apierrors.IsConflict(err):
|
||||||
|
conflicted++
|
||||||
|
default:
|
||||||
|
t.Fatalf("并发用例返回意外错误: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if succeeded != 1 || conflicted != 1 {
|
||||||
|
t.Fatal("同一快照只能有一个用例成功固定位置并继续创建")
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "CredentialPrepared")
|
||||||
|
stored, err := f.bao.KVv2("secret").Get(t.Context(), database.Status.CredentialRef.Path)
|
||||||
|
if err != nil || stored.VersionMetadata.Version != 1 {
|
||||||
|
t.Fatal("并发准备用例只能产生一个凭据版本")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package openbao_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||||
|
|
||||||
|
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/application"
|
||||||
|
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
||||||
|
)
|
||||||
|
|
||||||
|
const preparationNamespace = "credential-preparation"
|
||||||
|
|
||||||
|
type preparationFixture struct {
|
||||||
|
api client.Client
|
||||||
|
config *rest.Config
|
||||||
|
scheme *runtime.Scheme
|
||||||
|
bao *bao.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPreparationFixture(t *testing.T) *preparationFixture {
|
||||||
|
t.Helper()
|
||||||
|
environment := &envtest.Environment{CRDDirectoryPaths: []string{"../../../../config/crd/bases"}, ErrorIfCRDPathMissing: true}
|
||||||
|
config, err := environment.Start()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := environment.Stop(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := corev1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
api, err := client.New(config, client.Options{Scheme: scheme})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := api.Create(t.Context(), &corev1.Namespace{Name: preparationNamespace}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &preparationFixture{api: api, config: config, scheme: scheme, bao: baoFixture(t)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *preparationFixture) bound(t *testing.T, name string) (*databasev1alpha1.PostgreSQLDatabase, *databasev1alpha1.PostgreSQLTenant) {
|
||||||
|
t.Helper()
|
||||||
|
instance := &databasev1alpha1.PostgreSQLInstance{Name: name, Spec: databasev1alpha1.PostgreSQLInstanceSpec{
|
||||||
|
Endpoint: databasev1alpha1.PostgreSQLEndpoint{Host: "postgres.example", HostAddr: "192.0.2.1"},
|
||||||
|
AdminCredentialRef: databasev1alpha1.AdminCredentialReference{Name: "management"},
|
||||||
|
}}
|
||||||
|
if err := f.api.Create(t.Context(), instance); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 本切片不连接 PG;这里只声明供应前置观察,真实管理能力由既有 PG 集成测试覆盖。
|
||||||
|
meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
|
||||||
|
Type: "Ready", Status: metav1.ConditionTrue, Reason: "FixtureReady", Message: "隔离测试前置观察", ObservedGeneration: instance.Generation,
|
||||||
|
})
|
||||||
|
if err := f.api.Status().Update(t.Context(), instance); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tenant := &databasev1alpha1.PostgreSQLTenant{Name: name, Namespace: preparationNamespace}
|
||||||
|
tenant.Spec.Provision = &databasev1alpha1.DatabaseProvisionRequest{InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(name)}}
|
||||||
|
if err := f.api.Create(t.Context(), 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(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.api.Get(t.Context(), client.ObjectKeyFromObject(tenant), tenant); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
database := &databasev1alpha1.PostgreSQLDatabase{}
|
||||||
|
if err := f.api.Get(t.Context(), client.ObjectKey{Name: string(tenant.Status.DatabaseRef.Name)}, database); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return database, tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *preparationFixture) service(t *testing.T) application.CredentialPreparation {
|
||||||
|
t.Helper()
|
||||||
|
return application.CredentialPreparation{
|
||||||
|
Resources: &kubernetes.CredentialResources{Client: f.api, Reader: f.api}, Store: fixtureStore(t, f.bao),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *preparationFixture) status(t *testing.T, database *databasev1alpha1.PostgreSQLDatabase, version int64, reason string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := f.api.Get(t.Context(), client.ObjectKeyFromObject(database), database); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
condition := meta.FindStatusCondition(database.Status.Conditions, "CredentialsReady")
|
||||||
|
if database.Status.CredentialVersion != version || condition == nil || condition.Reason != reason {
|
||||||
|
t.Fatalf("凭据版本或条件不符:version=%d,期望 reason=%s", database.Status.CredentialVersion, reason)
|
||||||
|
}
|
||||||
|
if meta.IsStatusConditionTrue(database.Status.Conditions, "Ready") {
|
||||||
|
t.Fatal("凭据准备不得宣告 Database Ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package openbao_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"maps"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
|
||||||
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
|
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"
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCredentialPreparationWithRealBackends(t *testing.T) {
|
||||||
|
fixture := newPreparationFixture(t)
|
||||||
|
t.Run("创建重启和幂等", func(t *testing.T) { testPreparationRestart(t, fixture) })
|
||||||
|
t.Run("已有值不认领", func(t *testing.T) { testPreparationExisting(t, fixture) })
|
||||||
|
t.Run("确认写入失败", func(t *testing.T) { testPreparationLostConfirmation(t, fixture) })
|
||||||
|
t.Run("后端结果不确定", func(t *testing.T) { testPreparationUncertain(t, fixture) })
|
||||||
|
t.Run("创建中绑定变化", func(t *testing.T) { testPreparationChangedBinding(t, fixture) })
|
||||||
|
t.Run("依赖恢复与固定位置", func(t *testing.T) { testPreparationDependencies(t, fixture) })
|
||||||
|
t.Run("并发用例只有一个写入", func(t *testing.T) { testPreparationConcurrency(t, fixture) })
|
||||||
|
t.Run("位置被API裁剪时拒绝外部写入", func(t *testing.T) { testPreparationPruning(t, fixture) })
|
||||||
|
t.Run("实际manager的watch与重启", func(t *testing.T) { testPreparationWatch(t, fixture) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationRestart(t *testing.T, f *preparationFixture) {
|
||||||
|
database, tenant := f.bound(t, "restart")
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "CredentialPrepared")
|
||||||
|
path := database.Status.CredentialRef.Path
|
||||||
|
before, err := f.bao.KVv2("secret").Get(t.Context(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("无法读取隔离测试凭据")
|
||||||
|
}
|
||||||
|
if before.Data["username"] != "restart" || before.Data["database"] != "restart" || len(before.Data) != 7 {
|
||||||
|
t.Fatal("用例未使用绑定目标生成七键凭据")
|
||||||
|
}
|
||||||
|
// 两次新用例实例模拟重启,第二轮必须没有无意义状态更新。
|
||||||
|
for range 2 {
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "CredentialPrepared")
|
||||||
|
revision := database.ResourceVersion
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "CredentialPrepared")
|
||||||
|
if database.ResourceVersion != revision {
|
||||||
|
t.Fatal("幂等重试不应改写 status")
|
||||||
|
}
|
||||||
|
after, err := f.bao.KVv2("secret").Get(t.Context(), path)
|
||||||
|
if err != nil || after.VersionMetadata.Version != 1 || !maps.Equal(before.Data, after.Data) {
|
||||||
|
t.Fatal("重启后不应生成或覆盖凭据")
|
||||||
|
}
|
||||||
|
if err := f.api.Delete(t.Context(), tenant); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "PreparationStopped")
|
||||||
|
if err := f.api.Delete(t.Context(), database); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "PreparationStopped")
|
||||||
|
if len(database.Finalizers) == 0 {
|
||||||
|
t.Fatal("本切片不得解除删除保护")
|
||||||
|
}
|
||||||
|
if _, err := f.bao.KVv2("secret").Get(t.Context(), path); err != nil {
|
||||||
|
t.Fatal("删除流程不得在本切片清理凭据")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationExisting(t *testing.T, f *preparationFixture) {
|
||||||
|
database, _ := f.bound(t, "existing")
|
||||||
|
store := fixtureStore(t, f.bao)
|
||||||
|
path, err := store.ProvisionPath(string(database.UID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Create(t.Context(), path, fixtureCredential(t)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, binding.Conflict)
|
||||||
|
// 即使管理员删除了后端值,未解除的不确定状态也不能自动创建。
|
||||||
|
if err := f.bao.KVv2("secret").DeleteMetadata(t.Context(), path); err != nil {
|
||||||
|
t.Fatal("无法清理隔离测试 key")
|
||||||
|
}
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.bao.KVv2("secret").Get(t.Context(), path); !errors.Is(err, bao.ErrSecretNotFound) {
|
||||||
|
t.Fatal("Conflict 重入不应生成替代凭据")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 故障只注入确认保存边界;此前写入使用真实 API server 和 Bao。
|
||||||
|
type failedConfirmationClient struct{ client.Client }
|
||||||
|
|
||||||
|
func (c failedConfirmationClient) Status() client.SubResourceWriter {
|
||||||
|
return failedConfirmationWriter{SubResourceWriter: c.Client.Status()}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failedConfirmationWriter struct{ client.SubResourceWriter }
|
||||||
|
|
||||||
|
func (w failedConfirmationWriter) Update(ctx context.Context, object client.Object, options ...client.SubResourceUpdateOption) error {
|
||||||
|
if database, ok := object.(*databasev1alpha1.PostgreSQLDatabase); ok && database.Status.CredentialVersion > 0 {
|
||||||
|
return errors.New("fixture refuses confirmation update")
|
||||||
|
}
|
||||||
|
return w.SubResourceWriter.Update(ctx, object, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationLostConfirmation(t *testing.T, f *preparationFixture) {
|
||||||
|
database, _ := f.bound(t, "confirmation")
|
||||||
|
service := f.service(t)
|
||||||
|
service.Resources = &kubernetes.CredentialResources{Client: failedConfirmationClient{f.api}, Reader: f.api}
|
||||||
|
if err := service.Reconcile(t.Context(), database.Name); err == nil {
|
||||||
|
t.Fatal("确认写入失败应返回 API 错误")
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, "CreationStarted")
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, binding.Conflict)
|
||||||
|
stored, err := f.bao.KVv2("secret").Get(t.Context(), database.Status.CredentialRef.Path)
|
||||||
|
if err != nil || stored.VersionMetadata.Version != 1 {
|
||||||
|
t.Fatal("写入结果必须保留且不能被重启认领")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type afterCreateStore struct {
|
||||||
|
application.CredentialStore
|
||||||
|
after func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s afterCreateStore) CreateCredential(ctx context.Context, location credentialdomain.Location, credential credentialdomain.ApplicationCredential) (int64, error) {
|
||||||
|
version, err := s.CredentialStore.CreateCredential(ctx, location, credential)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := s.after(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationUncertain(t *testing.T, f *preparationFixture) {
|
||||||
|
database, _ := f.bound(t, "uncertain")
|
||||||
|
service := f.service(t)
|
||||||
|
service.Store = afterCreateStore{CredentialStore: service.Store, after: func() error { return application.ErrCredentialUncertain }}
|
||||||
|
if err := service.Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, binding.Conflict)
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, binding.Conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationChangedBinding(t *testing.T, f *preparationFixture) {
|
||||||
|
database, tenant := f.bound(t, "changed")
|
||||||
|
service := f.service(t)
|
||||||
|
service.Store = afterCreateStore{CredentialStore: service.Store, after: func() error {
|
||||||
|
return f.api.Delete(t.Context(), tenant)
|
||||||
|
}}
|
||||||
|
if err := service.Reconcile(t.Context(), database.Name); err == nil {
|
||||||
|
t.Fatal("中途删除 Tenant 后不得确认凭据")
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, "CreationStarted")
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, binding.Conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationDependencies(t *testing.T, f *preparationFixture) {
|
||||||
|
database, _ := f.bound(t, "dependency")
|
||||||
|
denied := fixtureClient(t, f.bao.Address())
|
||||||
|
denied.ClearToken()
|
||||||
|
service := f.service(t)
|
||||||
|
service.Store = fixtureStore(t, denied)
|
||||||
|
if err := service.Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 0, binding.DependencyUnavailable)
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "CredentialPrepared")
|
||||||
|
if err := service.Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, binding.DependencyUnavailable)
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "CredentialPrepared")
|
||||||
|
moved, err := openbao.NewCredentials(f.bao, "other", "elsewhere")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service.Store = moved
|
||||||
|
if err := service.Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, binding.DependencyUnavailable)
|
||||||
|
if database.Status.CredentialRef.Mount != "secret" {
|
||||||
|
t.Fatal("配置变化不得迁移固定位置")
|
||||||
|
}
|
||||||
|
if err := f.bao.KVv2("secret").Delete(t.Context(), database.Status.CredentialRef.Path); err != nil {
|
||||||
|
t.Fatal("无法准备测试软删除")
|
||||||
|
}
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, binding.Conflict)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package openbao_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
bao "github.com/openbao/openbao/api/v2"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
|
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 在 API 边界模拟旧 schema 裁剪位置字段;剩余写入仍由真实 API server 处理。
|
||||||
|
type prunedLocationClient struct{ client.Client }
|
||||||
|
|
||||||
|
func (c prunedLocationClient) Status() client.SubResourceWriter {
|
||||||
|
return prunedLocationWriter{SubResourceWriter: c.Client.Status()}
|
||||||
|
}
|
||||||
|
|
||||||
|
type prunedLocationWriter struct{ client.SubResourceWriter }
|
||||||
|
|
||||||
|
func (w prunedLocationWriter) Update(ctx context.Context, object client.Object, options ...client.SubResourceUpdateOption) error {
|
||||||
|
if database, ok := object.(*databasev1alpha1.PostgreSQLDatabase); ok {
|
||||||
|
database.Status.CredentialRef = nil
|
||||||
|
}
|
||||||
|
return w.SubResourceWriter.Update(ctx, object, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreparationPruning(t *testing.T, f *preparationFixture) {
|
||||||
|
database, _ := f.bound(t, "pruning")
|
||||||
|
service := f.service(t)
|
||||||
|
service.Resources = &kubernetes.CredentialResources{Client: prunedLocationClient{f.api}, Reader: f.api}
|
||||||
|
if err := service.Reconcile(t.Context(), database.Name); err == nil {
|
||||||
|
t.Fatal("API 未保留位置时不得继续供应")
|
||||||
|
}
|
||||||
|
location, err := service.Store.ProvisionLocation(string(database.UID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.bao.KVv2(location.Mount).Get(t.Context(), location.Path); !errors.Is(err, bao.ErrSecretNotFound) {
|
||||||
|
t.Fatal("没有持久化位置时产生了外部凭据")
|
||||||
|
}
|
||||||
|
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.status(t, database, 1, "CredentialPrepared")
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package openbao_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
|
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
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"
|
||||||
|
|
||||||
|
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||||
|
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPreparationWatch(t *testing.T, f *preparationFixture) {
|
||||||
|
database, _ := f.bound(t, "watch")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !t.Failed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := f.api.Get(context.Background(), client.ObjectKeyFromObject(database), database); err == nil {
|
||||||
|
if condition := meta.FindStatusCondition(database.Status.Conditions, "CredentialsReady"); condition != nil {
|
||||||
|
t.Logf("失败时凭据条件: %s: %s", condition.Reason, condition.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
instance := &databasev1alpha1.PostgreSQLInstance{}
|
||||||
|
if err := f.api.Get(t.Context(), client.ObjectKey{Name: "watch"}, instance); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setReady := func(ready metav1.ConditionStatus) {
|
||||||
|
t.Helper()
|
||||||
|
meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
|
||||||
|
Type: "Ready", Status: ready, Reason: "FixtureObservation", Message: "隔离测试前置观察", ObservedGeneration: instance.Generation,
|
||||||
|
})
|
||||||
|
if err := f.api.Status().Update(t.Context(), instance); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setReady(metav1.ConditionFalse)
|
||||||
|
stop := startPreparationManager(t, f)
|
||||||
|
waitForPreparation(t, func() bool {
|
||||||
|
if err := f.api.Get(t.Context(), client.ObjectKeyFromObject(database), database); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
condition := meta.FindStatusCondition(database.Status.Conditions, "CredentialsReady")
|
||||||
|
return condition != nil && condition.Reason == "DependencyUnavailable"
|
||||||
|
})
|
||||||
|
if database.Status.CredentialRef != nil {
|
||||||
|
t.Fatal("Instance 未 Ready 时不应固定或写入凭据")
|
||||||
|
}
|
||||||
|
setReady(metav1.ConditionTrue)
|
||||||
|
waitForPreparation(t, func() bool {
|
||||||
|
return f.api.Get(t.Context(), client.ObjectKeyFromObject(database), database) == nil && database.Status.CredentialVersion == 1
|
||||||
|
})
|
||||||
|
stop()
|
||||||
|
// 重启真实 manager 后继续观察;不得只依赖旧进程中的记忆。
|
||||||
|
setReady(metav1.ConditionFalse)
|
||||||
|
stop = startPreparationManager(t, f)
|
||||||
|
defer stop()
|
||||||
|
waitForPreparation(t, func() bool {
|
||||||
|
if err := f.api.Get(t.Context(), client.ObjectKeyFromObject(database), database); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
condition := meta.FindStatusCondition(database.Status.Conditions, "CredentialsReady")
|
||||||
|
return condition != nil && condition.Status == metav1.ConditionFalse && condition.Reason == "DependencyUnavailable"
|
||||||
|
})
|
||||||
|
setReady(metav1.ConditionTrue)
|
||||||
|
waitForPreparation(t, func() bool {
|
||||||
|
if err := f.api.Get(t.Context(), client.ObjectKeyFromObject(database), database); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
condition := meta.FindStatusCondition(database.Status.Conditions, "CredentialsReady")
|
||||||
|
return condition != nil && condition.Status == metav1.ConditionTrue && database.Status.CredentialVersion == 1
|
||||||
|
})
|
||||||
|
stored, err := f.bao.KVv2("secret").Get(t.Context(), database.Status.CredentialRef.Path)
|
||||||
|
if err != nil || stored.VersionMetadata.Version != 1 {
|
||||||
|
t.Fatal("manager 重启不得重建凭据")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPreparationManager(t *testing.T, f *preparationFixture) func() {
|
||||||
|
t.Helper()
|
||||||
|
manager, err := ctrl.NewManager(f.config, ctrl.Options{
|
||||||
|
Scheme: f.scheme, Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
|
||||||
|
// 测试在同一进程顺序重启 manager;旧 worker 已停止,但名称注册表仍是进程级。
|
||||||
|
Controller: controllerconfig.Controller{SkipNameValidation: new(true)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bindingResources := &kubernetes.BindingResources{Client: manager.GetClient(), Reader: manager.GetAPIReader()}
|
||||||
|
binder := databasecontroller.NewBindingReconciler(manager.GetClient(), &application.BindingService{Resources: bindingResources}, bindingResources)
|
||||||
|
if err := binder.SetupWithManager(t.Context(), manager); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credentialResources := &kubernetes.CredentialResources{Client: manager.GetClient(), Reader: manager.GetAPIReader()}
|
||||||
|
preparation := &application.CredentialPreparation{Resources: credentialResources, Store: fixtureStore(t, f.bao)}
|
||||||
|
if err := databasecontroller.NewCredentialReconciler(manager.GetClient(), preparation).SetupWithManager(manager); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- manager.Start(ctx) }()
|
||||||
|
stopped := false
|
||||||
|
stop := func() {
|
||||||
|
if stopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopped = true
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
t.Error("凭据 manager 未停止")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(stop)
|
||||||
|
return stop
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForPreparation(t *testing.T, check func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if check() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("凭据 watch 没有在低频重查之前驱动协调")
|
||||||
|
}
|
||||||
@@ -68,7 +68,9 @@ func TestInstanceControllerWithRealPostgreSQL(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: controllerNamespace}
|
resources := &secretadapter.InstanceResources{Client: manager.GetClient(), Reader: manager.GetAPIReader()}
|
||||||
|
usecase := &application.InstanceReconciliation{Resources: resources, Observer: service}
|
||||||
|
reconciler := databasecontroller.NewInstanceReconciler(manager.GetClient(), usecase, resources, controllerNamespace)
|
||||||
if err := reconciler.SetupWithManager(manager); err != nil {
|
if err := reconciler.SetupWithManager(manager); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCredentialLocation = errors.New("credential location is outside the configured scope")
|
||||||
|
ErrCredentialUnavailable = errors.New("credential backend unavailable")
|
||||||
|
ErrCredentialNotFound = errors.New("application credential not found")
|
||||||
|
ErrCredentialConflict = errors.New("credential creation requires manual conflict resolution")
|
||||||
|
ErrCredentialUncertain = errors.New("credential creation outcome is uncertain; manual resolution required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialStore 只表达本用例需要的凭据操作,不提供覆盖或删除。
|
||||||
|
// version=0 的读取只用于观察是否已有值,成功不能作为认领依据。
|
||||||
|
type CredentialStore interface {
|
||||||
|
ProvisionLocation(string) (credentialdomain.Location, error)
|
||||||
|
ReadCredential(context.Context, credentialdomain.Location, int64) (credentialdomain.ApplicationCredential, error)
|
||||||
|
CreateCredential(context.Context, credentialdomain.Location, credentialdomain.ApplicationCredential) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialRecord 是同一轮观察的事实,状态中永远不保存密码。
|
||||||
|
type CredentialRecord struct {
|
||||||
|
credentialdomain.Target
|
||||||
|
Revision string
|
||||||
|
TenantGeneration int64
|
||||||
|
InstanceGeneration int64
|
||||||
|
Status credentialdomain.State
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialResources 的写入必须检查 Database UID/resourceVersion,保留其他状态。
|
||||||
|
// CheckCurrent 在外部操作前后回读本轮三个资源,拒绝陈旧快照;它不是跨系统事务。
|
||||||
|
type CredentialResources interface {
|
||||||
|
Load(context.Context, string) (*CredentialRecord, error)
|
||||||
|
Save(context.Context, *CredentialRecord, credentialdomain.State) (*CredentialRecord, error)
|
||||||
|
CheckCurrent(context.Context, *CredentialRecord) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialPreparation struct {
|
||||||
|
Resources CredentialResources
|
||||||
|
Store CredentialStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CredentialPreparation) Reconcile(ctx context.Context, name string) error {
|
||||||
|
record, err := s.Resources.Load(ctx, name)
|
||||||
|
if err != nil || record == nil || !record.RequiresPreparation() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state, canContinue := record.Status.Resume(); !canContinue {
|
||||||
|
if state == record.Status {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.report(ctx, record, state.Phase, state.Message)
|
||||||
|
}
|
||||||
|
if issue := record.Check(); issue != nil {
|
||||||
|
return s.report(ctx, record, issue.Phase, issue.Message)
|
||||||
|
}
|
||||||
|
location, err := s.Store.ProvisionLocation(record.Database.Identity.UID)
|
||||||
|
if err != nil {
|
||||||
|
return s.report(ctx, record, credentialdomain.Unavailable, "凭据存储位置配置无效,未执行外部写入")
|
||||||
|
}
|
||||||
|
if issue := record.Status.CheckLocation(location); issue != nil {
|
||||||
|
return s.report(ctx, record, issue.Phase, issue.Message)
|
||||||
|
}
|
||||||
|
if record.Status.Location == nil {
|
||||||
|
status := record.Status
|
||||||
|
status.Location = &location
|
||||||
|
status = status.WithPhase(credentialdomain.Pinned, "凭据位置已固定,等待创建")
|
||||||
|
record, err = s.Resources.Save(ctx, record, status)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if record.Status.Confirmed() {
|
||||||
|
return s.observe(ctx, record)
|
||||||
|
}
|
||||||
|
return s.create(ctx, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CredentialPreparation) create(ctx context.Context, record *CredentialRecord) error {
|
||||||
|
_, err := s.Store.ReadCredential(ctx, *record.Status.Location, 0)
|
||||||
|
if err == nil || errors.Is(err, credentialdomain.ErrApplicationCredentialInvalid) {
|
||||||
|
return s.report(ctx, record, credentialdomain.Conflict, "固定位置已有未确认的凭据;请人工核实,未认领或覆盖")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrCredentialNotFound) {
|
||||||
|
return s.report(ctx, record, credentialdomain.Unavailable, "创建前无法确认凭据位置是否为空,等待依赖恢复")
|
||||||
|
}
|
||||||
|
credential, err := credentialdomain.GenerateApplicationCredential(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return s.report(ctx, record, credentialdomain.InvalidTarget, "应用凭据目标无效,未执行外部写入")
|
||||||
|
}
|
||||||
|
status := record.Status
|
||||||
|
status = status.WithPhase(credentialdomain.Creating, "凭据创建已开始;尚无成功确认时不得重入创建")
|
||||||
|
record, err = s.Resources.Save(ctx, record, status)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version, err := s.Store.CreateCredential(ctx, *record.Status.Location, credential)
|
||||||
|
if errors.Is(err, ErrCredentialUnavailable) {
|
||||||
|
// 适配器只在明确未执行写入(认证拒绝或请求前取消)时返回此错误。
|
||||||
|
return s.report(ctx, record, credentialdomain.Unavailable, "凭据创建在执行前被拒绝,等待认证或权限恢复")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return s.report(ctx, record, credentialdomain.Conflict,
|
||||||
|
"凭据创建冲突或结果不确定;请核对固定位置的版本历史,未认领、覆盖或重新生成密码")
|
||||||
|
}
|
||||||
|
confirmed, issue := record.Status.Created(version)
|
||||||
|
if issue != nil {
|
||||||
|
return s.report(ctx, record, issue.Phase, issue.Message)
|
||||||
|
}
|
||||||
|
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.Resources.Save(ctx, record, confirmed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CredentialPreparation) observe(ctx context.Context, record *CredentialRecord) error {
|
||||||
|
credential, err := s.Store.ReadCredential(ctx, *record.Status.Location, record.Status.Version)
|
||||||
|
if errors.Is(err, ErrCredentialConflict) || errors.Is(err, ErrCredentialNotFound) {
|
||||||
|
return s.report(ctx, record, credentialdomain.Conflict, "已确认凭据消失、版本变化或内容无效;请人工核实,未生成替代密码")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return s.report(ctx, record, credentialdomain.Unavailable, "已确认凭据暂时无法读取;保留确认版本,等待依赖恢复")
|
||||||
|
}
|
||||||
|
if issue := record.CheckCredential(credential); issue != nil {
|
||||||
|
return s.report(ctx, record, issue.Phase, issue.Message)
|
||||||
|
}
|
||||||
|
if err := s.Resources.CheckCurrent(ctx, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status := record.Status
|
||||||
|
status.Phase = credentialdomain.Prepared
|
||||||
|
status.Message = "已确认凭据可读取;尚未验证 PostgreSQL 资源或完成 Tenant 交付"
|
||||||
|
_, err = s.Resources.Save(ctx, record, status)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CredentialPreparation) report(ctx context.Context, record *CredentialRecord, phase credentialdomain.Phase, message string) error {
|
||||||
|
status := record.Status
|
||||||
|
status.Phase = phase
|
||||||
|
status.Message = fmt.Sprintf("Database %s:%s", record.Database.Identity.Name, message)
|
||||||
|
_, err := s.Resources.Save(ctx, record, status)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
preparationInstanceName = "credential-instance"
|
||||||
|
preparationInstanceUID = "credential-instance-uid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func preparationRecord(t *testing.T) *CredentialRecord {
|
||||||
|
t.Helper()
|
||||||
|
tenant := binding.TenantIdentity{Namespace: bindingTestNamespace, Name: bindingTestName, UID: "credential-tenant-uid"}
|
||||||
|
database := binding.Identity{Name: binding.DynamicDatabaseName(tenant.UID), UID: "credential-database-uid"}
|
||||||
|
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||||
|
Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432, ManagementDatabase: "management", TLSMode: instance.TLSVerifyFull,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &CredentialRecord{
|
||||||
|
Database: binding.Database{
|
||||||
|
Identity: database, Instance: preparationInstanceName, InstanceUID: preparationInstanceUID, Name: bindingTestName, LoginRole: bindingTestName, Source: "Provision", Tenant: &tenant,
|
||||||
|
},
|
||||||
|
Tenant: &binding.Tenant{
|
||||||
|
Identity: tenant, Phase: binding.Bound, Database: &database,
|
||||||
|
Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: preparationInstanceName}},
|
||||||
|
},
|
||||||
|
Instance: &credentialdomain.Instance{Identity: binding.Identity{Name: preparationInstanceName, UID: preparationInstanceUID}, Ready: true, Endpoint: endpoint},
|
||||||
|
DatabaseProtected: true, TenantProtected: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryCredentialResources struct {
|
||||||
|
record *CredentialRecord
|
||||||
|
saveError error
|
||||||
|
checkError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memoryCredentialResources) Load(context.Context, string) (*CredentialRecord, error) {
|
||||||
|
copy := *r.record
|
||||||
|
return ©, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memoryCredentialResources) Save(_ context.Context, record *CredentialRecord, status credentialdomain.State) (*CredentialRecord, error) {
|
||||||
|
if r.saveError != nil {
|
||||||
|
return nil, r.saveError
|
||||||
|
}
|
||||||
|
copy := *record
|
||||||
|
copy.Status = status
|
||||||
|
r.record = ©
|
||||||
|
return ©, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memoryCredentialResources) CheckCurrent(context.Context, *CredentialRecord) error {
|
||||||
|
return r.checkError
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparationStore struct {
|
||||||
|
reads int
|
||||||
|
creates int
|
||||||
|
readError error
|
||||||
|
createError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*preparationStore) ProvisionLocation(uid string) (credentialdomain.Location, error) {
|
||||||
|
return credentialdomain.Location{Mount: "applications", Path: "database/" + uid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *preparationStore) ReadCredential(context.Context, credentialdomain.Location, int64) (credentialdomain.ApplicationCredential, error) {
|
||||||
|
s.reads++
|
||||||
|
return credentialdomain.ApplicationCredential{}, s.readError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *preparationStore) CreateCredential(context.Context, credentialdomain.Location, credentialdomain.ApplicationCredential) (int64, error) {
|
||||||
|
s.creates++
|
||||||
|
if s.createError != nil {
|
||||||
|
return 0, s.createError
|
||||||
|
}
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialPreparationGates(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
change func(*CredentialRecord)
|
||||||
|
}{
|
||||||
|
{"导入不供应", func(r *CredentialRecord) { r.Database.Source = "Import" }},
|
||||||
|
{"删除中的资源", func(r *CredentialRecord) { r.Database.Deleting = true }},
|
||||||
|
{"已释放资源", func(r *CredentialRecord) { r.Database.Phase = "Released" }},
|
||||||
|
{"缺少资源保护", func(r *CredentialRecord) { r.DatabaseProtected = false }},
|
||||||
|
{"缺少申请保护", func(r *CredentialRecord) { r.TenantProtected = false }},
|
||||||
|
{"单向绑定", func(r *CredentialRecord) { r.Tenant.Database = nil }},
|
||||||
|
{"删除中的申请", func(r *CredentialRecord) { r.Tenant.Deleting = true }},
|
||||||
|
{"申请尚未Bound", func(r *CredentialRecord) { r.Tenant.Phase = binding.Binding }},
|
||||||
|
{"旧申请身份", func(r *CredentialRecord) { r.Tenant.Identity.UID = "new-tenant" }},
|
||||||
|
{"旧资源身份", func(r *CredentialRecord) { r.Tenant.Database.UID = "new-database" }},
|
||||||
|
{"Instance未出现", func(r *CredentialRecord) { r.Instance = nil }},
|
||||||
|
{"Instance正在删除", func(r *CredentialRecord) { r.Instance.Deleting = true }},
|
||||||
|
{"Instance未Ready", func(r *CredentialRecord) { r.Instance.Ready = false }},
|
||||||
|
{"Instance身份未记录", func(r *CredentialRecord) { r.Database.InstanceUID = "" }},
|
||||||
|
{"Instance同名重建", func(r *CredentialRecord) { r.Instance.Identity.UID = "new-instance" }},
|
||||||
|
{"目标不一致", func(r *CredentialRecord) { r.Database.LoginRole = "another_owner" }},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
record := preparationRecord(t)
|
||||||
|
test.change(record)
|
||||||
|
resources := &memoryCredentialResources{record: record}
|
||||||
|
store := &preparationStore{readError: ErrCredentialNotFound}
|
||||||
|
if err := (CredentialPreparation{Resources: resources, Store: store}).Reconcile(t.Context(), record.Database.Identity.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if store.reads != 0 || store.creates != 0 || resources.record.Status.Version != 0 {
|
||||||
|
t.Fatal("前置条件不满足时不得读取、创建或确认凭据")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialPreparationWriteBoundary(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
saveError bool
|
||||||
|
stale bool
|
||||||
|
createError error
|
||||||
|
wantError bool
|
||||||
|
wantCreates int
|
||||||
|
wantVersion int64
|
||||||
|
}{
|
||||||
|
{name: "位置无法保存", saveError: true, wantError: true},
|
||||||
|
{name: "外部操作前快照变化", stale: true, wantError: true},
|
||||||
|
{name: "明确权限拒绝", createError: ErrCredentialUnavailable, wantCreates: 1},
|
||||||
|
{name: "创建结果不确定", createError: ErrCredentialUncertain, wantCreates: 1},
|
||||||
|
{name: "并发创建冲突", createError: ErrCredentialConflict, wantCreates: 1},
|
||||||
|
{name: "创建与回读成功", wantCreates: 1, wantVersion: 1},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
resources := &memoryCredentialResources{record: preparationRecord(t)}
|
||||||
|
if test.saveError {
|
||||||
|
resources.saveError = errors.New("fixture write failure")
|
||||||
|
}
|
||||||
|
if test.stale {
|
||||||
|
resources.checkError = errors.New("fixture stale observation")
|
||||||
|
}
|
||||||
|
store := &preparationStore{readError: ErrCredentialNotFound, createError: test.createError}
|
||||||
|
service := CredentialPreparation{Resources: resources, Store: store}
|
||||||
|
err := service.Reconcile(t.Context(), resources.record.Database.Identity.Name)
|
||||||
|
if (err != nil) != test.wantError || store.creates != test.wantCreates || resources.record.Status.Version != test.wantVersion {
|
||||||
|
t.Fatal("外部写入边界或确认时机不符合预期")
|
||||||
|
}
|
||||||
|
if test.createError == ErrCredentialUncertain || test.stale {
|
||||||
|
resources.checkError = nil
|
||||||
|
if err := service.Reconcile(t.Context(), resources.record.Database.Identity.Name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if store.creates != test.wantCreates || resources.record.Status.Phase != credentialdomain.Conflict {
|
||||||
|
t.Fatal("未确认创建重入时不得生成替代密码")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
@@ -14,8 +13,17 @@ import (
|
|||||||
const dependencyRetry = 30 * time.Second
|
const dependencyRetry = 30 * time.Second
|
||||||
|
|
||||||
type BindingReconciler struct {
|
type BindingReconciler struct {
|
||||||
Client client.Client
|
Client client.Client
|
||||||
Reader client.Reader
|
Service *application.BindingService
|
||||||
|
Presenter BindingPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
type BindingPresenter interface {
|
||||||
|
Present(context.Context, application.BindingResult) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBindingReconciler(cache client.Client, service *application.BindingService, presenter BindingPresenter) *BindingReconciler {
|
||||||
|
return &BindingReconciler{Client: cache, Service: service, Presenter: presenter}
|
||||||
}
|
}
|
||||||
|
|
||||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants,verbs=get;list;watch;update;patch
|
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants,verbs=get;list;watch;update;patch
|
||||||
@@ -27,13 +35,11 @@ type BindingReconciler struct {
|
|||||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch
|
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch
|
||||||
|
|
||||||
func (r *BindingReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
func (r *BindingReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
||||||
resources := &kubernetes.BindingResources{Client: r.Client, Reader: r.Reader}
|
result, err := r.Service.Reconcile(ctx, request.Namespace, request.Name)
|
||||||
service := application.BindingService{Resources: resources}
|
|
||||||
result, err := service.Reconcile(ctx, request.Namespace, request.Name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
if err := resources.Present(ctx, result); err != nil {
|
if err := r.Presenter.Present(ctx, result); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
if result.RetrySoon {
|
if result.RetrySoon {
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ func targetDatabaseName(tenant *databasev1alpha1.PostgreSQLTenant) string {
|
|||||||
return "tenant-" + string(tenant.UID)
|
return "tenant-" + string(tenant.UID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bindingTestReconciler(writer client.Client, reader client.Reader) *BindingReconciler {
|
||||||
|
resources := &kubernetes.BindingResources{Client: writer, Reader: reader}
|
||||||
|
return NewBindingReconciler(writer, &application.BindingService{Resources: resources}, resources)
|
||||||
|
}
|
||||||
|
|
||||||
func tenantReference(tenant *databasev1alpha1.PostgreSQLTenant) *databasev1alpha1.TenantReference {
|
func tenantReference(tenant *databasev1alpha1.PostgreSQLTenant) *databasev1alpha1.TenantReference {
|
||||||
return &databasev1alpha1.TenantReference{
|
return &databasev1alpha1.TenantReference{
|
||||||
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
|
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
|
||||||
@@ -103,7 +108,7 @@ func testDynamicBinding(t *testing.T, apiClient client.Client) {
|
|||||||
instance := readyInstance(t, apiClient, "dynamic-instance")
|
instance := readyInstance(t, apiClient, "dynamic-instance")
|
||||||
tenant := provisionTenant("dynamic", instance.Name)
|
tenant := provisionTenant("dynamic", instance.Name)
|
||||||
requireCreate(t, apiClient, tenant)
|
requireCreate(t, apiClient, tenant)
|
||||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
reconciler := bindingTestReconciler(apiClient, apiClient)
|
||||||
reconcileOK(t, reconciler, tenant)
|
reconcileOK(t, reconciler, tenant)
|
||||||
reload(t, apiClient, tenant)
|
reload(t, apiClient, tenant)
|
||||||
if tenant.Status.DatabaseRef == nil || tenant.Status.Phase != phaseBound {
|
if tenant.Status.DatabaseRef == nil || tenant.Status.Phase != phaseBound {
|
||||||
@@ -154,7 +159,7 @@ func testBindingRestart(t *testing.T, apiClient client.Client) {
|
|||||||
instance := readyInstance(t, apiClient, "restart-instance")
|
instance := readyInstance(t, apiClient, "restart-instance")
|
||||||
tenant := provisionTenant("restart", instance.Name)
|
tenant := provisionTenant("restart", instance.Name)
|
||||||
requireCreate(t, apiClient, tenant)
|
requireCreate(t, apiClient, tenant)
|
||||||
first := &BindingReconciler{Client: &failedTenantStatusClient{Client: apiClient}, Reader: apiClient}
|
first := bindingTestReconciler(&failedTenantStatusClient{Client: apiClient}, apiClient)
|
||||||
if _, err := first.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)}); err == nil {
|
if _, err := first.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)}); err == nil {
|
||||||
t.Fatal("预期第二次绑定写入失败")
|
t.Fatal("预期第二次绑定写入失败")
|
||||||
}
|
}
|
||||||
@@ -169,7 +174,7 @@ func testBindingRestart(t *testing.T, apiClient client.Client) {
|
|||||||
t.Fatal("失败后资源侧绑定不应回滚")
|
t.Fatal("失败后资源侧绑定不应回滚")
|
||||||
}
|
}
|
||||||
// 新建 reconciler,无旧内存,只从 API 中读取进度。
|
// 新建 reconciler,无旧内存,只从 API 中读取进度。
|
||||||
restarted := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
restarted := bindingTestReconciler(apiClient, apiClient)
|
||||||
reconcileOK(t, restarted, tenant)
|
reconcileOK(t, restarted, tenant)
|
||||||
reload(t, apiClient, tenant)
|
reload(t, apiClient, tenant)
|
||||||
if tenant.Status.DatabaseRef == nil || tenant.Status.DatabaseRef.UID != database.UID {
|
if tenant.Status.DatabaseRef == nil || tenant.Status.DatabaseRef.UID != database.UID {
|
||||||
@@ -190,7 +195,7 @@ func testConcurrentBinding(t *testing.T, apiClient client.Client) {
|
|||||||
results := make(chan error, len(tenants))
|
results := make(chan error, len(tenants))
|
||||||
for _, tenant := range tenants {
|
for _, tenant := range tenants {
|
||||||
workers.Go(func() {
|
workers.Go(func() {
|
||||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
reconciler := bindingTestReconciler(apiClient, apiClient)
|
||||||
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)})
|
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)})
|
||||||
results <- err
|
results <- err
|
||||||
})
|
})
|
||||||
@@ -202,7 +207,7 @@ func testConcurrentBinding(t *testing.T, apiClient client.Client) {
|
|||||||
t.Fatalf("并发协调出现非版本冲突错误: %v", err)
|
t.Fatalf("并发协调出现非版本冲突错误: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
reconciler := bindingTestReconciler(apiClient, apiClient)
|
||||||
bound := 0
|
bound := 0
|
||||||
for _, tenant := range tenants {
|
for _, tenant := range tenants {
|
||||||
reconcileOK(t, reconciler, tenant)
|
reconcileOK(t, reconciler, tenant)
|
||||||
@@ -233,7 +238,7 @@ func testBindingIdentity(t *testing.T, apiClient client.Client) {
|
|||||||
}
|
}
|
||||||
tenant := existingTenant("identity", database.Name)
|
tenant := existingTenant("identity", database.Name)
|
||||||
requireCreate(t, apiClient, tenant)
|
requireCreate(t, apiClient, tenant)
|
||||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
reconciler := bindingTestReconciler(apiClient, apiClient)
|
||||||
reconcileOK(t, reconciler, tenant)
|
reconcileOK(t, reconciler, tenant)
|
||||||
reload(t, apiClient, tenant)
|
reload(t, apiClient, tenant)
|
||||||
assertNotReady(t, tenant, reasonConflict)
|
assertNotReady(t, tenant, reasonConflict)
|
||||||
@@ -256,7 +261,7 @@ func testBindingIdentity(t *testing.T, apiClient client.Client) {
|
|||||||
func testBindingProtection(t *testing.T, apiClient client.Client) {
|
func testBindingProtection(t *testing.T, apiClient client.Client) {
|
||||||
tenant := provisionTenant("protection", "missing-instance")
|
tenant := provisionTenant("protection", "missing-instance")
|
||||||
requireCreate(t, apiClient, tenant)
|
requireCreate(t, apiClient, tenant)
|
||||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
reconciler := bindingTestReconciler(apiClient, apiClient)
|
||||||
reconcileOK(t, reconciler, tenant)
|
reconcileOK(t, reconciler, tenant)
|
||||||
reload(t, apiClient, tenant)
|
reload(t, apiClient, tenant)
|
||||||
assertNotReady(t, tenant, reasonDependency)
|
assertNotReady(t, tenant, reasonDependency)
|
||||||
@@ -292,7 +297,7 @@ func testStaleObservation(t *testing.T, apiClient client.Client) {
|
|||||||
}
|
}
|
||||||
tenant := existingTenant("stale", database.Name)
|
tenant := existingTenant("stale", database.Name)
|
||||||
requireCreate(t, apiClient, tenant)
|
requireCreate(t, apiClient, tenant)
|
||||||
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
|
reconciler := bindingTestReconciler(apiClient, apiClient)
|
||||||
reconcileOK(t, reconciler, tenant)
|
reconcileOK(t, reconciler, tenant)
|
||||||
reload(t, apiClient, tenant)
|
reload(t, apiClient, tenant)
|
||||||
assertNotReady(t, tenant, reasonDependency)
|
assertNotReady(t, tenant, reasonDependency)
|
||||||
@@ -318,7 +323,7 @@ func testBindingWatch(t *testing.T, apiClient client.Client, config *rest.Config
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reconciler := &BindingReconciler{}
|
reconciler := bindingTestReconciler(manager.GetClient(), manager.GetAPIReader())
|
||||||
if err := reconciler.SetupWithManager(t.Context(), manager); err != nil {
|
if err := reconciler.SetupWithManager(t.Context(), manager); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -373,7 +378,7 @@ func testPresentationVersion(t *testing.T, apiClient client.Client) {
|
|||||||
if err := resources.Present(t.Context(), result); !apierrors.IsConflict(err) {
|
if err := resources.Present(t.Context(), result); !apierrors.IsConflict(err) {
|
||||||
t.Fatalf("过期结果呈现 = %v, want Conflict", err)
|
t.Fatalf("过期结果呈现 = %v, want Conflict", err)
|
||||||
}
|
}
|
||||||
reconcileOK(t, &BindingReconciler{Client: apiClient, Reader: apiClient}, tenant)
|
reconcileOK(t, bindingTestReconciler(apiClient, apiClient), tenant)
|
||||||
reload(t, apiClient, tenant)
|
reload(t, apiClient, tenant)
|
||||||
if tenant.Status.Phase != phaseBound || tenant.Spec.SecretName != "updated-delivery" ||
|
if tenant.Status.Phase != phaseBound || tenant.Spec.SecretName != "updated-delivery" ||
|
||||||
tenant.Annotations["example.test/keep"] != "preserved" {
|
tenant.Annotations["example.test/keep"] != "preserved" {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
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/domain/binding"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
@@ -14,16 +15,16 @@ import (
|
|||||||
const targetDatabaseIndex = "database.bindingTarget"
|
const targetDatabaseIndex = "database.bindingTarget"
|
||||||
|
|
||||||
func (r *BindingReconciler) SetupWithManager(ctx context.Context, manager ctrl.Manager) error {
|
func (r *BindingReconciler) SetupWithManager(ctx context.Context, manager ctrl.Manager) error {
|
||||||
if r.Client == nil {
|
if r.Client == nil || r.Service == nil || r.Presenter == nil {
|
||||||
r.Client = manager.GetClient()
|
return errors.New("binding controller requires injected client, use case and presenter")
|
||||||
}
|
|
||||||
if r.Reader == nil {
|
|
||||||
r.Reader = manager.GetAPIReader()
|
|
||||||
}
|
}
|
||||||
if err := manager.GetFieldIndexer().IndexField(ctx, &databasev1alpha1.PostgreSQLTenant{},
|
if err := manager.GetFieldIndexer().IndexField(ctx, &databasev1alpha1.PostgreSQLTenant{},
|
||||||
targetDatabaseIndex, func(object client.Object) []string {
|
targetDatabaseIndex, func(object client.Object) []string {
|
||||||
tenant := object.(*databasev1alpha1.PostgreSQLTenant)
|
tenant := object.(*databasev1alpha1.PostgreSQLTenant)
|
||||||
return []string{kubernetes.BindingTargetName(tenant)}
|
if tenant.Spec.DatabaseRef != nil {
|
||||||
|
return []string{string(tenant.Spec.DatabaseRef.Name)}
|
||||||
|
}
|
||||||
|
return []string{binding.DynamicDatabaseName(string(tenant.UID))}
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialReconciler 只连接事件、用例和重试,不在控制器中编排凭据写入。
|
||||||
|
type CredentialReconciler struct {
|
||||||
|
Client client.Client
|
||||||
|
Service *application.CredentialPreparation
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCredentialReconciler(cache client.Client, service *application.CredentialPreparation) *CredentialReconciler {
|
||||||
|
return &CredentialReconciler{Client: cache, Service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CredentialReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
||||||
|
if err := r.Service.Reconcile(ctx, request.Name); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
// Bao 的可用性和版本变化没有 Kubernetes watch;与已有依赖重查保持一致。
|
||||||
|
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CredentialReconciler) SetupWithManager(manager ctrl.Manager) error {
|
||||||
|
if r.Client == nil || r.Service == nil {
|
||||||
|
return errors.New("credential controller requires injected client and use case")
|
||||||
|
}
|
||||||
|
return ctrl.NewControllerManagedBy(manager).
|
||||||
|
Named("database-credentials").For(&databasev1alpha1.PostgreSQLDatabase{}).
|
||||||
|
Watches(&databasev1alpha1.PostgreSQLTenant{}, handler.EnqueueRequestsFromMapFunc(r.requestsForTenant)).
|
||||||
|
Watches(&databasev1alpha1.PostgreSQLInstance{}, handler.EnqueueRequestsFromMapFunc(r.requestsForInstance)).
|
||||||
|
Complete(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CredentialReconciler) requestsForTenant(_ context.Context, object client.Object) []ctrl.Request {
|
||||||
|
tenant := object.(*databasev1alpha1.PostgreSQLTenant)
|
||||||
|
if tenant.Status.DatabaseRef == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []ctrl.Request{{Name: string(tenant.Status.DatabaseRef.Name)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CredentialReconciler) requestsForInstance(ctx context.Context, object client.Object) []ctrl.Request {
|
||||||
|
databases := &databasev1alpha1.PostgreSQLDatabaseList{}
|
||||||
|
if err := r.Client.List(ctx, databases); err != nil {
|
||||||
|
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Instance 凭据准备事件;等待低频重试")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var requests []ctrl.Request
|
||||||
|
for _, database := range databases.Items {
|
||||||
|
if string(database.Spec.InstanceRef.Name) == object.GetName() {
|
||||||
|
requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&database)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requests
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
@@ -12,27 +11,33 @@ import (
|
|||||||
|
|
||||||
type InstanceReconciler struct {
|
type InstanceReconciler struct {
|
||||||
Client client.Client
|
Client client.Client
|
||||||
Reader client.Reader
|
Service *application.InstanceReconciliation
|
||||||
Observer application.InstanceObserver
|
Presenter InstancePresenter
|
||||||
SecretNamespace string
|
SecretNamespace string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InstancePresenter interface {
|
||||||
|
PresentInstance(context.Context, application.InstanceResult) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInstanceReconciler(cache client.Client, service *application.InstanceReconciliation, presenter InstancePresenter, namespace string) *InstanceReconciler {
|
||||||
|
return &InstanceReconciler{Client: cache, Service: service, Presenter: presenter, SecretNamespace: namespace}
|
||||||
|
}
|
||||||
|
|
||||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;update;patch
|
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;update;patch
|
||||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/status,verbs=get;update;patch
|
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/status,verbs=get;update;patch
|
||||||
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/finalizers,verbs=update
|
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/finalizers,verbs=update
|
||||||
// Secret 权限单独声明为 namespace Role,不放入生成的 ClusterRole。
|
// Secret 权限单独声明为 namespace Role,不放入生成的 ClusterRole。
|
||||||
|
|
||||||
func (r *InstanceReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
func (r *InstanceReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
||||||
resources := &kubernetes.InstanceResources{Client: r.Client, Reader: r.Reader}
|
|
||||||
service := application.InstanceReconciliation{Resources: resources, Observer: r.Observer}
|
|
||||||
observationContext, cancel := context.WithTimeout(ctx, 15*time.Second)
|
observationContext, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := service.Reconcile(observationContext, request.Name)
|
result, err := r.Service.Reconcile(observationContext, request.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
// 查询超时后仍用 worker context 保存安全失败结果;manager 停止时不强行写入。
|
// 查询超时后仍用 worker context 保存安全失败结果;manager 停止时不强行写入。
|
||||||
if err := resources.PresentInstance(ctx, result); err != nil {
|
if err := r.Presenter.PresentInstance(ctx, result); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
if result.Record == nil || result.RemoveProtection {
|
if result.Record == nil || result.RemoveProtection {
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ func newInstanceReconciler(t *testing.T, apiClient client.Client, backend *insta
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(service.Close)
|
t.Cleanup(service.Close)
|
||||||
return &InstanceReconciler{Client: apiClient, Reader: apiClient, Observer: service}
|
resources := &kubernetes.InstanceResources{Client: apiClient, Reader: apiClient}
|
||||||
|
return NewInstanceReconciler(apiClient, &application.InstanceReconciliation{Resources: resources, Observer: service}, resources, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func reconcileInstance(t *testing.T, reconciler *InstanceReconciler, object *databasev1alpha1.PostgreSQLInstance) {
|
func reconcileInstance(t *testing.T, reconciler *InstanceReconciler, object *databasev1alpha1.PostgreSQLInstance) {
|
||||||
@@ -164,11 +165,11 @@ func TestInstanceDeletionProtection(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backend.inspect = func() { t.Fatal("删除中不应连接 PostgreSQL") }
|
backend.inspect = func() { t.Fatal("删除中不应连接 PostgreSQL") }
|
||||||
reconciler.Reader = &failedReferenceReader{Reader: apiClient}
|
reconciler.Service.Resources.(*kubernetes.InstanceResources).Reader = &failedReferenceReader{Reader: apiClient}
|
||||||
reconcileInstance(t, reconciler, object)
|
reconcileInstance(t, reconciler, object)
|
||||||
reload(t, apiClient, object)
|
reload(t, apiClient, object)
|
||||||
assertInstanceReason(t, object, reasonDependency)
|
assertInstanceReason(t, object, reasonDependency)
|
||||||
reconciler.Reader = apiClient
|
reconciler.Service.Resources.(*kubernetes.InstanceResources).Reader = apiClient
|
||||||
reconcileInstance(t, reconciler, object)
|
reconcileInstance(t, reconciler, object)
|
||||||
reload(t, apiClient, object)
|
reload(t, apiClient, object)
|
||||||
assertInstanceReason(t, object, "InstanceInUse")
|
assertInstanceReason(t, object, "InstanceInUse")
|
||||||
|
|||||||
@@ -22,15 +22,9 @@ func InstanceCacheOptions(namespace string) cache.Options {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *InstanceReconciler) SetupWithManager(manager ctrl.Manager) error {
|
func (r *InstanceReconciler) SetupWithManager(manager ctrl.Manager) error {
|
||||||
if r.Observer == nil || len(validation.IsDNS1123Label(r.SecretNamespace)) != 0 {
|
if r.Client == nil || r.Service == nil || r.Presenter == nil || len(validation.IsDNS1123Label(r.SecretNamespace)) != 0 {
|
||||||
return errors.New("instance observer and valid management Secret namespace required")
|
return errors.New("instance observer and valid management Secret namespace required")
|
||||||
}
|
}
|
||||||
if r.Client == nil {
|
|
||||||
r.Client = manager.GetClient()
|
|
||||||
}
|
|
||||||
if r.Reader == nil {
|
|
||||||
r.Reader = manager.GetAPIReader()
|
|
||||||
}
|
|
||||||
return ctrl.NewControllerManagedBy(manager).
|
return ctrl.NewControllerManagedBy(manager).
|
||||||
Named("database-instance").
|
Named("database-instance").
|
||||||
For(&databasev1alpha1.PostgreSQLInstance{}).
|
For(&databasev1alpha1.PostgreSQLInstance{}).
|
||||||
|
|||||||
+8
-1
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package application
|
package credential
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -88,6 +88,13 @@ func (c ApplicationCredential) Validate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchesTarget 只比较连接目标,不向用例暴露密码;管理库名不是应用连接目标的一部分。
|
||||||
|
func (c ApplicationCredential) MatchesTarget(username, database string, endpoint instance.Endpoint) bool {
|
||||||
|
actual, wanted := c.endpoint.Values(), endpoint.Values()
|
||||||
|
return c.username == username && c.database == database && actual.Host == wanted.Host &&
|
||||||
|
actual.HostAddr == wanted.HostAddr && actual.Port == wanted.Port && actual.TLSMode == wanted.TLSMode
|
||||||
|
}
|
||||||
|
|
||||||
// ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。
|
// ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。
|
||||||
func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) {
|
func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) {
|
||||||
values := make(map[string]string, 7)
|
values := make(map[string]string, 7)
|
||||||
+9
-8
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package application_test
|
package credential_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -23,7 +23,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
credentialdomain "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/instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,11 +36,11 @@ func TestApplicationCredential(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
first, err := application.GenerateApplicationCredential("owner", "app", endpoint)
|
first, err := credentialdomain.GenerateApplicationCredential("owner", "app", endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
second, err := application.GenerateApplicationCredential("owner", "app", endpoint)
|
second, err := credentialdomain.GenerateApplicationCredential("owner", "app", endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,7 @@ func TestApplicationCredential(t *testing.T) {
|
|||||||
if len(data) != 7 || data["password"] == second.SecretData()["password"] || len(data["password"].(string)) != 43 {
|
if len(data) != 7 || data["password"] == second.SecretData()["password"] || len(data["password"].(string)) != 43 {
|
||||||
t.Fatal("expected seven keys and independent 256-bit passwords")
|
t.Fatal("expected seven keys and independent 256-bit passwords")
|
||||||
}
|
}
|
||||||
parsed, err := application.ParseApplicationCredential(data)
|
parsed, err := credentialdomain.ParseApplicationCredential(data)
|
||||||
if err != nil || !maps.Equal(parsed.SecretData(), data) {
|
if err != nil || !maps.Equal(parsed.SecretData(), data) {
|
||||||
t.Fatal("credential did not round trip")
|
t.Fatal("credential did not round trip")
|
||||||
}
|
}
|
||||||
@@ -67,15 +68,15 @@ func TestApplicationCredential(t *testing.T) {
|
|||||||
for key := range data {
|
for key := range data {
|
||||||
invalid := maps.Clone(data)
|
invalid := maps.Clone(data)
|
||||||
delete(invalid, key)
|
delete(invalid, key)
|
||||||
if _, err := application.ParseApplicationCredential(invalid); err == nil {
|
if _, err := credentialdomain.ParseApplicationCredential(invalid); err == nil {
|
||||||
t.Fatalf("accepted missing %s", key)
|
t.Fatalf("accepted missing %s", key)
|
||||||
}
|
}
|
||||||
invalid[key] = 42
|
invalid[key] = 42
|
||||||
if _, err := application.ParseApplicationCredential(invalid); err == nil {
|
if _, err := credentialdomain.ParseApplicationCredential(invalid); err == nil {
|
||||||
t.Fatalf("accepted non-string %s", key)
|
t.Fatalf("accepted non-string %s", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (application.ApplicationCredential{}).Validate() == nil {
|
if (credentialdomain.ApplicationCredential{}).Validate() == nil {
|
||||||
t.Fatal("accepted zero credential")
|
t.Fatal("accepted zero credential")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Location struct {
|
||||||
|
Mount string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 表达凭据准备进度,不依赖 Kubernetes Condition 的类型或 Reason。
|
||||||
|
type Phase uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Pending Phase = iota
|
||||||
|
Pinned
|
||||||
|
Creating
|
||||||
|
Prepared
|
||||||
|
Conflict
|
||||||
|
Unavailable
|
||||||
|
Stopped
|
||||||
|
InvalidTarget
|
||||||
|
)
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Location *Location
|
||||||
|
Version int64
|
||||||
|
Phase Phase
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s State) WithPhase(phase Phase, message string) State {
|
||||||
|
s.Phase, s.Message = phase, message
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s State) Confirmed() bool { return s.Version > 0 }
|
||||||
|
|
||||||
|
// Created 只接受首次创建并回读得到的版本,不能把后续写入认作首次供应。
|
||||||
|
func (s State) Created(version int64) (State, *Issue) {
|
||||||
|
if version != 1 {
|
||||||
|
return s, &Issue{Conflict, "凭据创建冲突或结果不确定;请核对固定位置的版本历史,未认领、覆盖或重新生成密码"}
|
||||||
|
}
|
||||||
|
s.Version = version
|
||||||
|
return s.WithPhase(Prepared, "凭据已创建并回读确认;尚未创建 PostgreSQL 资源或交付给 Tenant"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume 决定新一轮是否可以继续。未确认的创建不能靠读取成功认领。
|
||||||
|
func (s State) Resume() (State, bool) {
|
||||||
|
if s.Version != 0 {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
switch s.Phase {
|
||||||
|
case Conflict:
|
||||||
|
return s, false
|
||||||
|
case Creating:
|
||||||
|
return s.WithPhase(Conflict, "凭据创建未留下成功确认;请核对固定位置与后端历史并人工处理,未重新生成密码"), false
|
||||||
|
default:
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s State) CheckLocation(configured Location) *Issue {
|
||||||
|
if s.Location != nil && *s.Location != configured {
|
||||||
|
return &Issue{Unavailable, "部署配置与固定凭据位置不一致;请恢复原 mount/path 配置,未迁移或改密"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Instance struct {
|
||||||
|
binding.Instance
|
||||||
|
Endpoint instance.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target 只包含供应资格所需事实,不含 resourceVersion、Conditions 或 repository 对象。
|
||||||
|
type Target struct {
|
||||||
|
Database binding.Database
|
||||||
|
Tenant *binding.Tenant
|
||||||
|
Instance *Instance
|
||||||
|
DatabaseProtected bool
|
||||||
|
TenantProtected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Issue struct {
|
||||||
|
Phase Phase
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Target) RequiresPreparation() bool { return t.Database.Source == "Provision" }
|
||||||
|
|
||||||
|
func (t Target) CheckCredential(value ApplicationCredential) *Issue {
|
||||||
|
if t.Instance == nil || !value.MatchesTarget(t.Database.LoginRole, t.Database.Name, t.Instance.Endpoint) {
|
||||||
|
return &Issue{Conflict, "已确认凭据与当前 Instance/database/loginRole 不一致;请人工核实,未修改凭据"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Target) Check() *Issue {
|
||||||
|
database := t.Database
|
||||||
|
if database.Deleting || database.Phase == binding.Deleting || database.Phase == "Released" {
|
||||||
|
return &Issue{Stopped, "Database 正在删除或已释放;保留凭据与 finalizer,不执行供应或清理"}
|
||||||
|
}
|
||||||
|
if database.Tenant == nil || t.Tenant == nil || t.Tenant.Database == nil {
|
||||||
|
return &Issue{Unavailable, "等待 Database 与 Tenant 双向绑定完成"}
|
||||||
|
}
|
||||||
|
if *database.Tenant != t.Tenant.Identity || *t.Tenant.Database != database.Identity {
|
||||||
|
return &Issue{Conflict, "双向绑定的名称或 UID 不匹配,未创建凭据"}
|
||||||
|
}
|
||||||
|
if t.Tenant.Deleting || t.Tenant.Phase != binding.Bound || !t.DatabaseProtected || !t.TenantProtected {
|
||||||
|
return &Issue{Stopped, "Tenant 未完成绑定、正在删除或缺少 finalizer 保护,未创建凭据"}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return &Issue{Conflict, "Tenant 申请与 Database 目标不一致,未创建凭据"}
|
||||||
|
}
|
||||||
|
if t.Instance == nil || database.InstanceUID == "" {
|
||||||
|
return &Issue{Unavailable, "等待 Instance 与已记录的实例身份"}
|
||||||
|
}
|
||||||
|
if issue := t.Instance.Check(&database); issue != nil {
|
||||||
|
phase := Unavailable
|
||||||
|
if issue.Reason == binding.Conflict {
|
||||||
|
phase = Conflict
|
||||||
|
}
|
||||||
|
return &Issue{phase, issue.Message}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package credential_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
credential "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparationResume(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
phase credential.Phase
|
||||||
|
version int64
|
||||||
|
continueAllowed bool
|
||||||
|
result credential.Phase
|
||||||
|
}{
|
||||||
|
{"尚未创建", credential.Pinned, 0, true, credential.Pinned},
|
||||||
|
{"依赖恢复", credential.Unavailable, 0, true, credential.Unavailable},
|
||||||
|
{"中断创建", credential.Creating, 0, false, credential.Conflict},
|
||||||
|
{"未确认冲突", credential.Conflict, 0, false, credential.Conflict},
|
||||||
|
{"已确认后读取失败", credential.Unavailable, 1, true, credential.Unavailable},
|
||||||
|
{"已确认后冲突重验", credential.Conflict, 1, true, credential.Conflict},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
original := credential.State{Phase: test.phase, Version: test.version, Message: "保留原诊断"}
|
||||||
|
state, allowed := original.Resume()
|
||||||
|
if allowed != test.continueAllowed || state.Phase != test.result || state.Version != original.Version {
|
||||||
|
t.Fatal("恢复判定或确认版本发生变化")
|
||||||
|
}
|
||||||
|
if test.phase == credential.Conflict && state.Message != original.Message {
|
||||||
|
t.Fatal("冲突重入应保留原诊断")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparationLocationAndConfirmation(t *testing.T) {
|
||||||
|
location := credential.Location{Mount: "applications", Path: "database/uid"}
|
||||||
|
state := credential.State{Location: &location, Phase: credential.Creating}
|
||||||
|
if issue := state.CheckLocation(location); issue != nil {
|
||||||
|
t.Fatal("固定位置不应被拒绝")
|
||||||
|
}
|
||||||
|
if issue := state.CheckLocation(credential.Location{Mount: "other", Path: location.Path}); issue == nil || issue.Phase != credential.Unavailable {
|
||||||
|
t.Fatal("配置变化必须停止,不迁移已固定位置")
|
||||||
|
}
|
||||||
|
for _, version := range []int64{0, -1, 2} {
|
||||||
|
result, issue := state.Created(version)
|
||||||
|
if issue == nil || issue.Phase != credential.Conflict || result.Confirmed() {
|
||||||
|
t.Fatal("错误版本不得确认创建")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, issue := state.Created(1)
|
||||||
|
if issue != nil || !result.Confirmed() || result.Phase != credential.Prepared || result.Location != state.Location {
|
||||||
|
t.Fatal("首次写入回读应确认并保留位置")
|
||||||
|
}
|
||||||
|
if state.Version != 0 {
|
||||||
|
t.Fatal("领域判定不得修改调用方的旧状态")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package credential_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||||
|
credential "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
targetTestApplication = "sampleapp"
|
||||||
|
targetTestInstance = "test-instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparationTarget(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
change func(*credential.Target)
|
||||||
|
want credential.Phase
|
||||||
|
}{
|
||||||
|
{"完整绑定", func(*credential.Target) {}, credential.Pending},
|
||||||
|
{"单向绑定", func(target *credential.Target) { target.Tenant.Database = nil }, credential.Unavailable},
|
||||||
|
{"旧租户身份", func(target *credential.Target) { target.Tenant.Identity.UID = "new" }, credential.Conflict},
|
||||||
|
{"旧实例身份", func(target *credential.Target) { target.Instance.Identity.UID = "new" }, credential.Conflict},
|
||||||
|
{"资源删除", func(target *credential.Target) { target.Database.Deleting = true }, credential.Stopped},
|
||||||
|
{"申请删除", func(target *credential.Target) { target.Tenant.Deleting = true }, credential.Stopped},
|
||||||
|
{"Released", func(target *credential.Target) { target.Database.Phase = "Released" }, credential.Stopped},
|
||||||
|
{"缺少保护", func(target *credential.Target) { target.DatabaseProtected = false }, credential.Stopped},
|
||||||
|
{"实例未就绪", func(target *credential.Target) { target.Instance.Ready = false }, credential.Unavailable},
|
||||||
|
{"目标变化", func(target *credential.Target) { target.Database.LoginRole = "other" }, credential.Conflict},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
tenantID := binding.TenantIdentity{Namespace: "apps", Name: targetTestApplication, UID: "tenant"}
|
||||||
|
databaseID := binding.Identity{Name: binding.DynamicDatabaseName(tenantID.UID), UID: "database"}
|
||||||
|
target := credential.Target{
|
||||||
|
Database: binding.Database{Identity: databaseID, Tenant: &tenantID, Instance: targetTestInstance, InstanceUID: "instance-id", Name: targetTestApplication, LoginRole: targetTestApplication, Source: "Provision"},
|
||||||
|
Tenant: &binding.Tenant{Identity: tenantID, Database: &databaseID, Phase: binding.Bound, Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: targetTestInstance}}},
|
||||||
|
Instance: &credential.Instance{Identity: binding.Identity{Name: targetTestInstance, UID: "instance-id"}, Ready: true},
|
||||||
|
DatabaseProtected: true, TenantProtected: true,
|
||||||
|
}
|
||||||
|
test.change(&target)
|
||||||
|
issue := target.Check()
|
||||||
|
if test.want == credential.Pending {
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatalf("有效绑定被拒绝: %s", issue.Message)
|
||||||
|
}
|
||||||
|
} else if issue == nil || issue.Phase != test.want {
|
||||||
|
t.Fatal("领域资格判定不符")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user