feat: 固定 Database 凭据位置与确认版本
Verify / test (pull_request) Successful in 13m42s
Verify / lint (pull_request) Successful in 14m26s
Verify / database-integration (pull_request) Successful in 16m16s

This commit is contained in:
2026-09-27 18:08:09 +00:00
parent 6b2808ce91
commit 371248659b
13 changed files with 323 additions and 20 deletions
+2 -2
View File
@@ -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
@@ -0,0 +1,87 @@
package v1alpha1_test
import (
"testing"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
)
func testCredentialStatus(t *testing.T, client ctrlclient.Client) {
database := validDatabase("credential-status")
if err := client.Create(t.Context(), database); err != nil {
t.Fatal(err)
}
withoutLocation := database.DeepCopy()
withoutLocation.Status.CredentialVersion = 1
if err := client.Status().Update(t.Context(), withoutLocation); !apierrors.IsInvalid(err) {
t.Fatalf("没有位置不能确认版本: %v", err)
}
database.Status.CredentialRef = &databasev1alpha1.CredentialReference{
Mount: "application-secrets", Path: "applications/" + string(database.UID),
}
if err := client.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
testCredentialStatusRemoval(t, client, database)
changedTarget := database.DeepCopy()
changedTarget.Spec.Database = "another_database"
if err := client.Update(t.Context(), changedTarget); !apierrors.IsInvalid(err) {
t.Fatalf("凭据位置固定后不得更换实际目标: %v", err)
}
stale := database.DeepCopy()
database.Status.CredentialVersion = 1
if err := client.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
stale.Status.Phase = "Binding"
if err := client.Status().Update(t.Context(), stale); !apierrors.IsConflict(err) {
t.Fatalf("旧 resourceVersion 不得覆盖确认结果: %v", err)
}
testCredentialStatusRemoval(t, client, database)
for _, version := range []int64{0, -1, 2} {
changed := database.DeepCopy()
changed.Status.CredentialVersion = version
if err := client.Status().Update(t.Context(), changed); !apierrors.IsInvalid(err) {
t.Fatalf("不可移除或更改确认版本 %d: %v", version, err)
}
}
database.Status.Conditions = []metav1.Condition{{
Type: "CredentialsReady", Status: metav1.ConditionFalse,
Reason: "DependencyUnavailable", Message: "凭据暂时无法读取",
ObservedGeneration: database.Generation, LastTransitionTime: metav1.Now(),
}}
if err := client.Status().Update(t.Context(), database); err != nil {
t.Fatalf("当前不可用不应阻止保留确认记录: %v", err)
}
reloaded := &databasev1alpha1.PostgreSQLDatabase{}
if err := client.Get(t.Context(), ctrlclient.ObjectKeyFromObject(database), reloaded); err != nil {
t.Fatal(err)
}
if reloaded.Status.CredentialVersion != 1 || reloaded.Status.CredentialRef == nil {
t.Fatal("重读丢失凭据确认记录")
}
}
func testCredentialStatusRemoval(t *testing.T, client ctrlclient.Client, database *databasev1alpha1.PostgreSQLDatabase) {
t.Helper()
for _, change := range []struct {
name string
edit func(*databasev1alpha1.PostgreSQLDatabase)
}{
{"移除整个 status", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status = databasev1alpha1.PostgreSQLDatabaseStatus{} }},
{"移除位置", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef = nil }},
{"修改 mount", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef.Mount = "other" }},
{"修改 path", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef.Path = "applications/other" }},
} {
t.Run(change.name, func(t *testing.T) {
changed := database.DeepCopy()
change.edit(changed)
if err := client.Status().Update(t.Context(), changed); !apierrors.IsInvalid(err) {
t.Fatalf("不应接受已固定凭据位置的更改: %v", err)
}
})
}
}
@@ -30,6 +30,14 @@ type PostgreSQLDatabaseStatus struct {
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
// +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`
+1
View File
@@ -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) })
}
@@ -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))