From 371248659bb99cb605c6128192b3901bd0116a3f Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 27 Sep 2026 18:08:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=9B=BA=E5=AE=9A=20Database=20?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E4=BD=8D=E7=BD=AE=E4=B8=8E=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/database/v1alpha1/common_types.go | 4 +- .../v1alpha1/credential_status_test.go | 87 +++++++++++++++++++ .../v1alpha1/postgresqldatabase_types.go | 13 ++- api/database/v1alpha1/validation_test.go | 1 + .../v1alpha1/zz_generated.deepcopy.go | 5 ++ ...yatori.ddupan.top_postgresqldatabases.yaml | 46 ++++++++-- docs/database/README.md | 7 +- docs/database/api-reference.md | 12 ++- docs/database/specification.md | 6 ++ .../confirmed_credentials_integration_test.go | 47 ++++++++++ .../openbao/confirmed_credentials_test.go | 69 +++++++++++++++ .../database/adapter/openbao/credentials.go | 41 +++++++-- .../adapter/openbao/credentials_test.go | 5 +- 13 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 api/database/v1alpha1/credential_status_test.go create mode 100644 internal/database/adapter/openbao/confirmed_credentials_integration_test.go create mode 100644 internal/database/adapter/openbao/confirmed_credentials_test.go diff --git a/api/database/v1alpha1/common_types.go b/api/database/v1alpha1/common_types.go index ecbf3f4..7a15593 100644 --- a/api/database/v1alpha1/common_types.go +++ b/api/database/v1alpha1/common_types.go @@ -54,8 +54,8 @@ const ( ReclaimDelete ReclaimPolicy = "Delete" ) -// CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。 -// 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。 +// CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。 +// 管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。 type CredentialReference struct { // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=253 diff --git a/api/database/v1alpha1/credential_status_test.go b/api/database/v1alpha1/credential_status_test.go new file mode 100644 index 0000000..0482a9a --- /dev/null +++ b/api/database/v1alpha1/credential_status_test.go @@ -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) + } + }) + } +} diff --git a/api/database/v1alpha1/postgresqldatabase_types.go b/api/database/v1alpha1/postgresqldatabase_types.go index 9166b40..faef6b5 100644 --- a/api/database/v1alpha1/postgresqldatabase_types.go +++ b/api/database/v1alpha1/postgresqldatabase_types.go @@ -30,6 +30,14 @@ type PostgreSQLDatabaseStatus struct { // InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。 // +optional 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 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。 // +optional Phase string `json:"phase,omitempty"` @@ -42,7 +50,10 @@ type PostgreSQLDatabaseStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +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="Database",type=string,JSONPath=`.spec.database` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status` diff --git a/api/database/v1alpha1/validation_test.go b/api/database/v1alpha1/validation_test.go index e85aac8..388f592 100644 --- a/api/database/v1alpha1/validation_test.go +++ b/api/database/v1alpha1/validation_test.go @@ -61,6 +61,7 @@ func TestDatabaseAPI(t *testing.T) { t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) }) t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(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) }) } diff --git a/api/database/v1alpha1/zz_generated.deepcopy.go b/api/database/v1alpha1/zz_generated.deepcopy.go index 32e3e34..369a73b 100644 --- a/api/database/v1alpha1/zz_generated.deepcopy.go +++ b/api/database/v1alpha1/zz_generated.deepcopy.go @@ -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. func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) { *out = *in + if in.CredentialRef != nil { + in, out := &in.CredentialRef, &out.CredentialRef + *out = new(CredentialReference) + **out = **in + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) diff --git a/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml b/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml index 9ca893a..8ff07a1 100644 --- a/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml +++ b/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml @@ -50,8 +50,8 @@ spec: properties: credentialRef: description: |- - CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。 - 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。 + CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。 + 管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。 properties: mount: maxLength: 253 @@ -198,6 +198,29 @@ spec: x-kubernetes-list-map-keys: - type 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: description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。 type: string @@ -212,13 +235,22 @@ spec: - spec type: object 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 starts - 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) + 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))' served: true storage: true diff --git a/docs/database/README.md b/docs/database/README.md index 899cc7e..9b10354 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -169,7 +169,12 @@ Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Data 拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。 认证会话已按下面的显式参数接入 manager;凭据存储尚未接入供应用例。 -Database 状态中的稳定位置和已确认步骤、供应 service/controller、PostgreSQL 创建以及 ESO 交付仍未完成。 +Database 已有 `status.credentialRef` 和 `status.credentialVersion` 的字段与 CEL 校验: +固定位置、只在创建并回读成功后确认版本,二者写入后不可清空或修改。 +`ReadConfirmed` 按已确认版本检查最新 KV 值,删除或版本漂移报 Conflict,不读取旧版本掩盖变化。 +真实 API server 验证 status 清除拒绝、版本冲突和 Conditions 更新;真实 Bao 验证重建 adapter +后读取、缺少确认记录拒绝、版本变化与软删除。字段持久化的供应 service/controller、 +PostgreSQL 创建以及 ESO 交付仍未完成,本切片不是已接入的凭据准备流程。 测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。 ## OpenBao Kubernetes 认证会话 diff --git a/docs/database/api-reference.md b/docs/database/api-reference.md index be022e7..25755e9 100644 --- a/docs/database/api-reference.md +++ b/docs/database/api-reference.md @@ -4,7 +4,7 @@ | --- | --- | | 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 | | API group/version | `database.ayatori.ddupan.top/v1alpha1` | -| 最后更新 | 2026-09-25 | +| 最后更新 | 2026-09-27 | 以 [系统规格](specification.md) 与 [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.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 | | Database | `status.instanceUID` | 观察时的 Instance 身份 | +| Database | `status.credentialRef.mount/path` | 首次写入前固定的 KV v2 位置,不随部署配置迁移 | +| Database | `status.credentialVersion` | 创建并回读成功后确认的正整数版本;省略表示未确认 | | Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 | | Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 | | Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance | @@ -36,6 +38,12 @@ Instance phase 沿用已批准枚举;Database/Tenant phase 暂不冻结供应 `credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。 其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。 +凭据位置与确认版本一旦写入便不可更改或移除;确认版本必须有对应位置。Conditions 描述 +当前可用性,不替代确认记录,也不能因读取暂时失败而清空记录。上述 schema 已有真实 API +server 校验;负责持久化它们的供应用例尚未接入,现有绑定 controller 不会填写这些字段。 +未确认的已有值必须报 Conflict,不能用读取成功补记版本;已确认版本的恢复读取检查最新 +KV 版本,删除或版本漂移均需人工处理,不回退旧版本或生成替代密码。第一版不提供轮换入口。 + 示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、 [导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、 [动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。 @@ -47,7 +55,7 @@ API 接受两个 Tenant 引用同一 Database 不表示允许双重绑定;排 已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。 同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。 -Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察或 +Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察、凭据位置或 绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。 读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。 绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照 diff --git a/docs/database/specification.md b/docs/database/specification.md index 5d8c3fc..6b1988d 100644 --- a/docs/database/specification.md +++ b/docs/database/specification.md @@ -192,6 +192,12 @@ mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenan 位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。 不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。 +2026-09-27 确认最小凭据记录:Database `status.credentialRef` 在首次外部写入前固定 +mount/path;`status.credentialVersion` 仅在成功创建并回读后保存 KV 版本。位置和确认版本 +不得自动更改或清空,Conditions 只描述当前可用性。已有值但无确认版本时报告 Conflict, +不能靠读取成功认领;已确认凭据消失或最新版本不一致同样停止,等待人工核实。 +部署参数变化不得搬迁旧位置;确认记录写入的 resourceVersion 冲突不能通过盲目重试覆盖。 + TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求 见 [安全模型](security.md)。这些安全约束继续适用。 diff --git a/internal/database/adapter/openbao/confirmed_credentials_integration_test.go b/internal/database/adapter/openbao/confirmed_credentials_integration_test.go new file mode 100644 index 0000000..ee64325 --- /dev/null +++ b/internal/database/adapter/openbao/confirmed_credentials_integration_test.go @@ -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("恢复检查不得生成或覆盖凭据") + } +} diff --git a/internal/database/adapter/openbao/confirmed_credentials_test.go b/internal/database/adapter/openbao/confirmed_credentials_test.go new file mode 100644 index 0000000..4b9b477 --- /dev/null +++ b/internal/database/adapter/openbao/confirmed_credentials_test.go @@ -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("失败时不能返回可用凭据") + } + }) + } +} diff --git a/internal/database/adapter/openbao/credentials.go b/internal/database/adapter/openbao/credentials.go index ecfddd8..461c767 100644 --- a/internal/database/adapter/openbao/credentials.go +++ b/internal/database/adapter/openbao/credentials.go @@ -80,20 +80,51 @@ func (c *Credentials) accepts(path string) bool { // Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。 func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) { + secret, err := c.read(ctx, path) + if err != nil { + return application.ApplicationCredential{}, err + } + return application.ParseApplicationCredential(secret.Data) +} + +// ReadConfirmed 读取最新值并核对已持久化的确认版本,不回退读取历史版本。 +// 确认后的删除或改写需要人工处理,不能因此重新生成密码。 +func (c *Credentials) ReadConfirmed(ctx context.Context, path string, version int64) (application.ApplicationCredential, error) { + if version < 1 { + return application.ApplicationCredential{}, ErrConflict + } + secret, err := c.read(ctx, path) + if errors.Is(err, ErrNotFound) { + return application.ApplicationCredential{}, ErrConflict + } + if err != nil { + return application.ApplicationCredential{}, err + } + if secret.VersionMetadata == nil || int64(secret.VersionMetadata.Version) != version { + return application.ApplicationCredential{}, ErrConflict + } + credential, err := application.ParseApplicationCredential(secret.Data) + if err != nil { + return application.ApplicationCredential{}, ErrConflict + } + return credential, nil +} + +func (c *Credentials) read(ctx context.Context, path string) (*bao.KVSecret, error) { if !c.accepts(path) { - return application.ApplicationCredential{}, ErrInvalidLocation + return nil, ErrInvalidLocation } secret, err := c.kv.Get(ctx, path) if errors.Is(err, bao.ErrSecretNotFound) { - return application.ApplicationCredential{}, ErrNotFound + return nil, ErrNotFound } if err != nil { - return application.ApplicationCredential{}, ErrUnavailable + return nil, ErrUnavailable } if secret == nil || secret.Data == nil { - return application.ApplicationCredential{}, ErrNotFound + return nil, ErrNotFound } - return application.ParseApplicationCredential(secret.Data) + return secret, nil } // Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。 diff --git a/internal/database/adapter/openbao/credentials_test.go b/internal/database/adapter/openbao/credentials_test.go index b164173..c065716 100644 --- a/internal/database/adapter/openbao/credentials_test.go +++ b/internal/database/adapter/openbao/credentials_test.go @@ -36,6 +36,7 @@ const ( fixturePassword = "AYATORI-TEST-ONLY-application-password" fixtureToken = "AYATORI-TEST-ONLY-bao-token" kvDataKey = "data" + kvVersionKey = "version" ) func fixtureCredential(t *testing.T) application.ApplicationCredential { @@ -66,7 +67,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) { 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") } - 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") } return @@ -85,7 +86,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) { } response := map[string]any{kvDataKey: data} 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 { t.Error("cannot encode fixture read response") From 35ada6d7eb58245c5214632282178b145489d0dc Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 27 Sep 2026 18:35:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=8E=A5=E9=80=9A=20Database=20?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E5=87=86=E5=A4=87=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/database/README.md | 42 +++- docs/database/api-reference.md | 5 +- docs/database/deployment.md | 22 +- docs/database/operations.md | 11 +- internal/bootstrap/database.go | 31 ++- .../bootstrap/manager_integration_test.go | 18 +- internal/bootstrap/openbao.go | 18 +- internal/bootstrap/options_test.go | 15 +- internal/bootstrap/run.go | 5 +- .../adapter/kubernetes/binding_resources.go | 5 +- .../kubernetes/credential_resources.go | 142 +++++++++++ .../adapter/kubernetes/instance_resources.go | 2 +- .../adapter/openbao/credential_store.go | 38 +++ .../database/adapter/openbao/credentials.go | 13 +- ...reparation_concurrency_integration_test.go | 62 +++++ .../preparation_fixture_integration_test.go | 116 +++++++++ .../openbao/preparation_integration_test.go | 236 ++++++++++++++++++ .../preparation_pruning_integration_test.go | 52 ++++ .../preparation_watch_integration_test.go | 138 ++++++++++ .../application/application_credential.go | 7 + .../application/credential_preparation.go | 182 ++++++++++++++ .../credential_preparation_test.go | 168 +++++++++++++ .../database/application/credential_target.go | 27 ++ .../controller/credential_controller.go | 67 +++++ 24 files changed, 1385 insertions(+), 37 deletions(-) create mode 100644 internal/database/adapter/kubernetes/credential_resources.go create mode 100644 internal/database/adapter/openbao/credential_store.go create mode 100644 internal/database/adapter/openbao/preparation_concurrency_integration_test.go create mode 100644 internal/database/adapter/openbao/preparation_fixture_integration_test.go create mode 100644 internal/database/adapter/openbao/preparation_integration_test.go create mode 100644 internal/database/adapter/openbao/preparation_pruning_integration_test.go create mode 100644 internal/database/adapter/openbao/preparation_watch_integration_test.go create mode 100644 internal/database/application/credential_preparation.go create mode 100644 internal/database/application/credential_preparation_test.go create mode 100644 internal/database/application/credential_target.go create mode 100644 internal/database/controller/credential_controller.go diff --git a/docs/database/README.md b/docs/database/README.md index 9b10354..5df95c0 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -43,8 +43,8 @@ registry 准备决策;这一依赖现已从代码移除,不能把旧运行 - 领域层不依赖 Kubernetes types、数据库 driver 或凭据 provider。 - CredentialReference 只携带管理 Secret 的名称与字段映射,不包含 Secret 内容或 OpenBao path。 - Instance checkpoint 不是外部事实;实际能力必须由 application/adapter 观察后交给领域对象判断。 -- 当前代码只检查 Instance 供应前置条件,不授予 Tenant 所有权或外部写入权限,也不表示 - Database API 已经可用。 +- Instance 观察只检查供应前置条件,不单独授予 Tenant 所有权或外部写入权限;凭据准备 + 另行校验双向绑定和保护,尚不表示完整 Database API 已经可用。 ## 管理凭据与连接切片 @@ -168,14 +168,42 @@ Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Data 真实后端覆盖创建/回读、并发唯一创建、重建适配器读取、软删除冲突、固定前缀 token 拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。 -认证会话已按下面的显式参数接入 manager;凭据存储尚未接入供应用例。 +认证会话和凭据准备用例已接入 manager,但默认不启用外部写入。 Database 已有 `status.credentialRef` 和 `status.credentialVersion` 的字段与 CEL 校验: 固定位置、只在创建并回读成功后确认版本,二者写入后不可清空或修改。 `ReadConfirmed` 按已确认版本检查最新 KV 值,删除或版本漂移报 Conflict,不读取旧版本掩盖变化。 -真实 API server 验证 status 清除拒绝、版本冲突和 Conditions 更新;真实 Bao 验证重建 adapter -后读取、缺少确认记录拒绝、版本变化与软删除。字段持久化的供应 service/controller、 -PostgreSQL 创建以及 ESO 交付仍未完成,本切片不是已接入的凭据准备流程。 -测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。 +测试 token 只用于临时 fixture,不是生产静态 token 配置接口。 + +### 凭据准备闭环 + +配置认证后,显式设置 `--database-credential-mount` 启用准备;路径默认 +`applications/`,前缀由 `--database-credential-prefix` 配置。 +`CredentialPreparation` 用例检查 Provision 来源、双向绑定 UID、申请目标、finalizer、 +删除/Released 状态及当前 Instance Ready;导入资源不执行本流程。 +顺序为固定位置 → 确认后端尚无凭据 → 保存 CreationStarted → 创建并回读 → 保存确认版本。 +`CredentialReconciler` 只负责 Database/Tenant/Instance watch 和 30 秒依赖重查; +Kubernetes repository 负责直接读取及有 resourceVersion 保护的状态更新。 + +外部操作前后回查目标: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 认证会话 diff --git a/docs/database/api-reference.md b/docs/database/api-reference.md index 25755e9..befafea 100644 --- a/docs/database/api-reference.md +++ b/docs/database/api-reference.md @@ -40,9 +40,12 @@ Instance phase 沿用已批准枚举;Database/Tenant phase 暂不冻结供应 凭据位置与确认版本一旦写入便不可更改或移除;确认版本必须有对应位置。Conditions 描述 当前可用性,不替代确认记录,也不能因读取暂时失败而清空记录。上述 schema 已有真实 API -server 校验;负责持久化它们的供应用例尚未接入,现有绑定 controller 不会填写这些字段。 +server 校验;独立的凭据准备用例在显式启用后填写这些字段,绑定 controller 不负责外部写入。 未确认的已有值必须报 Conflict,不能用读取成功补记版本;已确认版本的恢复读取检查最新 KV 版本,删除或版本漂移均需人工处理,不回退旧版本或生成替代密码。第一版不提供轮换入口。 +`CredentialsReady` 条件只描述凭据准备结果;它不授权实际数据库交付。 +`CreationStarted` 且无确认版本表示创建未完成确认,重入时停在 Conflict;不尝试推断 +进程中断前请求是否发出。完整执行和测试边界见[凭据准备闭环](README.md#凭据准备闭环)。 示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、 [导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、 diff --git a/docs/database/deployment.md b/docs/database/deployment.md index 83c1aa8..e8d25f3 100644 --- a/docs/database/deployment.md +++ b/docs/database/deployment.md @@ -1,6 +1,6 @@ # 部署与配置 -> 本页区分已实现的 Instance 观测配置与尚未接入的供应/交付目标合同。 +> 本页区分已实现的 Instance 观测、认证和凭据准备配置,与尚未接入的 PostgreSQL 供应/交付合同。 > 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。 | 项目 | 内容 | @@ -45,11 +45,11 @@ Instance 观测)与 `--database-root-cert`(公开 PostgreSQL CA PEM 路径 | `--openbao-auth-mount` | 已实现,`kubernetes` | Kubernetes auth mount 名称 | | `--openbao-auth-role` | 已实现,启用时必填 | OpenBao 登录 role | | `--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-name` | 已实现,启用时必填 | TokenRequest 目标 SA 名称 | | `--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 固定引用 | | `--database-root-cert` | 已实现 | 只读 PEM trust bundle,不含私钥;沿用 Instance 连接配置 | | `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 | @@ -122,8 +122,20 @@ base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空 ## OpenBao 与 ESO -controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2 -data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。 +当前凭据准备只需固定前缀下 KV v2 data 的 create/read/update 权限,不需要 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 身份 只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得 diff --git a/docs/database/operations.md b/docs/database/operations.md index 51a8507..18b0ad7 100644 --- a/docs/database/operations.md +++ b/docs/database/operations.md @@ -5,13 +5,22 @@ ## 当前绑定切片的限制 -源码已接入绑定 controller,未接入 PostgreSQL 供应、OpenBao/ESO 交付或删除清理。 +源码已接入绑定、Instance 观测与可选的 Bao 凭据准备,未接入 PostgreSQL 供应、ESO 交付或删除清理。 Bound/BindingComplete 只表示 Kubernetes 双向记录一致,Ready 仍为 False。 Tenant 删除会保留 `database.ayatori.ddupan.top/tenant-protection` 并报告 DeletionPending; Database 的 `database.ayatori.ddupan.top/database-protection` 也尚无清理后移除路径。 这是未完成能力的明确边界,不是已经实现的 Retain/Delete 恢复逻辑。不要将此切片部署为 业务 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`, diff --git a/internal/bootstrap/database.go b/internal/bootstrap/database.go index fa0ce6c..d4d207a 100644 --- a/internal/bootstrap/database.go +++ b/internal/bootstrap/database.go @@ -6,7 +6,10 @@ import ( "fmt" "os" + bao "github.com/openbao/openbao/api/v2" + "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/application" databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller" @@ -14,14 +17,18 @@ import ( ) type databaseOptions struct { - secretNamespace string - rootCert string + secretNamespace string + rootCert string + credentialMount string + credentialPrefix string } func (o *databaseOptions) bindFlags(flags *flag.FlagSet) { flags.StringVar(&o.secretNamespace, "database-secret-namespace", os.Getenv("POD_NAMESPACE"), "固定管理 Secret namespace;为空时不启用 Instance 观测") 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) { @@ -31,7 +38,7 @@ func (o databaseOptions) configureManager(options *ctrl.Options) { } // setupDatabase 封装 Database 的内部装配,并返回在 manager 停止后执行的清理。 -func setupDatabase(ctx context.Context, manager ctrl.Manager, options databaseOptions) (func(), error) { +func setupDatabase(ctx context.Context, manager ctrl.Manager, options databaseOptions, baoClient *bao.Client) (func(), error) { cleanup := func() {} if options.secretNamespace != "" { service, err := setupInstanceObservation(manager, options.secretNamespace, options.rootCert) @@ -44,9 +51,27 @@ func setupDatabase(ctx context.Context, manager ctrl.Manager, options databaseOp cleanup() return nil, fmt.Errorf("set up Database binding controller: %w", err) } + if err := setupCredentialPreparation(manager, options, baoClient); err != nil { + cleanup() + return nil, fmt.Errorf("set up Database credential preparation: %w", err) + } return cleanup, 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 (&databasecontroller.CredentialReconciler{Store: store}).SetupWithManager(manager) +} + func setupInstanceObservation(manager ctrl.Manager, namespace, rootCert string) (*application.InstanceService, error) { credentials, err := kubernetes.NewSecretCredentials(manager.GetAPIReader(), namespace) if err != nil { diff --git a/internal/bootstrap/manager_integration_test.go b/internal/bootstrap/manager_integration_test.go index e2c1ede..cfde582 100644 --- a/internal/bootstrap/manager_integration_test.go +++ b/internal/bootstrap/manager_integration_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + bao "github.com/openbao/openbao/api/v2" + "sigs.k8s.io/controller-runtime/pkg/envtest" ) @@ -46,16 +48,28 @@ func TestBootstrapWithRealAPIServer(t *testing.T) { if err != nil { t.Fatal(err) } - if err := setupOpenBaoAuthentication(manager, options.openBao); err != nil { + baoClient, err := setupOpenBaoAuthentication(manager, options.openBao) + if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) defer cancel() - cleanup, err := setupDatabase(ctx, manager, options.database) + cleanup, err := setupDatabase(ctx, manager, options.database, baoClient) if err != nil { t.Fatal(err) } defer cleanup() + // 空 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) go func() { done <- manager.Start(ctx) }() defer func() { diff --git a/internal/bootstrap/openbao.go b/internal/bootstrap/openbao.go index 550158b..8c3b340 100644 --- a/internal/bootstrap/openbao.go +++ b/internal/bootstrap/openbao.go @@ -21,6 +21,8 @@ import ( "flag" "net/http" + bao "github.com/openbao/openbao/api/v2" + ctrl "sigs.k8s.io/controller-runtime" "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") } -func setupOpenBaoAuthentication(manager ctrl.Manager, options openBaoOptions) error { +func setupOpenBaoAuthentication(manager ctrl.Manager, options openBaoOptions) (*bao.Client, error) { if options.address == "" { - return nil + return nil, nil } client, err := openbao.NewClient(options.address, options.caCert) if err != nil { - return err + return nil, err } // 复用 manager 已装配的 Kubernetes client,不重复加载配置或创建客户端。 session, err := openbao.NewKubernetesSession( client, manager.GetClient(), options.mount, options.role, options.identity, ) if err != nil { - return err + return nil, err } 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() { return errors.New("OpenBao Kubernetes authentication unavailable") } return nil }) + if err != nil { + return nil, err + } + return client, nil } diff --git a/internal/bootstrap/options_test.go b/internal/bootstrap/options_test.go index 60ebf4b..2ddb410 100644 --- a/internal/bootstrap/options_test.go +++ b/internal/bootstrap/options_test.go @@ -38,7 +38,7 @@ func TestDefaultConfiguration(t *testing.T) { if options.manager.webhookOptions().Port != 9443 || !options.logging.Development { 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") } 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) { options := parseTestOptions(t, "--metrics-bind-address=:9090", "--metrics-secure=false", "--health-probe-bind-address=:9091", diff --git a/internal/bootstrap/run.go b/internal/bootstrap/run.go index c7435ef..77f7575 100644 --- a/internal/bootstrap/run.go +++ b/internal/bootstrap/run.go @@ -23,10 +23,11 @@ func Run() error { if err != nil { 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) } - cleanup, err := setupDatabase(context.Background(), manager, options.database) + cleanup, err := setupDatabase(context.Background(), manager, options.database, baoClient) if err != nil { return err } diff --git a/internal/database/adapter/kubernetes/binding_resources.go b/internal/database/adapter/kubernetes/binding_resources.go index ef23a0d..750527d 100644 --- a/internal/database/adapter/kubernetes/binding_resources.go +++ b/internal/database/adapter/kubernetes/binding_resources.go @@ -19,6 +19,7 @@ import ( const ( TenantFinalizer = "database.ayatori.ddupan.top/tenant-protection" DatabaseFinalizer = "database.ayatori.ddupan.top/database-protection" + readyCondition = "Ready" ) // BindingResources 读取领域所需事实,并把用例结果呈现为 CR、finalizer 与 Conditions。 @@ -148,7 +149,7 @@ func (r *BindingResources) presentStatus(ctx context.Context, object *databasev1 } } 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, }) 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 { - condition := meta.FindStatusCondition(conditions, "Ready") + condition := meta.FindStatusCondition(conditions, readyCondition) return condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == generation } diff --git a/internal/database/adapter/kubernetes/credential_resources.go b/internal/database/adapter/kubernetes/credential_resources.go new file mode 100644 index 0000000..c7eb0ca --- /dev/null +++ b/internal/database/adapter/kubernetes/credential_resources.go @@ -0,0 +1,142 @@ +package kubernetes + +import ( + "context" + "fmt" + + 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), 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) + 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 = &application.CredentialInstance{ + Identity: binding.Identity{Name: instance.Name, UID: string(instance.UID)}, + Deleting: !instance.DeletionTimestamp.IsZero(), Ready: currentReady(instance.Generation, instance.Status.Conditions), + Generation: instance.Generation, Endpoint: observed.Target.Definition().Endpoint(), + } + return record, nil +} + +func credentialStatus(database *databasev1alpha1.PostgreSQLDatabase) application.CredentialStatus { + status := application.CredentialStatus{Version: database.Status.CredentialVersion} + if ref := database.Status.CredentialRef; ref != nil { + status.Location = &application.CredentialLocation{Mount: ref.Mount, Path: ref.Path} + } + if condition := meta.FindStatusCondition(database.Status.Conditions, application.CredentialsReady); condition != nil { + status.Ready = condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == database.Generation + status.Reason, status.Message = condition.Reason, condition.Message + } + return status +} + +func (r *CredentialResources) Save(ctx context.Context, record *application.CredentialRecord, status application.CredentialStatus) (*application.CredentialRecord, error) { + bindingResources := &BindingResources{Client: r.Client, Reader: r.Reader} + database, err := bindingResources.databaseAtVersion(ctx, &record.Database) + 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.Ready { + conditionStatus = metav1.ConditionTrue + } + meta.SetStatusCondition(&database.Status.Conditions, metav1.Condition{ + Type: application.CredentialsReady, Status: conditionStatus, Reason: status.Reason, 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) + 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.Database.Revision != record.Database.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.Tenant.Generation == previous.Tenant.Generation && + equality.Semantic.DeepEqual(current.Tenant.Tenant, previous.Tenant.Tenant) && + current.TenantProtected == previous.TenantProtected && current.DatabaseProtected == previous.DatabaseProtected && + current.Instance.Instance == previous.Instance.Instance && current.Instance.Generation == previous.Instance.Generation && + current.Instance.Endpoint == previous.Instance.Endpoint +} diff --git a/internal/database/adapter/kubernetes/instance_resources.go b/internal/database/adapter/kubernetes/instance_resources.go index 2c4799a..67f2da4 100644 --- a/internal/database/adapter/kubernetes/instance_resources.go +++ b/internal/database/adapter/kubernetes/instance_resources.go @@ -84,7 +84,7 @@ func (r *InstanceResources) PresentInstance(ctx context.Context, result applicat ready = metav1.ConditionTrue } 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, }) if !equality.Semantic.DeepEqual(*previous, object.Status) { diff --git a/internal/database/adapter/openbao/credential_store.go b/internal/database/adapter/openbao/credential_store.go new file mode 100644 index 0000000..75e24bb --- /dev/null +++ b/internal/database/adapter/openbao/credential_store.go @@ -0,0 +1,38 @@ +package openbao + +import ( + "context" + + "git.ddupan.top/panxiao81/ayatori/internal/database/application" +) + +var _ application.CredentialStore = (*Credentials)(nil) + +func (c *Credentials) ProvisionLocation(uid string) (application.CredentialLocation, error) { + path, err := c.ProvisionPath(uid) + if err != nil { + return application.CredentialLocation{}, err + } + return application.CredentialLocation{Mount: c.mount, Path: path}, nil +} + +func (c *Credentials) ReadCredential(ctx context.Context, location application.CredentialLocation, version int64) (application.ApplicationCredential, error) { + if location.Mount != c.mount { + return application.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 application.CredentialLocation, credential application.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 +} diff --git a/internal/database/adapter/openbao/credentials.go b/internal/database/adapter/openbao/credentials.go index 461c767..465eca9 100644 --- a/internal/database/adapter/openbao/credentials.go +++ b/internal/database/adapter/openbao/credentials.go @@ -32,11 +32,11 @@ import ( ) var ( - ErrInvalidLocation = errors.New("credential location is outside the configured scope") - ErrUnavailable = errors.New("credential backend unavailable") - ErrNotFound = errors.New("application credential not found") - ErrConflict = errors.New("credential creation requires manual conflict resolution") - ErrUncertain = errors.New("credential creation outcome is uncertain; manual resolution required") + ErrInvalidLocation = application.ErrCredentialLocation + ErrUnavailable = application.ErrCredentialUnavailable + ErrNotFound = application.ErrCredentialNotFound + ErrConflict = application.ErrCredentialConflict + ErrUncertain = application.ErrCredentialUncertain ) var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) @@ -45,6 +45,7 @@ var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // 本适配器既不自动认领已有值,也不提供覆盖、轮换或删除操作。 type Credentials struct { kv *bao.KVv2 + mount string basePath string } @@ -54,7 +55,7 @@ func NewCredentials(client *bao.Client, mount, basePath string) (*Credentials, e if client == nil || !validPath(mount) || !validPath(basePath) { 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 { diff --git a/internal/database/adapter/openbao/preparation_concurrency_integration_test.go b/internal/database/adapter/openbao/preparation_concurrency_integration_test.go new file mode 100644 index 0000000..ebed94b --- /dev/null +++ b/internal/database/adapter/openbao/preparation_concurrency_integration_test.go @@ -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, application.CredentialPrepared) + stored, err := f.bao.KVv2("secret").Get(t.Context(), database.Status.CredentialRef.Path) + if err != nil || stored.VersionMetadata.Version != 1 { + t.Fatal("并发准备用例只能产生一个凭据版本") + } +} diff --git a/internal/database/adapter/openbao/preparation_fixture_integration_test.go b/internal/database/adapter/openbao/preparation_fixture_integration_test.go new file mode 100644 index 0000000..dbf2a86 --- /dev/null +++ b/internal/database/adapter/openbao/preparation_fixture_integration_test.go @@ -0,0 +1,116 @@ +//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) + } + binder := &databasecontroller.BindingReconciler{Client: f.api, Reader: f.api} + 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, application.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") + } +} diff --git a/internal/database/adapter/openbao/preparation_integration_test.go b/internal/database/adapter/openbao/preparation_integration_test.go new file mode 100644 index 0000000..8f5dd0a --- /dev/null +++ b/internal/database/adapter/openbao/preparation_integration_test.go @@ -0,0 +1,236 @@ +//go:build integration + +package openbao_test + +import ( + "context" + "errors" + "maps" + "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" + "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, application.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, application.CredentialPrepared) + revision := database.ResourceVersion + if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil { + t.Fatal(err) + } + f.status(t, database, 1, application.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, application.CredentialCreationStarted) + 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 application.CredentialLocation, credential application.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, application.CredentialCreationStarted) + 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, application.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, application.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) +} diff --git a/internal/database/adapter/openbao/preparation_pruning_integration_test.go b/internal/database/adapter/openbao/preparation_pruning_integration_test.go new file mode 100644 index 0000000..9e7e022 --- /dev/null +++ b/internal/database/adapter/openbao/preparation_pruning_integration_test.go @@ -0,0 +1,52 @@ +//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" + "git.ddupan.top/panxiao81/ayatori/internal/database/application" +) + +// 在 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, application.CredentialPrepared) +} diff --git a/internal/database/adapter/openbao/preparation_watch_integration_test.go b/internal/database/adapter/openbao/preparation_watch_integration_test.go new file mode 100644 index 0000000..bf2e886 --- /dev/null +++ b/internal/database/adapter/openbao/preparation_watch_integration_test.go @@ -0,0 +1,138 @@ +//go:build integration + +package openbao_test + +import ( + "context" + "testing" + "time" + + "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" + "git.ddupan.top/panxiao81/ayatori/internal/database/application" + 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, application.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, application.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, application.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, application.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) + } + if err := (&databasecontroller.BindingReconciler{}).SetupWithManager(t.Context(), manager); err != nil { + t.Fatal(err) + } + if err := (&databasecontroller.CredentialReconciler{Store: fixtureStore(t, f.bao)}).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 没有在低频重查之前驱动协调") +} diff --git a/internal/database/application/application_credential.go b/internal/database/application/application_credential.go index f1b67b7..2eb7634 100644 --- a/internal/database/application/application_credential.go +++ b/internal/database/application/application_credential.go @@ -88,6 +88,13 @@ func (c ApplicationCredential) Validate() error { return err } +// MatchesTarget 只比较连接目标,不向用例暴露密码;管理库名不是应用连接目标的一部分。 +func (c ApplicationCredential) MatchesTarget(username, database string, endpoint instance.Endpoint) bool { + actual, wanted := c.endpoint.Values(), endpoint.Values() + return c.username == username && c.database == database && actual.Host == wanted.Host && + actual.HostAddr == wanted.HostAddr && actual.Port == wanted.Port && actual.TLSMode == wanted.TLSMode +} + // ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。 func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) { values := make(map[string]string, 7) diff --git a/internal/database/application/credential_preparation.go b/internal/database/application/credential_preparation.go new file mode 100644 index 0000000..7444c2a --- /dev/null +++ b/internal/database/application/credential_preparation.go @@ -0,0 +1,182 @@ +package application + +import ( + "context" + "errors" + "fmt" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding" + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +var ( + ErrCredentialLocation = errors.New("credential location is outside the configured scope") + ErrCredentialUnavailable = errors.New("credential backend unavailable") + ErrCredentialNotFound = errors.New("application credential not found") + ErrCredentialConflict = errors.New("credential creation requires manual conflict resolution") + ErrCredentialUncertain = errors.New("credential creation outcome is uncertain; manual resolution required") +) + +const ( + CredentialsReady = "CredentialsReady" + CredentialCreationStarted = "CreationStarted" + CredentialPrepared = "CredentialPrepared" +) + +type CredentialLocation struct { + Mount string + Path string +} + +// CredentialStore 只表达本用例需要的凭据操作,不提供覆盖或删除。 +// version=0 的读取只用于观察是否已有值,成功不能作为认领依据。 +type CredentialStore interface { + ProvisionLocation(string) (CredentialLocation, error) + ReadCredential(context.Context, CredentialLocation, int64) (ApplicationCredential, error) + CreateCredential(context.Context, CredentialLocation, ApplicationCredential) (int64, error) +} + +type CredentialInstance struct { + binding.Instance + Generation int64 + Endpoint instance.Endpoint +} + +// CredentialRecord 是同一轮观察的事实,状态中永远不保存密码。 +type CredentialRecord struct { + Database BindingDatabase + Tenant *BindingTenant + Instance *CredentialInstance + DatabaseProtected bool + TenantProtected bool + Status CredentialStatus +} + +type CredentialStatus struct { + Location *CredentialLocation + Version int64 + Ready bool + Reason string + Message string +} + +// CredentialResources 的写入必须检查 Database UID/resourceVersion,保留其他状态。 +// CheckCurrent 在外部操作前后回读本轮三个资源,拒绝陈旧快照;它不是跨系统事务。 +type CredentialResources interface { + Load(context.Context, string) (*CredentialRecord, error) + Save(context.Context, *CredentialRecord, CredentialStatus) (*CredentialRecord, error) + CheckCurrent(context.Context, *CredentialRecord) error +} + +type CredentialPreparation struct { + Resources CredentialResources + Store CredentialStore +} + +func (s CredentialPreparation) Reconcile(ctx context.Context, name string) error { + record, err := s.Resources.Load(ctx, name) + if err != nil || record == nil || record.Database.Source != "Provision" { + return err + } + // 未完成创建的重入不猜测后端结果。即使进程在实际发请求前退出,也需要人工核实。 + if record.Status.Version == 0 && record.Status.Reason == binding.Conflict { + return nil // 保留首次冲突的具体原因,不因后端恢复而重入创建。 + } + if record.Status.Version == 0 && record.Status.Reason == CredentialCreationStarted { + return s.report(ctx, record, binding.Conflict, + "凭据创建未留下成功确认;请核对固定位置与后端历史并人工处理,未重新生成密码") + } + if issue := record.check(); issue != nil { + return s.report(ctx, record, issue.Reason, issue.Message) + } + location, err := s.Store.ProvisionLocation(record.Database.Identity.UID) + if err != nil { + return s.report(ctx, record, binding.DependencyUnavailable, "凭据存储位置配置无效,未执行外部写入") + } + if record.Status.Location == nil { + status := record.Status + status.Location = &location + status.Ready, status.Reason, status.Message = false, "LocationPinned", "凭据位置已固定,等待创建" + record, err = s.Resources.Save(ctx, record, status) + if err != nil { + return err + } + } else if *record.Status.Location != location { + return s.report(ctx, record, binding.DependencyUnavailable, + "部署配置与固定凭据位置不一致;请恢复原 mount/path 配置,未迁移或改密") + } + if record.Status.Version > 0 { + return s.observe(ctx, record) + } + return s.create(ctx, record) +} + +func (s CredentialPreparation) create(ctx context.Context, record *CredentialRecord) error { + _, err := s.Store.ReadCredential(ctx, *record.Status.Location, 0) + if err == nil || errors.Is(err, ErrApplicationCredentialInvalid) { + return s.report(ctx, record, binding.Conflict, "固定位置已有未确认的凭据;请人工核实,未认领或覆盖") + } + if !errors.Is(err, ErrCredentialNotFound) { + return s.report(ctx, record, binding.DependencyUnavailable, "创建前无法确认凭据位置是否为空,等待依赖恢复") + } + credential, err := GenerateApplicationCredential(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint) + if err != nil { + return s.report(ctx, record, "InvalidTarget", "应用凭据目标无效,未执行外部写入") + } + status := record.Status + status.Ready, status.Reason = false, CredentialCreationStarted + status.Message = "凭据创建已开始;尚无成功确认时不得重入创建" + record, err = s.Resources.Save(ctx, record, status) + if err != nil { + return err + } + if err := s.Resources.CheckCurrent(ctx, record); err != nil { + return err + } + version, err := s.Store.CreateCredential(ctx, *record.Status.Location, credential) + if errors.Is(err, ErrCredentialUnavailable) { + // 适配器只在明确未执行写入(认证拒绝或请求前取消)时返回此错误。 + return s.report(ctx, record, binding.DependencyUnavailable, "凭据创建在执行前被拒绝,等待认证或权限恢复") + } + if err != nil || version != 1 { + return s.report(ctx, record, binding.Conflict, + "凭据创建冲突或结果不确定;请核对固定位置的版本历史,未认领、覆盖或重新生成密码") + } + if err := s.Resources.CheckCurrent(ctx, record); err != nil { + return err + } + status = record.Status + status.Version, status.Ready, status.Reason = version, true, CredentialPrepared + status.Message = "凭据已创建并回读确认;尚未创建 PostgreSQL 资源或交付给 Tenant" + _, err = s.Resources.Save(ctx, record, status) + return err +} + +func (s CredentialPreparation) observe(ctx context.Context, record *CredentialRecord) error { + credential, err := s.Store.ReadCredential(ctx, *record.Status.Location, record.Status.Version) + if errors.Is(err, ErrCredentialConflict) || errors.Is(err, ErrCredentialNotFound) { + return s.report(ctx, record, binding.Conflict, "已确认凭据消失、版本变化或内容无效;请人工核实,未生成替代密码") + } + if err != nil { + return s.report(ctx, record, binding.DependencyUnavailable, "已确认凭据暂时无法读取;保留确认版本,等待依赖恢复") + } + if !credential.MatchesTarget(record.Database.LoginRole, record.Database.Name, record.Instance.Endpoint) { + return s.report(ctx, record, binding.Conflict, "已确认凭据与当前 Instance/database/loginRole 不一致;请人工核实,未修改凭据") + } + if err := s.Resources.CheckCurrent(ctx, record); err != nil { + return err + } + status := record.Status + status.Ready, status.Reason = true, CredentialPrepared + status.Message = "已确认凭据可读取;尚未验证 PostgreSQL 资源或完成 Tenant 交付" + _, err = s.Resources.Save(ctx, record, status) + return err +} + +func (s CredentialPreparation) report(ctx context.Context, record *CredentialRecord, reason, message string) error { + status := record.Status + status.Ready, status.Reason = false, reason + status.Message = fmt.Sprintf("Database %s:%s", record.Database.Identity.Name, message) + _, err := s.Resources.Save(ctx, record, status) + return err +} diff --git a/internal/database/application/credential_preparation_test.go b/internal/database/application/credential_preparation_test.go new file mode 100644 index 0000000..7906374 --- /dev/null +++ b/internal/database/application/credential_preparation_test.go @@ -0,0 +1,168 @@ +package application + +import ( + "context" + "errors" + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding" + "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" +) + +const ( + preparationInstanceName = "credential-instance" + preparationInstanceUID = "credential-instance-uid" +) + +func preparationRecord(t *testing.T) *CredentialRecord { + t.Helper() + tenant := binding.TenantIdentity{Namespace: bindingTestNamespace, Name: bindingTestName, UID: "credential-tenant-uid"} + database := binding.Identity{Name: binding.DynamicDatabaseName(tenant.UID), UID: "credential-database-uid"} + endpoint, err := instance.NewEndpoint(instance.EndpointValues{ + Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432, ManagementDatabase: "management", TLSMode: instance.TLSVerifyFull, + }) + if err != nil { + t.Fatal(err) + } + return &CredentialRecord{ + Database: BindingDatabase{Database: binding.Database{ + Identity: database, Instance: preparationInstanceName, InstanceUID: preparationInstanceUID, Name: bindingTestName, LoginRole: bindingTestName, Source: "Provision", Tenant: &tenant, + }}, + Tenant: &BindingTenant{ + Identity: tenant, Phase: binding.Bound, Database: &database, + Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: preparationInstanceName}}, + }, + Instance: &CredentialInstance{Identity: binding.Identity{Name: preparationInstanceName, UID: preparationInstanceUID}, Ready: true, Endpoint: endpoint}, + DatabaseProtected: true, TenantProtected: true, + } +} + +type memoryCredentialResources struct { + record *CredentialRecord + saveError error + checkError error +} + +func (r *memoryCredentialResources) Load(context.Context, string) (*CredentialRecord, error) { + copy := *r.record + return ©, nil +} + +func (r *memoryCredentialResources) Save(_ context.Context, record *CredentialRecord, status CredentialStatus) (*CredentialRecord, error) { + if r.saveError != nil { + return nil, r.saveError + } + copy := *record + copy.Status = status + r.record = © + 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) (CredentialLocation, error) { + return CredentialLocation{Mount: "applications", Path: "database/" + uid}, nil +} + +func (s *preparationStore) ReadCredential(context.Context, CredentialLocation, int64) (ApplicationCredential, error) { + s.reads++ + return ApplicationCredential{}, s.readError +} + +func (s *preparationStore) CreateCredential(context.Context, CredentialLocation, ApplicationCredential) (int64, error) { + s.creates++ + if s.createError != nil { + return 0, s.createError + } + return 1, nil +} + +func TestCredentialPreparationGates(t *testing.T) { + for _, test := range []struct { + name string + change func(*CredentialRecord) + }{ + {"导入不供应", func(r *CredentialRecord) { r.Database.Source = "Import" }}, + {"删除中的资源", func(r *CredentialRecord) { r.Database.Deleting = true }}, + {"已释放资源", func(r *CredentialRecord) { r.Database.Phase = "Released" }}, + {"缺少资源保护", func(r *CredentialRecord) { r.DatabaseProtected = false }}, + {"缺少申请保护", func(r *CredentialRecord) { r.TenantProtected = false }}, + {"单向绑定", func(r *CredentialRecord) { r.Tenant.Database = nil }}, + {"删除中的申请", func(r *CredentialRecord) { r.Tenant.Deleting = true }}, + {"申请尚未Bound", func(r *CredentialRecord) { r.Tenant.Phase = binding.Binding }}, + {"旧申请身份", func(r *CredentialRecord) { r.Tenant.Identity.UID = "new-tenant" }}, + {"旧资源身份", func(r *CredentialRecord) { r.Tenant.Database.UID = "new-database" }}, + {"Instance未出现", func(r *CredentialRecord) { r.Instance = nil }}, + {"Instance正在删除", func(r *CredentialRecord) { r.Instance.Deleting = true }}, + {"Instance未Ready", func(r *CredentialRecord) { r.Instance.Ready = false }}, + {"Instance身份未记录", func(r *CredentialRecord) { r.Database.InstanceUID = "" }}, + {"Instance同名重建", func(r *CredentialRecord) { r.Instance.Identity.UID = "new-instance" }}, + {"目标不一致", func(r *CredentialRecord) { r.Database.LoginRole = "another_owner" }}, + } { + t.Run(test.name, func(t *testing.T) { + record := preparationRecord(t) + test.change(record) + resources := &memoryCredentialResources{record: record} + store := &preparationStore{readError: ErrCredentialNotFound} + if err := (CredentialPreparation{Resources: resources, Store: store}).Reconcile(t.Context(), record.Database.Identity.Name); err != nil { + t.Fatal(err) + } + if store.reads != 0 || store.creates != 0 || resources.record.Status.Version != 0 { + t.Fatal("前置条件不满足时不得读取、创建或确认凭据") + } + }) + } +} + +func TestCredentialPreparationWriteBoundary(t *testing.T) { + for _, test := range []struct { + name string + saveError bool + stale bool + createError error + wantError bool + wantCreates int + wantVersion int64 + }{ + {name: "位置无法保存", saveError: true, wantError: true}, + {name: "外部操作前快照变化", stale: true, wantError: true}, + {name: "明确权限拒绝", createError: ErrCredentialUnavailable, wantCreates: 1}, + {name: "创建结果不确定", createError: ErrCredentialUncertain, wantCreates: 1}, + {name: "并发创建冲突", createError: ErrCredentialConflict, wantCreates: 1}, + {name: "创建与回读成功", wantCreates: 1, wantVersion: 1}, + } { + t.Run(test.name, func(t *testing.T) { + resources := &memoryCredentialResources{record: preparationRecord(t)} + if test.saveError { + resources.saveError = errors.New("fixture write failure") + } + if test.stale { + resources.checkError = errors.New("fixture stale observation") + } + store := &preparationStore{readError: ErrCredentialNotFound, createError: test.createError} + service := CredentialPreparation{Resources: resources, Store: store} + err := service.Reconcile(t.Context(), resources.record.Database.Identity.Name) + if (err != nil) != test.wantError || store.creates != test.wantCreates || resources.record.Status.Version != test.wantVersion { + t.Fatal("外部写入边界或确认时机不符合预期") + } + if test.createError == ErrCredentialUncertain || test.stale { + resources.checkError = nil + if err := service.Reconcile(t.Context(), resources.record.Database.Identity.Name); err != nil { + t.Fatal(err) + } + if store.creates != test.wantCreates || resources.record.Status.Reason != binding.Conflict { + t.Fatal("未确认创建重入时不得生成替代密码") + } + } + }) + } +} diff --git a/internal/database/application/credential_target.go b/internal/database/application/credential_target.go new file mode 100644 index 0000000..02ca549 --- /dev/null +++ b/internal/database/application/credential_target.go @@ -0,0 +1,27 @@ +package application + +import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding" + +func (r *CredentialRecord) check() *binding.Issue { + database := r.Database + if database.Deleting || database.Phase == binding.Deleting || database.Phase == "Released" { + return &binding.Issue{Reason: "PreparationStopped", Message: "Database 正在删除或已释放;保留凭据与 finalizer,不执行供应或清理"} + } + if database.Tenant == nil || r.Tenant == nil || r.Tenant.Database == nil { + return &binding.Issue{Reason: binding.DependencyUnavailable, Message: "等待 Database 与 Tenant 双向绑定完成"} + } + if *database.Tenant != r.Tenant.Identity || *r.Tenant.Database != database.Identity { + return &binding.Issue{Reason: binding.Conflict, Message: "双向绑定的名称或 UID 不匹配,未创建凭据"} + } + if r.Tenant.Deleting || r.Tenant.Phase != binding.Bound || !r.DatabaseProtected || !r.TenantProtected { + return &binding.Issue{Reason: "PreparationStopped", Message: "Tenant 未完成绑定、正在删除或缺少 finalizer 保护,未创建凭据"} + } + target, err := r.Tenant.Request.Resolve(r.Tenant.Identity) + if err != nil || (target.Provision != nil && !database.MatchesProvision(target, r.Tenant.Identity)) || target.Name != database.Identity.Name { + return &binding.Issue{Reason: binding.Conflict, Message: "Tenant 申请与 Database 目标不一致,未创建凭据"} + } + if r.Instance == nil || database.InstanceUID == "" { + return &binding.Issue{Reason: binding.DependencyUnavailable, Message: "等待 Instance 与已记录的实例身份"} + } + return r.Instance.Check(&database.Database) +} diff --git a/internal/database/controller/credential_controller.go b/internal/database/controller/credential_controller.go new file mode 100644 index 0000000..ec87f4f --- /dev/null +++ b/internal/database/controller/credential_controller.go @@ -0,0 +1,67 @@ +package controller + +import ( + "context" + + 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" + 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 + Reader client.Reader + Store application.CredentialStore +} + +func (r *CredentialReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + service := application.CredentialPreparation{ + Resources: &kubernetes.CredentialResources{Client: r.Client, Reader: r.Reader}, Store: r.Store, + } + if err := 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.Client = manager.GetClient() + } + if r.Reader == nil { + r.Reader = manager.GetAPIReader() + } + 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 +}