7 Commits
Author SHA1 Message Date
panxiao81 f4deb98a7f Merge pull request 'feat: Instance 原生管理观测与删除保护' (#11) from feat/database-instance-observation into main 2026-09-25 14:32:25 +00:00
panxiao81 bc227bfdb4 feat: 接入 Instance 原生管理观测与删除保护
Verify / test (pull_request) Successful in 12m41s
Verify / lint (pull_request) Successful in 14m14s
Verify / database-integration (pull_request) Successful in 16m9s
2026-09-25 11:35:04 +00:00
panxiao81 55b269ce2e Merge pull request 'feat: Database 三资源 API 与分层绑定协调' (#10) from feat/database-resource-api into main 2026-09-25 04:44:13 +00:00
panxiao81 7e9e8e828b feat: 接入 Database 三资源 API 与分层绑定协调
Verify / test (pull_request) Successful in 11m39s
Verify / lint (pull_request) Successful in 12m51s
Verify / database-integration (pull_request) Successful in 13m12s
确定单库单账号、集群级 Database、资源侧先写绑定和凭据定位合同。领域层承载纯规则,service 协調流程,Kubernetes adapter 负责资源呈现与版本保护。

验证:全量 make test、三轮 race、真实 API server 并发与重启补写、最小 RBAC/watch、lint 和文档检查通过。供应、凭据交付及删除清理尚未实现,保留 DeletionPending/finalizer 边界。
2026-09-25 04:09:43 +00:00
panxiao81 347a667c0c Merge pull request 'refactor: 分离 Database 资源与申请并撤除 registry' (#9) from feat/database-registry-inspection into main 2026-09-24 17:22:53 +00:00
panxiao81 f347ee5292 ci: 避免 main 合并后重复全量验证
Verify / lint (pull_request) Successful in 16m40s
Verify / database-integration (pull_request) Successful in 17m50s
Verify / test (pull_request) Successful in 3m25s
2026-09-24 16:58:20 +00:00
panxiao81 e2e795a889 Merge pull request 'feat: 接入 Instance 版本与扩展可用性观测' (#8) from feat/database-metadata-observation into main
Verify / test (push) Successful in 10m53s
Verify / lint (push) Successful in 12m26s
Verify / database-integration (push) Successful in 16m44s
2026-09-24 16:51:48 +00:00
55 changed files with 4871 additions and 103 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
name: Verify name: Verify
on: on:
# 合并前完成全量验证,合并到 main 后不重复运行同一套检查。
pull_request: pull_request:
push: workflow_dispatch:
branches: [main]
jobs: jobs:
test: test:
+67
View File
@@ -0,0 +1,67 @@
package v1alpha1
import "k8s.io/apimachinery/pkg/types"
// ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`
type ObjectName string
// PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z][a-z0-9_]{0,62}$`
type PostgreSQLIdentifier string
// InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
type InstanceReference struct {
Name ObjectName `json:"name"`
}
// DatabaseReference 是 Tenant 对已有集群级 PostgreSQLDatabase 的选择。
type DatabaseReference struct {
Name ObjectName `json:"name"`
}
// BoundDatabaseReference 记录已经参与绑定的对象身份,而非仅记录可复用的名称。
type BoundDatabaseReference struct {
Name ObjectName `json:"name"`
// +kubebuilder:validation:Type=string
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=128
UID types.UID `json:"uid"`
}
// TenantReference 是 Database 的当前绑定记录,不是允许绑定名单。
type TenantReference struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
Namespace string `json:"namespace"`
Name ObjectName `json:"name"`
// +kubebuilder:validation:Type=string
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=128
UID types.UID `json:"uid"`
}
// ReclaimPolicy 控制资源释放后的处置,只有资源管理者可以修改。
// +kubebuilder:validation:Enum=Retain;Delete
type ReclaimPolicy string
const (
ReclaimRetain ReclaimPolicy = "Retain"
ReclaimDelete ReclaimPolicy = "Delete"
)
// CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
// 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
type CredentialReference struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
Mount string `json:"mount"`
// Path 是 mount 内的逻辑路径,不含 KV v2 的 data/ API 前缀。
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=1024
Path string `json:"path"`
}
@@ -0,0 +1,25 @@
// Package v1alpha1 定义 Database 领域的 Kubernetes API。
// +kubebuilder:object:generate=true
// +groupName=database.ayatori.ddupan.top
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var (
SchemeGroupVersion = schema.GroupVersion{Group: "database.ayatori.ddupan.top", Version: "v1alpha1"}
GroupVersion = SchemeGroupVersion
SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&PostgreSQLInstance{}, &PostgreSQLInstanceList{},
&PostgreSQLDatabase{}, &PostgreSQLDatabaseList{},
&PostgreSQLTenant{}, &PostgreSQLTenantList{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
})
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -0,0 +1,62 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
// PostgreSQLDatabaseSpec 是一库、一个 login owner 及凭据的独立资源声明。
// +kubebuilder:validation:XValidation:rule="(self.source == 'Import') == has(self.credentialRef)",message="only imported databases require an existing credentialRef"
type PostgreSQLDatabaseSpec struct {
InstanceRef InstanceReference `json:"instanceRef"`
Database PostgreSQLIdentifier `json:"database"`
LoginRole PostgreSQLIdentifier `json:"loginRole"`
// Source 明确区分创建与只读导入,不从后端同名对象推断。
// +kubebuilder:validation:Enum=Provision;Import
Source string `json:"source"`
// +optional
CredentialRef *CredentialReference `json:"credentialRef,omitempty"`
// +kubebuilder:default=Retain
// +optional
ReclaimPolicy ReclaimPolicy `json:"reclaimPolicy,omitempty"`
// TenantRef 由 controller 先写入;Released 时仍保留旧身份。
// +optional
TenantRef *TenantReference `json:"tenantRef,omitempty"`
}
type PostgreSQLDatabaseStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
// +optional
InstanceUID types.UID `json:"instanceUID,omitempty"`
// Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
// +optional
Phase string `json:"phase,omitempty"`
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +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: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`
type PostgreSQLDatabase struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec PostgreSQLDatabaseSpec `json:"spec"`
// +optional
Status PostgreSQLDatabaseStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type PostgreSQLDatabaseList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []PostgreSQLDatabase `json:"items"`
}
@@ -0,0 +1,80 @@
package v1alpha1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// PostgreSQLEndpoint 显式区分证书主机名与实际连接 IP,不进行 DNS 推导。
type PostgreSQLEndpoint struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
Host string `json:"host"`
// +kubebuilder:validation:MaxLength=45
// +kubebuilder:validation:XValidation:rule="isIP(self)",message="hostaddr must be a single IPv4 or IPv6 address"
HostAddr string `json:"hostaddr"`
// +kubebuilder:default=5432
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +optional
Port int32 `json:"port,omitempty"`
// +kubebuilder:default=postgres
// +optional
Database PostgreSQLIdentifier `json:"database,omitempty"`
// +kubebuilder:default=verify-full
// +kubebuilder:validation:Enum=disable;require;verify-ca;verify-full
// +optional
SSLMode string `json:"sslMode,omitempty"`
}
// AdminCredentialReference 只能读取 controller namespace 的 Secret。
type AdminCredentialReference struct {
Name ObjectName `json:"name"`
// +kubebuilder:default=username
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$`
// +optional
UsernameKey string `json:"usernameKey,omitempty"`
// +kubebuilder:default=password
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$`
// +optional
PasswordKey string `json:"passwordKey,omitempty"`
}
type PostgreSQLInstanceSpec struct {
Endpoint PostgreSQLEndpoint `json:"endpoint"`
AdminCredentialRef AdminCredentialReference `json:"adminCredentialRef"`
}
type PostgreSQLInstanceStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +kubebuilder:validation:Enum=Pending;Validating;Ready;Deleting
// +optional
Phase string `json:"phase,omitempty"`
// +optional
PostgreSQLVersion string `json:"postgresqlVersion,omitempty"`
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
type PostgreSQLInstance struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec PostgreSQLInstanceSpec `json:"spec"`
// +optional
Status PostgreSQLInstanceStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type PostgreSQLInstanceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []PostgreSQLInstance `json:"items"`
}
@@ -0,0 +1,70 @@
package v1alpha1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// DatabaseProvisionRequest 仅用于动态申请,省略名称时由 controller 按 Tenant 名称解析。
type DatabaseProvisionRequest struct {
InstanceRef InstanceReference `json:"instanceRef"`
// +optional
Database PostgreSQLIdentifier `json:"database,omitempty"`
// +optional
LoginRole PostgreSQLIdentifier `json:"loginRole,omitempty"`
}
// PostgreSQLTenantSpec 显式选择动态申请或已有 Database,不重复声明来源。
// +kubebuilder:validation:XValidation:rule="has(self.provision) != has(self.databaseRef)",message="exactly one of provision and databaseRef is required"
type PostgreSQLTenantSpec struct {
// +optional
Provision *DatabaseProvisionRequest `json:"provision,omitempty"`
// +optional
DatabaseRef *DatabaseReference `json:"databaseRef,omitempty"`
// Extensions 保留后端扩展名称的原样拼写,不按 SQL identifier 限制。
// +listType=set
// +optional
Extensions []string `json:"extensions,omitempty"`
// SecretName 指定 Tenant namespace 内的投射目标,省略时使用合同约定的默认名称。
// +optional
SecretName ObjectName `json:"secretName,omitempty"`
}
type PostgreSQLTenantStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// DatabaseRef 只有在资源侧确认绑定后才写入。
// +optional
DatabaseRef *BoundDatabaseReference `json:"databaseRef,omitempty"`
// +optional
Phase string `json:"phase,omitempty"`
// SecretName 是已观察到的同 namespace 投射目标,不包含凭据值。
// +optional
SecretName ObjectName `json:"secretName,omitempty"`
// CredentialURL 只含 OpenBao API 位置,禁止嵌入认证信息。
// +optional
CredentialURL string `json:"credentialURL,omitempty"`
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase in ['Binding', 'Bound', 'Deleting']) || ((has(self.spec.provision) == has(oldSelf.spec.provision)) && (!has(oldSelf.spec.provision) || self.spec.provision == oldSelf.spec.provision) && (has(self.spec.databaseRef) == has(oldSelf.spec.databaseRef)) && (!has(oldSelf.spec.databaseRef) || self.spec.databaseRef == oldSelf.spec.databaseRef))",message="binding target cannot change after binding starts"
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase in ['Binding', 'Bound', 'Deleting']) || (has(self.status) && has(self.status.phase) && self.status.phase in ['Binding', 'Bound', 'Deleting'])",message="binding progress cannot return to an unbound state"
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.status.databaseRef.name`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
type PostgreSQLTenant struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec PostgreSQLTenantSpec `json:"spec"`
// +optional
Status PostgreSQLTenantStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type PostgreSQLTenantList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []PostgreSQLTenant `json:"items"`
}
+312
View File
@@ -0,0 +1,312 @@
package v1alpha1_test
import (
"context"
"io"
"os"
"path/filepath"
"testing"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/yaml"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
const (
testNamespace = "database-api"
testInstanceName = "shared-postgres"
readyPhase = "Ready"
)
func TestDatabaseAPI(t *testing.T) {
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
t.Skip("KUBEBUILDER_ASSETS 未设置;运行 make test 执行真实 API server 测试")
}
scheme := runtime.NewScheme()
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
crdPath, err := filepath.Abs("../../../config/crd/bases")
if err != nil {
t.Fatal(err)
}
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}, ErrorIfCRDPathMissing: true}
config, err := environment.Start()
if err != nil {
t.Fatalf("启动 envtest: %v", err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Errorf("停止 envtest: %v", err)
}
})
client, err := ctrlclient.New(config, ctrlclient.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
namespace := &corev1.Namespace{}
namespace.Name = testNamespace
if err := client.Create(t.Context(), namespace); err != nil {
t.Fatal(err)
}
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) { testSamples(t, client, scheme) })
}
func testSamples(t *testing.T, client ctrlclient.Client, scheme *runtime.Scheme) {
paths := []string{
"database_v1alpha1_postgresqlinstance.yaml",
"database_v1alpha1_postgresqldatabase.yaml",
"database_v1alpha1_postgresqltenant.yaml",
}
for _, name := range paths {
t.Run(name, func(t *testing.T) {
file, err := os.Open(filepath.Join("../../../config/samples", name))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := file.Close(); err != nil {
t.Error(err)
}
})
decoder := yaml.NewYAMLOrJSONDecoder(file, 4096)
for {
var raw runtime.RawExtension
if err := decoder.Decode(&raw); err == io.EOF {
break
} else if err != nil {
t.Fatal(err)
}
object, _, err := serializer.NewCodecFactory(scheme).UniversalDeserializer().Decode(raw.Raw, nil, nil)
if err != nil {
t.Fatal(err)
}
resource, ok := object.(ctrlclient.Object)
if !ok {
t.Fatalf("示例不是资源对象: %T", object)
}
if resource.GetNamespace() != "" {
resource.SetNamespace(testNamespace)
}
if err := client.Create(t.Context(), resource); err != nil {
t.Fatalf("示例未通过 API 校验: %v", err)
}
}
})
}
}
func testDefaults(t *testing.T, client ctrlclient.Client) {
instance := validInstance("defaults")
if err := client.Create(t.Context(), instance); err != nil {
t.Fatal(err)
}
endpoint := instance.Spec.Endpoint
if endpoint.Port != 5432 || endpoint.Database != "postgres" || endpoint.SSLMode != "verify-full" {
t.Fatalf("连接默认值不符: %+v", endpoint)
}
credentials := instance.Spec.AdminCredentialRef
if credentials.UsernameKey != "username" || credentials.PasswordKey != "password" {
t.Fatal("管理 Secret 字段默认值不符")
}
database := validDatabase("defaults")
if err := client.Create(t.Context(), database); err != nil {
t.Fatal(err)
}
if database.Spec.ReclaimPolicy != databasev1alpha1.ReclaimRetain {
t.Fatalf("默认回收策略 = %q", database.Spec.ReclaimPolicy)
}
// 进入删除流程前允许双向修改策略,不要求第二次审批字段。
for _, policy := range []databasev1alpha1.ReclaimPolicy{databasev1alpha1.ReclaimDelete, databasev1alpha1.ReclaimRetain} {
database.Spec.ReclaimPolicy = policy
if err := client.Update(t.Context(), database); err != nil {
t.Fatalf("修改回收策略: %v", err)
}
}
objects := []struct {
object ctrlclient.Object
namespaced bool
}{
{instance, false}, {database, false}, {validTenant("scope"), true},
}
for _, item := range objects {
namespaced, err := client.IsObjectNamespaced(item.object)
if err != nil || namespaced != item.namespaced {
t.Fatalf("%T 作用域 = %v, error = %v", item.object, namespaced, err)
}
}
// 导入不要求 Tenant 或 Instance 对象已经存在,跨对象就绪由 controller 判断。
imported := validDatabase("imported")
imported.Spec.Source = "Import"
imported.Spec.CredentialRef = &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing/app"}
if err := client.Create(t.Context(), imported); err != nil {
t.Fatal(err)
}
for _, name := range []string{"first", "second"} {
tenant := validTenant(name)
tenant.Spec.Provision = nil
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: "imported"}
if err := client.Create(t.Context(), tenant); err != nil {
t.Fatalf("声明已有资源申请: %v", err)
}
}
// 两个申请都可被 API 接受,不代表二者都已绑定或获得凭据。
}
func testInvalidDeclarations(t *testing.T, client ctrlclient.Client) {
instanceCases := []struct {
name string
mutate func(*databasev1alpha1.PostgreSQLInstance)
}{
{"port", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.Port = -1 }},
{"address", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.HostAddr = "localhost" }},
{"scoped-address", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.HostAddr = "fe80::1%eth0" }},
{"tls", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.SSLMode = "prefer" }},
{"identifier", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.Database = "bad-name" }},
{"secret-key", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.AdminCredentialRef.PasswordKey = "bad/key" }},
}
for _, tc := range instanceCases {
t.Run(tc.name, func(t *testing.T) {
object := validInstance(tc.name)
tc.mutate(object)
requireInvalidCreate(t, client, object)
})
}
databaseCases := []struct {
name string
mutate func(*databasev1alpha1.PostgreSQLDatabase)
}{
{"missing-instance", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.InstanceRef.Name = "" }},
{"missing-role", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.LoginRole = "" }},
{"unknown-source", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.Source = "Adopt" }},
{"missing-credentials", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.Source = "Import" }},
{"provision-credentials", func(d *databasev1alpha1.PostgreSQLDatabase) {
d.Spec.CredentialRef = &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing"}
}},
{"unknown-policy", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.ReclaimPolicy = "Recycle" }},
{"binding-without-uid", func(d *databasev1alpha1.PostgreSQLDatabase) {
d.Spec.TenantRef = &databasev1alpha1.TenantReference{Namespace: testNamespace, Name: "tenant"}
}},
}
for _, tc := range databaseCases {
t.Run(tc.name, func(t *testing.T) {
object := validDatabase(tc.name)
tc.mutate(object)
requireInvalidCreate(t, client, object)
})
}
t.Run("互斥申请入口", func(t *testing.T) {
tenant := validTenant("ambiguous")
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: "existing"}
requireInvalidCreate(t, client, tenant)
tenant.Spec.Provision = nil
tenant.Spec.DatabaseRef = nil
requireInvalidCreate(t, client, tenant)
})
}
// 本测试验证 API 写入语义,不模拟或宣称已经实现 controller 的恢复循环。
func testBindingWrites(t *testing.T, client ctrlclient.Client) {
ctx := t.Context()
tenant := validTenant("binding")
tenant.Status.Phase = readyPhase
if err := client.Create(ctx, tenant); err != nil {
t.Fatal(err)
}
if tenant.Status.Phase != "" {
t.Fatal("普通 Create 不应写入 status")
}
database := validDatabase("binding")
if err := client.Create(ctx, database); err != nil {
t.Fatal(err)
}
stale := database.DeepCopy()
database.Spec.TenantRef = &databasev1alpha1.TenantReference{
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
}
if err := client.Update(ctx, database); err != nil {
t.Fatal(err)
}
stale.Spec.TenantRef = &databasev1alpha1.TenantReference{Namespace: tenant.Namespace, Name: "other", UID: "other-uid"}
if err := client.Update(ctx, stale); !apierrors.IsConflict(err) {
t.Fatalf("过期并发写入 = %v, want Conflict", err)
}
// 换用 API 回读的对象补第二步,证明恢复所需记录不依赖先前内存。
observedDatabase := &databasev1alpha1.PostgreSQLDatabase{}
if err := client.Get(ctx, ctrlclient.ObjectKeyFromObject(database), observedDatabase); err != nil {
t.Fatal(err)
}
if observedDatabase.Spec.TenantRef.UID != tenant.UID {
t.Fatal("资源侧绑定被竞争写入覆盖")
}
beforeGeneration := tenant.Generation
tenant.Status.DatabaseRef = &databasev1alpha1.BoundDatabaseReference{
Name: databasev1alpha1.ObjectName(database.Name), UID: database.UID,
}
if err := client.Status().Update(ctx, tenant); err != nil {
t.Fatal(err)
}
if tenant.Generation != beforeGeneration || tenant.Status.DatabaseRef.UID != database.UID {
t.Fatal("status 更新错误地影响 generation 或绑定身份")
}
tenant.Status.Phase = readyPhase
if err := client.Update(ctx, tenant); err != nil {
t.Fatal(err)
}
if tenant.Status.Phase != "" {
t.Fatal("普通 Update 不应修改 status")
}
condition := metav1.Condition{Type: readyPhase, Status: metav1.ConditionFalse,
Reason: "Pending", Message: "尚未验证后端", LastTransitionTime: metav1.Now()}
tenant.Status.Conditions = []metav1.Condition{condition, condition}
if err := client.Status().Update(ctx, tenant); !apierrors.IsInvalid(err) {
t.Fatalf("重复 Condition = %v, want Invalid", err)
}
}
func requireInvalidCreate(t *testing.T, client ctrlclient.Client, object ctrlclient.Object) {
t.Helper()
if err := client.Create(context.Background(), object); !apierrors.IsInvalid(err) {
t.Fatalf("Create %T = %v, want Invalid", object, err)
}
}
func validInstance(name string) *databasev1alpha1.PostgreSQLInstance {
object := &databasev1alpha1.PostgreSQLInstance{}
object.Name = name
object.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{Host: "postgres.example.test", HostAddr: "127.0.0.1"}
object.Spec.AdminCredentialRef.Name = "postgres-admin"
return object
}
func validDatabase(name string) *databasev1alpha1.PostgreSQLDatabase {
object := &databasev1alpha1.PostgreSQLDatabase{}
object.Name = name
object.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
InstanceRef: databasev1alpha1.InstanceReference{Name: testInstanceName},
Database: "app", LoginRole: "app", Source: "Provision",
}
return object
}
func validTenant(name string) *databasev1alpha1.PostgreSQLTenant {
object := &databasev1alpha1.PostgreSQLTenant{}
object.Name = name
object.Namespace = testNamespace
object.Spec.Provision = &databasev1alpha1.DatabaseProvisionRequest{
InstanceRef: databasev1alpha1.InstanceReference{Name: testInstanceName},
}
return object
}
@@ -0,0 +1,452 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AdminCredentialReference) DeepCopyInto(out *AdminCredentialReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdminCredentialReference.
func (in *AdminCredentialReference) DeepCopy() *AdminCredentialReference {
if in == nil {
return nil
}
out := new(AdminCredentialReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BoundDatabaseReference) DeepCopyInto(out *BoundDatabaseReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BoundDatabaseReference.
func (in *BoundDatabaseReference) DeepCopy() *BoundDatabaseReference {
if in == nil {
return nil
}
out := new(BoundDatabaseReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CredentialReference) DeepCopyInto(out *CredentialReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialReference.
func (in *CredentialReference) DeepCopy() *CredentialReference {
if in == nil {
return nil
}
out := new(CredentialReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseProvisionRequest) DeepCopyInto(out *DatabaseProvisionRequest) {
*out = *in
out.InstanceRef = in.InstanceRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseProvisionRequest.
func (in *DatabaseProvisionRequest) DeepCopy() *DatabaseProvisionRequest {
if in == nil {
return nil
}
out := new(DatabaseProvisionRequest)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseReference) DeepCopyInto(out *DatabaseReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseReference.
func (in *DatabaseReference) DeepCopy() *DatabaseReference {
if in == nil {
return nil
}
out := new(DatabaseReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *InstanceReference) DeepCopyInto(out *InstanceReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceReference.
func (in *InstanceReference) DeepCopy() *InstanceReference {
if in == nil {
return nil
}
out := new(InstanceReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLDatabase) DeepCopyInto(out *PostgreSQLDatabase) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabase.
func (in *PostgreSQLDatabase) DeepCopy() *PostgreSQLDatabase {
if in == nil {
return nil
}
out := new(PostgreSQLDatabase)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLDatabase) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLDatabaseList) DeepCopyInto(out *PostgreSQLDatabaseList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]PostgreSQLDatabase, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseList.
func (in *PostgreSQLDatabaseList) DeepCopy() *PostgreSQLDatabaseList {
if in == nil {
return nil
}
out := new(PostgreSQLDatabaseList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLDatabaseList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLDatabaseSpec) DeepCopyInto(out *PostgreSQLDatabaseSpec) {
*out = *in
out.InstanceRef = in.InstanceRef
if in.CredentialRef != nil {
in, out := &in.CredentialRef, &out.CredentialRef
*out = new(CredentialReference)
**out = **in
}
if in.TenantRef != nil {
in, out := &in.TenantRef, &out.TenantRef
*out = new(TenantReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseSpec.
func (in *PostgreSQLDatabaseSpec) DeepCopy() *PostgreSQLDatabaseSpec {
if in == nil {
return nil
}
out := new(PostgreSQLDatabaseSpec)
in.DeepCopyInto(out)
return out
}
// 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.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseStatus.
func (in *PostgreSQLDatabaseStatus) DeepCopy() *PostgreSQLDatabaseStatus {
if in == nil {
return nil
}
out := new(PostgreSQLDatabaseStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLEndpoint) DeepCopyInto(out *PostgreSQLEndpoint) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLEndpoint.
func (in *PostgreSQLEndpoint) DeepCopy() *PostgreSQLEndpoint {
if in == nil {
return nil
}
out := new(PostgreSQLEndpoint)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstance) DeepCopyInto(out *PostgreSQLInstance) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstance.
func (in *PostgreSQLInstance) DeepCopy() *PostgreSQLInstance {
if in == nil {
return nil
}
out := new(PostgreSQLInstance)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLInstance) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstanceList) DeepCopyInto(out *PostgreSQLInstanceList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]PostgreSQLInstance, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceList.
func (in *PostgreSQLInstanceList) DeepCopy() *PostgreSQLInstanceList {
if in == nil {
return nil
}
out := new(PostgreSQLInstanceList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLInstanceList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstanceSpec) DeepCopyInto(out *PostgreSQLInstanceSpec) {
*out = *in
out.Endpoint = in.Endpoint
out.AdminCredentialRef = in.AdminCredentialRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceSpec.
func (in *PostgreSQLInstanceSpec) DeepCopy() *PostgreSQLInstanceSpec {
if in == nil {
return nil
}
out := new(PostgreSQLInstanceSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstanceStatus) DeepCopyInto(out *PostgreSQLInstanceStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceStatus.
func (in *PostgreSQLInstanceStatus) DeepCopy() *PostgreSQLInstanceStatus {
if in == nil {
return nil
}
out := new(PostgreSQLInstanceStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenant) DeepCopyInto(out *PostgreSQLTenant) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenant.
func (in *PostgreSQLTenant) DeepCopy() *PostgreSQLTenant {
if in == nil {
return nil
}
out := new(PostgreSQLTenant)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLTenant) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenantList) DeepCopyInto(out *PostgreSQLTenantList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]PostgreSQLTenant, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantList.
func (in *PostgreSQLTenantList) DeepCopy() *PostgreSQLTenantList {
if in == nil {
return nil
}
out := new(PostgreSQLTenantList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLTenantList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenantSpec) DeepCopyInto(out *PostgreSQLTenantSpec) {
*out = *in
if in.Provision != nil {
in, out := &in.Provision, &out.Provision
*out = new(DatabaseProvisionRequest)
**out = **in
}
if in.DatabaseRef != nil {
in, out := &in.DatabaseRef, &out.DatabaseRef
*out = new(DatabaseReference)
**out = **in
}
if in.Extensions != nil {
in, out := &in.Extensions, &out.Extensions
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantSpec.
func (in *PostgreSQLTenantSpec) DeepCopy() *PostgreSQLTenantSpec {
if in == nil {
return nil
}
out := new(PostgreSQLTenantSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenantStatus) DeepCopyInto(out *PostgreSQLTenantStatus) {
*out = *in
if in.DatabaseRef != nil {
in, out := &in.DatabaseRef, &out.DatabaseRef
*out = new(BoundDatabaseReference)
**out = **in
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantStatus.
func (in *PostgreSQLTenantStatus) DeepCopy() *PostgreSQLTenantStatus {
if in == nil {
return nil
}
out := new(PostgreSQLTenantStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TenantReference) DeepCopyInto(out *TenantReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantReference.
func (in *TenantReference) DeepCopy() *TenantReference {
if in == nil {
return nil
}
out := new(TenantReference)
in.DeepCopyInto(out)
return out
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
ctrl "sigs.k8s.io/controller-runtime"
)
func setupInstanceObservation(manager ctrl.Manager, namespace, rootCert string) (*application.InstanceService, error) {
credentials, err := kubernetes.NewSecretCredentials(manager.GetConfig(), namespace)
if err != nil {
return nil, err
}
service, err := application.NewInstanceService(credentials, postgresql.Connector{RootCert: rootCert})
if err != nil {
return nil, err
}
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: namespace}
if err := reconciler.SetupWithManager(manager); err != nil {
service.Close()
return nil, err
}
return service, nil
}
+33 -3
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"crypto/tls" "crypto/tls"
"flag" "flag"
"os" "os"
@@ -19,7 +20,10 @@ import (
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1" executionv1alpha1 "git.ddupan.top/panxiao81/ayatori/api/execution/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
// +kubebuilder:scaffold:imports // +kubebuilder:scaffold:imports
) )
@@ -32,11 +36,16 @@ func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(executionv1alpha1.AddToScheme(scheme)) utilruntime.Must(executionv1alpha1.AddToScheme(scheme))
utilruntime.Must(databasev1alpha1.AddToScheme(scheme))
// +kubebuilder:scaffold:scheme // +kubebuilder:scaffold:scheme
} }
// nolint:gocyclo // nolint:gocyclo
func main() { func main() {
var databaseNamespace, databaseRootCert string
flag.StringVar(&databaseNamespace, "database-secret-namespace", os.Getenv("POD_NAMESPACE"),
"固定管理 Secret namespace;为空时不启用 Instance 观测")
flag.StringVar(&databaseRootCert, "database-root-cert", "", "PostgreSQL 管理连接信任的公开 CA bundle 路径")
var metricsAddr string var metricsAddr string
var metricsCertPath, metricsCertName, metricsCertKey string var metricsCertPath, metricsCertName, metricsCertKey string
var webhookCertPath, webhookCertName, webhookCertKey string var webhookCertPath, webhookCertName, webhookCertKey string
@@ -141,7 +150,7 @@ func main() {
metricsServerOptions.KeyName = metricsCertKey metricsServerOptions.KeyName = metricsCertKey
} }
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ managerOptions := ctrl.Options{
Scheme: scheme, Scheme: scheme,
Metrics: metricsServerOptions, Metrics: metricsServerOptions,
WebhookServer: webhookServer, WebhookServer: webhookServer,
@@ -159,13 +168,29 @@ func main() {
// if you are doing or is intended to do any operation such as perform cleanups // if you are doing or is intended to do any operation such as perform cleanups
// after the manager stops then its usage might be unsafe. // after the manager stops then its usage might be unsafe.
// LeaderElectionReleaseOnCancel: true, // LeaderElectionReleaseOnCancel: true,
}) }
if databaseNamespace != "" {
managerOptions.Cache = databasecontroller.InstanceCacheOptions(databaseNamespace)
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), managerOptions)
if err != nil { if err != nil {
setupLog.Error(err, "Failed to start manager") setupLog.Error(err, "Failed to start manager")
os.Exit(1) os.Exit(1)
} }
// +kubebuilder:scaffold:builder // +kubebuilder:scaffold:builder
var instanceService *application.InstanceService
if databaseNamespace != "" {
instanceService, err = setupInstanceObservation(mgr, databaseNamespace, databaseRootCert)
if err != nil {
setupLog.Error(err, "Failed to set up Instance observation")
os.Exit(1)
}
}
if err := (&databasecontroller.BindingReconciler{}).SetupWithManager(context.Background(), mgr); err != nil {
setupLog.Error(err, "Failed to set up Database binding controller")
os.Exit(1)
}
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "Failed to set up health check") setupLog.Error(err, "Failed to set up health check")
@@ -177,7 +202,12 @@ func main() {
} }
setupLog.Info("Starting manager") setupLog.Info("Starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { err = mgr.Start(ctrl.SetupSignalHandler())
// worker 完全停止后才释放 pgxpool,避免与在途观察竞争。
if instanceService != nil {
instanceService.Close()
}
if err != nil {
setupLog.Error(err, "Failed to run manager") setupLog.Error(err, "Failed to run manager")
os.Exit(1) os.Exit(1)
} }
@@ -0,0 +1,226 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: postgresqldatabases.database.ayatori.ddupan.top
spec:
group: database.ayatori.ddupan.top
names:
kind: PostgreSQLDatabase
listKind: PostgreSQLDatabaseList
plural: postgresqldatabases
singular: postgresqldatabase
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.instanceRef.name
name: Instance
type: string
- jsonPath: .spec.database
name: Database
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: PostgreSQLDatabaseSpec 是一库、一个 login owner 及凭据的独立资源声明。
properties:
credentialRef:
description: |-
CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
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
database:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
instanceRef:
description: InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
required:
- name
type: object
loginRole:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
reclaimPolicy:
default: Retain
description: ReclaimPolicy 控制资源释放后的处置,只有资源管理者可以修改。
enum:
- Retain
- Delete
type: string
source:
description: Source 明确区分创建与只读导入,不从后端同名对象推断。
enum:
- Provision
- Import
type: string
tenantRef:
description: TenantRef 由 controller 先写入;Released 时仍保留旧身份。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
namespace:
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
type: string
uid:
description: |-
UID is a type that holds unique ID values, including UUIDs. Because we
don't ONLY use UUIDs, this is an alias to string. Being a type captures
intent and helps make sure that UIDs and names do not get conflated.
maxLength: 128
minLength: 1
type: string
required:
- name
- namespace
- uid
type: object
required:
- database
- instanceRef
- loginRole
- source
type: object
x-kubernetes-validations:
- message: only imported databases require an existing credentialRef
rule: (self.source == 'Import') == has(self.credentialRef)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
instanceUID:
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
type: string
observedGeneration:
format: int64
type: integer
phase:
description: Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
type: string
type: object
required:
- spec
type: object
x-kubernetes-validations:
- 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)
|| self.spec.credentialRef == oldSelf.spec.credentialRef))'
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,191 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: postgresqlinstances.database.ayatori.ddupan.top
spec:
group: database.ayatori.ddupan.top
names:
kind: PostgreSQLInstance
listKind: PostgreSQLInstanceList
plural: postgresqlinstances
singular: postgresqlinstance
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
adminCredentialRef:
description: AdminCredentialReference 只能读取 controller namespace 的
Secret。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
passwordKey:
default: password
maxLength: 253
minLength: 1
pattern: ^[-._a-zA-Z0-9]+$
type: string
usernameKey:
default: username
maxLength: 253
minLength: 1
pattern: ^[-._a-zA-Z0-9]+$
type: string
required:
- name
type: object
endpoint:
description: PostgreSQLEndpoint 显式区分证书主机名与实际连接 IP,不进行 DNS 推导。
properties:
database:
default: postgres
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
host:
maxLength: 253
minLength: 1
type: string
hostaddr:
maxLength: 45
type: string
x-kubernetes-validations:
- message: hostaddr must be a single IPv4 or IPv6 address
rule: isIP(self)
port:
default: 5432
format: int32
maximum: 65535
minimum: 1
type: integer
sslMode:
default: verify-full
enum:
- disable
- require
- verify-ca
- verify-full
type: string
required:
- host
- hostaddr
type: object
required:
- adminCredentialRef
- endpoint
type: object
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
format: int64
type: integer
phase:
enum:
- Pending
- Validating
- Ready
- Deleting
type: string
postgresqlVersion:
type: string
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,223 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: postgresqltenants.database.ayatori.ddupan.top
spec:
group: database.ayatori.ddupan.top
names:
kind: PostgreSQLTenant
listKind: PostgreSQLTenantList
plural: postgresqltenants
singular: postgresqltenant
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .status.databaseRef.name
name: Database
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: PostgreSQLTenantSpec 显式选择动态申请或已有 Database,不重复声明来源。
properties:
databaseRef:
description: DatabaseReference 是 Tenant 对已有集群级 PostgreSQLDatabase
的选择。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
required:
- name
type: object
extensions:
description: Extensions 保留后端扩展名称的原样拼写,不按 SQL identifier 限制。
items:
type: string
type: array
x-kubernetes-list-type: set
provision:
description: DatabaseProvisionRequest 仅用于动态申请,省略名称时由 controller 按
Tenant 名称解析。
properties:
database:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
instanceRef:
description: InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group
引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
required:
- name
type: object
loginRole:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
required:
- instanceRef
type: object
secretName:
description: SecretName 指定 Tenant namespace 内的投射目标,省略时使用合同约定的默认名称。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
type: object
x-kubernetes-validations:
- message: exactly one of provision and databaseRef is required
rule: has(self.provision) != has(self.databaseRef)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
credentialURL:
description: CredentialURL 只含 OpenBao API 位置,禁止嵌入认证信息。
type: string
databaseRef:
description: DatabaseRef 只有在资源侧确认绑定后才写入。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
uid:
description: |-
UID is a type that holds unique ID values, including UUIDs. Because we
don't ONLY use UUIDs, this is an alias to string. Being a type captures
intent and helps make sure that UIDs and names do not get conflated.
maxLength: 128
minLength: 1
type: string
required:
- name
- uid
type: object
observedGeneration:
format: int64
type: integer
phase:
type: string
secretName:
description: SecretName 是已观察到的同 namespace 投射目标,不包含凭据值。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
type: object
required:
- spec
type: object
x-kubernetes-validations:
- message: binding target cannot change after binding starts
rule: '!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase
in [''Binding'', ''Bound'', ''Deleting'']) || ((has(self.spec.provision)
== has(oldSelf.spec.provision)) && (!has(oldSelf.spec.provision) || self.spec.provision
== oldSelf.spec.provision) && (has(self.spec.databaseRef) == has(oldSelf.spec.databaseRef))
&& (!has(oldSelf.spec.databaseRef) || self.spec.databaseRef == oldSelf.spec.databaseRef))'
- message: binding progress cannot return to an unbound state
rule: '!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase
in [''Binding'', ''Bound'', ''Deleting'']) || (has(self.status) && has(self.status.phase)
&& self.status.phase in [''Binding'', ''Bound'', ''Deleting''])'
served: true
storage: true
subresources:
status: {}
+3
View File
@@ -2,6 +2,9 @@
# since it depends on service name and namespace that are out of this kustomize package. # since it depends on service name and namespace that are out of this kustomize package.
# It should be run by config/default # It should be run by config/default
resources: resources:
- bases/database.ayatori.ddupan.top_postgresqlinstances.yaml
- bases/database.ayatori.ddupan.top_postgresqldatabases.yaml
- bases/database.ayatori.ddupan.top_postgresqltenants.yaml
- bases/execution.ayatori.ddupan.top_jobs.yaml - bases/execution.ayatori.ddupan.top_jobs.yaml
- bases/execution.ayatori.ddupan.top_jobclasses.yaml - bases/execution.ayatori.ddupan.top_jobclasses.yaml
- bases/execution.ayatori.ddupan.top_kubernetesexecutionparameters.yaml - bases/execution.ayatori.ddupan.top_kubernetesexecutionparameters.yaml
+5
View File
@@ -65,6 +65,11 @@ spec:
- --health-probe-bind-address=:8081 - --health-probe-bind-address=:8081
image: controller:latest image: controller:latest
name: manager name: manager
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
ports: ports:
- containerPort: 8081 - containerPort: 8081
name: health name: health
@@ -0,0 +1,23 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: database-management-credentials
namespace: system
rules:
- apiGroups: [""]
resources: [secrets]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: database-management-credentials
namespace: system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: database-management-credentials
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+1
View File
@@ -7,6 +7,7 @@ resources:
- service_account.yaml - service_account.yaml
- role.yaml - role.yaml
- role_binding.yaml - role_binding.yaml
- database_credentials_role.yaml
- leader_election_role.yaml - leader_election_role.yaml
- leader_election_role_binding.yaml - leader_election_role_binding.yaml
# The following RBAC configurations are used to protect # The following RBAC configurations are used to protect
+41 -6
View File
@@ -1,11 +1,46 @@
---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole kind: ClusterRole
metadata: metadata:
labels:
app.kubernetes.io/name: ayatori
app.kubernetes.io/managed-by: kustomize
name: manager-role name: manager-role
rules: rules:
- apiGroups: [""] - apiGroups:
resources: ["pods"] - database.ayatori.ddupan.top
verbs: ["get", "list", "watch"] resources:
- postgresqldatabases
verbs:
- create
- get
- list
- patch
- update
- watch
- apiGroups:
- database.ayatori.ddupan.top
resources:
- postgresqldatabases/finalizers
- postgresqlinstances/finalizers
- postgresqltenants/finalizers
verbs:
- update
- apiGroups:
- database.ayatori.ddupan.top
resources:
- postgresqldatabases/status
- postgresqlinstances/status
- postgresqltenants/status
verbs:
- get
- patch
- update
- apiGroups:
- database.ayatori.ddupan.top
resources:
- postgresqlinstances
- postgresqltenants
verbs:
- get
- list
- patch
- update
- watch
@@ -0,0 +1,15 @@
# 管理员登记已有数据库;不会因创建 CR 就修改数据库或凭据。
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLDatabase
metadata:
name: imported-app
spec:
instanceRef:
name: shared-postgres
database: existing_app
loginRole: existing_app
source: Import
credentialRef:
mount: secret
path: existing/app/postgresql
reclaimPolicy: Retain
@@ -0,0 +1,11 @@
# Instance 观察 controller 尚未接入;管理 Secret 由管理员在 controller namespace 提供。
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLInstance
metadata:
name: shared-postgres
spec:
endpoint:
host: postgres.example.test
hostaddr: 192.0.2.10
adminCredentialRef:
name: shared-postgres-admin
@@ -0,0 +1,25 @@
# 二选一:动态申请或显式引用已有 Database;当前只有绑定协调,没有供应/交付 controller。
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLTenant
metadata:
name: new-app
namespace: default
spec:
provision:
instanceRef:
name: shared-postgres
database: new_app
loginRole: new_app
extensions:
- pgcrypto
secretName: new-app-postgresql
---
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLTenant
metadata:
name: existing-app
namespace: default
spec:
databaseRef:
name: imported-app
secretName: existing-app-postgresql
+3
View File
@@ -1,5 +1,8 @@
## Append samples of your project ## ## Append samples of your project ##
resources: resources:
- database_v1alpha1_postgresqlinstance.yaml
- database_v1alpha1_postgresqldatabase.yaml
- database_v1alpha1_postgresqltenant.yaml
- execution_v1alpha1_job.yaml - execution_v1alpha1_job.yaml
- execution_v1alpha1_jobclass.yaml - execution_v1alpha1_jobclass.yaml
- execution_v1alpha1_kubernetesexecutionparameters.yaml - execution_v1alpha1_kubernetesexecutionparameters.yaml
+13
View File
@@ -1,5 +1,18 @@
# 环境与发布 # 环境与发布
## CI 验证入口
Verify 工作流在 PR 上执行全量测试、lint 和 Database 集成测试;合并到 main 后不通过 push
事件重复运行。需要排障或验证直接推送的紧急修复时,可通过 workflow_dispatch 手动运行。
此约定不减少检查项目,也不修改分支保护设置;常规变更必须经过 PR,直接推送 main 不会自动验证。
Gitea 的 PR 工作流验证分支 head,而不是合并预览提交,见
[官方事件说明](https://docs.gitea.com/usage/actions/faq/)。合并前必须确认最新 head 检查通过,
且与当前 main 合并不会引入未经验证的内容组合;基线有实质变化时先更新分支并重验。
只改变基线引用且目标文件树不变时,不需要为了合并提交的 SHA 不同重复全量验证。
## 环境与制品晋级
Ayatori 首先建立 Dev。首个产品能力完成开发并达到可发布状态前,Prod 不实际存在;此时 Ayatori 首先建立 Dev。首个产品能力完成开发并达到可发布状态前,Prod 不实际存在;此时
没有生产制品需要承载,提前维护第二套环境没有收益。 没有生产制品需要承载,提前维护第二套环境没有收益。
+54 -11
View File
@@ -1,7 +1,7 @@
# Database 模块 # Database 模块
Database 是 Ayatori 首批实际产品领域之一。当前已包含 Instance 领域基础、管理凭据连接与 Database 是 Ayatori 首批实际产品领域之一。当前已包含三资源 API、分层绑定与 Instance 原生
metadata 观察切片,尚未完成 Database API/controller 和 Tenant 供应链路。 管理能力观测;尚未完成 Database 供应/导入、Tenant 凭据交付与资源回收链路。
## 当前设计(2026-09-24) ## 当前设计(2026-09-24)
@@ -12,7 +12,7 @@ Retain 后人工重新绑定与资源侧 Delete。撤销 PostgreSQL ownership re
依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见 依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见
[系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。 [系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。
registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理 registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理
能力观察直接判定 Ready。新增 Database 资源、绑定、导入、角色/凭据管理边界与回收链路尚未实现。wiki 同步位置见 能力观察直接判定 Ready。Database 资源与绑定已接入,导入、角色/凭据供应及回收仍未完成。wiki 同步位置见
`homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。 `homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。
## 来源基线 ## 来源基线
@@ -59,10 +59,10 @@ Secret metadata 和无关字段变化不重建连接。观测后再次读取 Sec
不把旧连接的成功作为新凭据有效的证据;这不构成跨 Kubernetes/PostgreSQL 的原子事务。 不把旧连接的成功作为新凭据有效的证据;这不构成跨 Kubernetes/PostgreSQL 的原子事务。
Instance UID、endpoint 或凭据引用变化也会释放旧连接;Forget/Close 只释放本地资源。 Instance UID、endpoint 或凭据引用变化也会释放旧连接;Forget/Close 只释放本地资源。
当前通过 `ObserveMetadata` 读取服务器版本和可用扩展,`ObserveVersion` 只是其版本读取便捷入口, `ObserveMetadata` 读取服务器版本和可用扩展,`ObserveVersion` 是版本读取便捷入口;两者
不能产生完整 CapabilityObservation 或 Ready。 不会填充管理检查,不能产生 Ready。`ObserveManagement` 使用同一凭据/连接边界读取完整
controller 接入、Secret watch、finalizer 与真实权限检查仍待后续切片;并发 CR 更新 原生管理检查,返回绑定当前 target 的 `InstanceObservation`。controller 的资源呈现适配器
必须由调用者通过 resourceVersion 校验。应用层沿用源实现的串行处理,本阶段未引入新的调度框架。 使用 resourceVersion 拒绝过期写入。应用层沿用串行处理,不引入新的调度框架。
运行 `make test-database-integration` 验证真实 API server + 一次性 PostgreSQL;fixture 不接受外部 运行 `make test-database-integration` 验证真实 API server + 一次性 PostgreSQL;fixture 不接受外部
DSN,镜像固定摘要,使用随机本机回环端口并在退出时删除测试容器。覆盖缺失/错误凭据、RBAC、 DSN,镜像固定摘要,使用随机本机回环端口并在退出时删除测试容器。覆盖缺失/错误凭据、RBAC、
@@ -83,14 +83,13 @@ SQL adapter 通过一条只读语句读取 `pg_catalog.current_setting('server_v
`InstanceService` 保留原有 CredentialReader → Connector → Database 边界,复用同一个 `InstanceService` 保留原有 CredentialReader → Connector → Database 边界,复用同一个
凭据读取、连接刷新和串行释放流程,不新增连接池封装或任意查询回调。每次重新查询 metadata, 凭据读取、连接刷新和串行释放流程,不新增连接池封装或任意查询回调。每次重新查询 metadata,
并在 Secret 有效值回读一致后生成不可变的 `MetadataObservation`,绑定本次 target(含当前 并在 Secret 有效值回读一致后生成不可变的 `InstanceObservation`,绑定本次 target(含当前
generation),不绑定建池时的旧 target。结果不包含凭据,扩展集合不与 driver 的可变 slice 共享。 generation),不绑定建池时的旧 target。结果不包含凭据,扩展集合不与 driver 的可变 slice 共享。
凭据中途变化、读取失败或查询失败时,返回零值观察并释放连接,不复用旧的扩展列表。 凭据中途变化、读取失败或查询失败时,返回零值观察并释放连接,不复用旧的扩展列表。
调用方可将 `Target()` 与 `Extensions()` 交给 Instance 的 `ObserveExtensions`;应用调用链 调用方可将 `Target()` 与 `Extensions()` 交给 Instance 的 `ObserveExtensions`;应用调用链
仍负责同轮次使用,不能持久化或跨轮缓存这份证据。metadata 读取不安装扩展、不初始化 registry、 仍负责同轮次使用,不能持久化或跨轮缓存这份证据。metadata 读取不安装扩展、不初始化 registry、
不设置 Ready,也不授予 Tenant 写权限。管理权限矩阵及 controller 的 不设置 Ready,也不授予 Tenant 写权限。管理权限检查使用下面的独立入口。
checkpoint/status/finalizer 链路仍是后续切片。
真实 API server + PostgreSQL 测试验证未安装扩展可被观察、名称保持大小写、search_path 遮蔽 真实 API server + PostgreSQL 测试验证未安装扩展可被观察、名称保持大小写、search_path 遮蔽
不改变查询来源、低权限账号读取、权限撤回失败与恢复、Secret 中途变化丢弃扩展结果。 不改变查询来源、低权限账号读取、权限撤回失败与恢复、Secret 中途变化丢弃扩展结果。
@@ -106,7 +105,51 @@ Instance 不再具有 InitializingRegistry 阶段、RegistryState、准备决策
保留凭据读取与连接刷新、TLS、metadata/扩展观察及其真实后端测试。领域测试覆盖每项能力 保留凭据读取与连接刷新、TLS、metadata/扩展观察及其真实后端测试。领域测试覆盖每项能力
在初次验证和 Ready 重验时失败、依赖恢复、重启后重新取证、错误目标/阶段及删除保护。 在初次验证和 Ready 重验时失败、依赖恢复、重启后重新取证、错误目标/阶段及删除保护。
这不等于管理权限探测矩阵或三资源 controller 已实现。 这不等于三资源供应、交付和回收已实现。
## Instance 原生管理观测
2026-09-25 维护者确认先使用原生非 superuser 方案,不引入 SECURITY DEFINER 接口。
`InspectManagement` 通过同一条只读语句读取当前执行角色的 `CREATEROLE`、`CREATEDB`、
superuser 属性、服务器可写状态、版本和扩展列表。不创建探针数据库/角色,不初始化 schema。
只有非 superuser、具备两项原生属性且当前服务器/会话可写时,基础管理能力才通过。
角色属性不可从继承成员关系推导;具体已有资源仍须检查 owner、membership 与授权范围。
权限依据和真实测试对应:
- role:当前角色具有 CREATEROLE,可创建普通登录角色;
- database:当前角色具有 CREATEDB,且会话非只读、服务器不在 recovery;
- grant:使用自己新建角色的管理权限,显式建立 SET membership,再以 owner 管理数据库 ACL;
- extension:新建数据库 owner 可安装 trusted 扩展;可用列表不是安装授权,非 trusted 或
其他前提不满足的扩展仍可能失败,必须逐请求执行和回读。导入不继承此动态供应授权。
参考 PostgreSQL 官方 [CREATE ROLE](https://www.postgresql.org/docs/18/sql-createrole.html)、
[CREATE DATABASE](https://www.postgresql.org/docs/18/sql-createdatabase.html) 与
[CREATE EXTENSION](https://www.postgresql.org/docs/18/sql-createextension.html)。这些检查是基础
能力观察,不是未来操作必然成功的保证;权限、容量、连接数等仍可能在执行时变化。
`InstanceReconciliation` 协调 finalizer、观察、领域判定和删除引用检查,controller 只连接
事件、用例、状态呈现与重试。每轮重建无证据的领域对象;旧 Ready 不授权新一轮操作。
失败清除当前版本结果并撤销 Ready;CR 在观察期间被修改则拒绝旧结果,下一轮重新读取。
连接/权限变化由 Secret watch 和 30 秒重查驱动,单轮 IO 最长 15 秒;不持续写入相同状态。
manager 通过 `--database-secret-namespace`(默认 `POD_NAMESPACE`)启用 Instance 观测;
为空时不启用。本地运行需显式提供该参数。Deployment 使用 downward API 获取自身 namespace;
Secret 的 get/list/watch 权限由该 namespace 的 Role 单独授予,不放入 ClusterRole。
watch 使用 controller-runtime 的 metadata-only cache,读取有效凭据仍直连 API server。
TLS 使用既有 endpoint 合同,公开 CA bundle 可由 `--database-root-cert` 指定;不会自动挂载
生产证书或创建管理 Secret。manager worker 停止后统一关闭 pgxpool。
Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Database(含 Released、删除中)
或动态 Tenant 申请都会阻止 finalizer 解除;列表查询失败也等待。仅在引用全部解除后移除
`database.ayatori.ddupan.top/instance-protection`,不删除 PostgreSQL、账号或凭据。
引用查询与删除不是跨对象事务;后续供应仍必须拒绝已删除/删除中的 Instance。
验收使用真实 PostgreSQL + API server:原生管理账号实际建库、owner 授权、trusted 扩展
安装/回读,拒绝非 trusted 扩展;权限撤回/恢复、只读会话、superuser 拒绝和中途轮换。
实际 manager 在生成的资源 RBAC 和 namespaced Secret Role 下验证缺失 Secret 后出现、
轮换、删除、跨 namespace 拒绝和 watch。API 测试另覆盖写入版本冲突、幂等、新 reconciler
恢复与引用删除保护。完整 DBaaS 仍需供应/导入、OpenBao/ESO、Retain/Delete 集成验收。
## 设计入口 ## 设计入口
+95 -13
View File
@@ -2,17 +2,72 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 三资源模型已批准;新增字段与绑定协议待评审 | | 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 |
| API group/version | `database.ayatori.ddupan.top/v1alpha1` | | API group/version | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-24 | | 最后更新 | 2026-09-25 |
以 [系统规格](specification.md) 与 以 [系统规格](specification.md) 与
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。尚未实现新 API, [ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码,
本页不提供可直接 apply 的三资源 YAML,以免把工作名称和未决字段当作已发布合同。 尚未发布为可用 DBaaS。示例可进入绑定协调,但不代表创建对象后会供应数据库或交付凭据。
## 当前 API 切片
Go 类型位于 `api/database/v1alpha1`,CRD 随 `config/crd` 发布;manager 已注册 Scheme 和
`internal/database/controller` 的绑定 controller。以下字段是本切片的具体实现:
| 资源 | 字段 | 含义 |
| --- | --- | --- |
| Database | `spec.instanceRef.name` | 所属集群级 Instance |
| Database | `spec.database`、`spec.loginRole` | 实际数据库与唯一登录 owner,必填 |
| Database | `spec.source` | 必填 `Provision` 或 `Import`,不隐式认领 |
| Database | `spec.credentialRef.mount/path` | Import 必填的已有 KV v2 凭据位置;Provision 禁止指定 |
| Database | `spec.reclaimPolicy` | Retain 默认或 Delete |
| Database | `spec.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 |
| Database | `status.instanceUID` | 观察时的 Instance 身份 |
| Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 |
| Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 |
| Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance |
| Tenant | `spec.extensions`、`spec.secretName` | 扩展集合与同 namespace 的交付目标 |
| Tenant | `status.databaseRef.name/uid` | 资源侧绑定成功后写入 |
| Tenant | `status.secretName`、`status.credentialURL` | 交付观察,不包含密码或认证信息 |
三资源均有 status subresource、observedGeneration 和按 type 唯一的 Conditions。
Instance phase 沿用已批准枚举;Database/Tenant phase 暂不冻结供应子阶段枚举。
`credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。
其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。
示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、
[导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、
[动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。
当前 schema 验证名称、端口、IP、TLS 枚举、申请互斥、导入凭据要求和绑定 UID 完整性。
API 接受两个 Tenant 引用同一 Database 不表示允许双重绑定;排他绑定由 controller 协调。
绑定 controller 解析动态 database/loginRole 的 Tenant 名称默认值;动态资源 CR 名称为
`tenant-<Tenant UID>`,首次创建即包含资源侧绑定与 finalizer,不带 Tenant ownerReference。
已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。
同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。
Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察或
绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。
读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。
绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照
映射、finalizer 和状态呈现。呈现前若资源版本已变化,返回冲突供下一轮重读,不覆盖其他修改。
双向记录完成后 Tenant 为 Bound,Ready=False/BindingComplete,明确尚未供应或交付。
生成的 manager ClusterRole 授予资源读写,不包含 Secret 读取;Instance 观测的管理 Secret
权限由固定 namespace 的独立 Role 授予。Instance controller 已接入原生管理观察与引用删除
保护,启用方式及 Ready 边界见 [模块说明](README.md#instance-原生管理观测)。
当前有 Tenant/Database finalizer 保护,但**删除清理尚未实现**:Tenant 删除报告
Ready=False/DeletionPending 并保留绑定与 finalizer,Database 的保护也不会被自动移除。
还未实现删除流程开始后的回收策略固定、Retain 释放、Released 重新开放、外部清理、
扩展只追加、Secret 默认名称解析与凭据交付。在后续清理协议和真实后端验收完成前,
不能作为可用 DBaaS 部署,也不能通过强行移除 finalizer 把它视为已完成清理。
## 通用约定 ## 通用约定
- Instance 是 cluster-scoped;Tenant 是 namespaced;Database scope 待字段评审。 - Instance 与 Database 是 cluster-scoped;Tenant 是 namespaced。
- Tenant 按名称引用 Database,不提供 Database namespace;Database 绑定记录包含
Tenant namespace/name/UID。字段见当前 API 切片;绑定采用下述资源侧先写顺序。
- 每类资源提供唯一的 Ready Condition、observedGeneration;phase 用于进度展示, - 每类资源提供唯一的 Ready Condition、observedGeneration;phase 用于进度展示,
不能单独作为写权限或所有权证明。 不能单独作为写权限或所有权证明。
- 引用必须区分定位名称与已绑定 UID;同名新对象不继承绑定。 - 引用必须区分定位名称与已绑定 UID;同名新对象不继承绑定。
@@ -57,21 +112,30 @@ Tenant 引用。无引用才解除,不级联删除资源;查询失败不能
## Database 资源(工作 Kind:PostgreSQLDatabase) ## Database 资源(工作 Kind:PostgreSQLDatabase)
以下是字段职责,不是已批准的 JSON schema: v1alpha1 以一个 database、一个兼任 owner 的 login role 及其应用凭据作为 Database 的
生命周期边界;不提供多账号字段或独立 Role/Credential CRD。多账号需求留待后续 API 版本。
此决定不改变导入只读验证和显式管理授权的要求。
Database 是平台管理的集群级资源,不归属于应用 namespace,也不需要资源专用 namespace。
普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放;
这些资源管理操作由平台管理员授权。controller 的绑定协调权限与用户申请权限分别配置。
以下是行为合同,具体 schema 见当前 API 切片与生成的 CRD;后端行为尚未实现:
| 内容 | 合同 | | 内容 | 合同 |
| --- | --- | | --- | --- |
| `instanceRef` | Database 自身必填;定位来源并记录绑定的 Instance UID,不依赖 Tenant 补齐 | | `instanceRef` | Database 自身必填;定位来源并记录绑定的 Instance UID,不依赖 Tenant 补齐 |
| 外部目标 | 实际 database 名称及已确认的管理范围;操作开始后不可隐式改目标 | | 外部目标 | 实际 database 名称及已确认的管理范围;操作开始后不可隐式改目标 |
| 来源 | 区分动态供应与管理员显式导入;不能从同名存在推断导入授权 | | 来源 | 区分动态供应与管理员显式导入;不能从同名存在推断导入授权 |
| 申请预留/绑定 | 至多一个 Tenant,含 namespace/name/UID;Released 保留旧身份 | | 绑定 | 至多一个 Tenant,含 namespace/name/UID;Released 保留旧身份;无允许绑定名单 |
| 回收策略 | 资源侧 Retain(默认)或显式 Delete;普通 Tenant editor 不得扩大授权 | | 回收策略 | 资源侧 Retain(默认)或显式 Delete;普通 Tenant editor 不得扩大授权 |
| 观察与进度 | 实际目标、当前阶段、条件和安全诊断,不保存秘密 | | 观察与进度 | 实际目标、当前阶段、条件和安全诊断,不保存秘密 |
概念生命周期包含供应/验证、可绑定、已绑定、Released 和删除;最终 phase 枚举待协议评审。 概念生命周期包含供应/验证、可绑定、已绑定、Released 和删除;最终 phase 枚举待协议评审。
Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用中的资源受删除保护。 Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用中的资源受删除保护。
导入的初始检查只读;存在不等于 Ready,也不等于有权交付给任意 Tenant。 导入的初始检查只读;存在不等于 Ready。未绑定且可用的资源允许 Tenant 显式申请,
不要求管理员逐 Tenant 授权;已占用或 Released 的资源不能直接绑定。
默认保留不隐含密码、owner、授权或删除的变更许可。 默认保留不隐含密码、owner、授权或删除的变更许可。
## PostgreSQLTenant ## PostgreSQLTenant
@@ -90,16 +154,34 @@ Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用
仅按 Tenant namespace/name 派生凭据路径的规则不再是实现合同。已有代码没有兼容负担, 仅按 Tenant namespace/name 派生凭据路径的规则不再是实现合同。已有代码没有兼容负担,
不保留两套相互覆盖的策略字段。 不保留两套相互覆盖的策略字段。
## 绑定协议评审要求 ## 已确认的绑定顺序
1. 动态申请按 Tenant UID 确定 Database 名称并创建记录;已有资源申请使用指定的 Database,
检查资源未绑定且可用,不增加反向授权名单。动态创建重试遇到同名记录时需核对身份与目标。
2. 使用 resourceVersion 并发控制,先在 Database 记录 Tenant namespace/name/UID。
已绑定其他 Tenant 时报告 Conflict,不抢占;版本冲突后重新读取、重新判断。
3. 在 Tenant status 记录 Database name/UID。若上一步成功、本步失败,后续 reconcile
核对身份后补齐,不回滚已经成功的资源侧绑定。
4. 双向记录一致才允许动态供应或凭据交付;实际数据库与凭据验证通过后才可 Ready。
此顺序借鉴 PV/PVC 的资源侧先写模式,行为依据见
[系统规格](specification.md#5-动态供应与排他绑定)。Retain 释放时保留旧绑定身份并进入
Released,不自动清空后重新分配。API 记录部分写入由 reconcile 重试;外部创建结果
无法确认时仍报告 Conflict,交给人工,不新增事务队列或 registry。
## 绑定协议剩余评审要求
实现前必须明确: 实现前必须明确:
1. 资源 scope 与 typed reference 格式,管理员预留/导入与普通申请者的 RBAC 边界。 1. 引用字段的最终格式,以及落实管理员资源管理、controller 协调与普通申请权限的 RBAC 规则。
2. 资源侧排他记录的写入点、双向绑定顺序、API 冲突重试与单边完成恢复。 2. 绑定记录的最终字段及校验规则,落实上述写入顺序与恢复行为。
3. Tenant UID 变化、对象删除、Released 旧引用与管理员重新授权的判断。 3. Tenant UID 变化、对象删除、Released 旧引用与管理员重新授权的判断。
4. 同一物理数据库重复登记的冲突处理;列表查询不是原子认领。 4. 同一物理数据库重复登记的冲突处理;列表查询不是原子认领。
5. Database 的 role/凭据管理范围、稳定凭据定位、旧访问处置与投射清理顺序。 5. 已有凭据关联字段、旧访问处置与投射清理顺序;动态凭据路径已确定按 Database UID 定位。
6. 删除开始后的策略固定点和 Tenant/Database finalizer 配合。 6. Tenant/Database finalizer 配合;回收策略默认 Retain,删除流程前可改,进入后固定。
资源管理者设置 Delete 即表示删除授权,不增加额外审批字段。导入显式关联已有凭据;
Released 不自动改密,管理员处理旧访问后才重新开放。Tenant 不得自选任意 OpenBao 路径。
该协议使用 Kubernetes API 持久化,不为它新增 PostgreSQL registry。 该协议使用 Kubernetes API 持久化,不为它新增 PostgreSQL registry。
未完成一致绑定不得供应或交付;外部创建结果不确定按 Conflict 人工处理。 未完成一致绑定不得供应或交付;外部创建结果不确定按 Conflict 人工处理。
+16 -2
View File
@@ -18,7 +18,20 @@
需故障注入外部成功而 API 写入失败、后端响应丢失、双 Tenant 竞争、同名新 UID、依赖稍后出现、 需故障注入外部成功而 API 写入失败、后端响应丢失、双 Tenant 竞争、同名新 UID、依赖稍后出现、
Instance 删除与 Released 引用。冲突必须给出可操作而不泄密的诊断;不要求自动认领不确定结果。 Instance 删除与 Released 引用。冲突必须给出可操作而不泄密的诊断;不要求自动认领不确定结果。
envtest 不运行 GC 或 ESO;这些行为必须由测试集群验证。 envtest 不运行 GC 或 ESO;这些行为必须由测试集群验证。
新增资源 API 尚未实现,本轮没有完成或运行这些新增行为测试。 三资源 API 类型与 CRD 已实现,`make test` 包含真实 API server 验证:作用域、
静态默认值、非法声明、status 写入隔离、Condition 唯一性、resourceVersion 冲突与绑定记录回读。
另有绑定 controller 的真实 API server 测试:动态记录幂等、资源侧写入后故障注入、
新 reconciler 回读补齐、双 Tenant 竞争、Released/旧 UID 拒绝、目标固定、陈旧观察、
删除期间 finalizer 保留。实际 manager 在生成的 RBAC 角色下通过 watch/cache 处理依赖
稍后出现,绑定角色不具备 Secret 读取权限。没有 PostgreSQL/OpenBao 写入;后端 Ready
在测试中由 fixture 提供,不能把它当成完整 Instance/Database 观察验证。
Retain 释放和 Delete 清理仍未实现,当前删除会保持 DeletionPending 与 finalizer。
真实后端供应、凭据交付与上述完整删除矩阵仍待后续切片验收。
绑定规则另有不依赖 Kubernetes 的单元测试;service 测试只验证操作顺序、失败停止与最终
身份回读,不模拟 API server。原真实 API controller 测试覆盖完整分层调用,另验证 service
返回后发生并发修改时,资源呈现拒绝过期结果,重试完成绑定且保留其他字段。
## Ayatori 已接入的凭据与 metadata 切片测试 ## Ayatori 已接入的凭据与 metadata 切片测试
@@ -53,7 +66,8 @@ metadata 测试验证版本与可用扩展的只读查询,包括未安装扩
Instance 领域测试不再提供 registry 状态,初次验证与 Ready 重验分别覆盖所有管理检查项的 Instance 领域测试不再提供 registry 状态,初次验证与 Ready 重验分别覆盖所有管理检查项的
未观察、不可用、认证失败、权限不足及未知值,并验证依赖恢复;完整管理观察可直接 Ready。 未观察、不可用、认证失败、权限不足及未知值,并验证依赖恢复;完整管理观察可直接 Ready。
这些测试尚不包含 Instance CRD/controller、Secret watch、status/finalizer 事件链、权限探测矩阵、ESO 或 Tenant 供应。版本查询成功不意味着 Instance Ready。 PostgreSQL adapter 测试不包含 controller、Secret watch、status/finalizer 事件链、权限探测矩阵、ESO 或 Tenant 供应。
CRD 基础语义由前述 API 测试覆盖;版本查询成功不意味着 Instance Ready。
本项目同时依赖 Kubernetes API、PostgreSQL、OpenBao 和 ESO。日常开发不连接 homelab 本项目同时依赖 Kubernetes API、PostgreSQL、OpenBao 和 ESO。日常开发不连接 homelab
中的真实服务:Kubernetes 使用 envtest 或一次性 Kind,另外两个依赖使用一次性 中的真实服务:Kubernetes 使用 envtest 或一次性 Kind,另外两个依赖使用一次性
+5 -3
View File
@@ -1,8 +1,8 @@
# Instance 领域对象规格 # Instance 领域对象规格
日期:2026-09-24。资源模型修订依据 日期:2026-09-25。资源模型修订依据
[ADR-0009](../decisions/0009-database-resource-and-claim.md),行为以 [ADR-0009](../decisions/0009-database-resource-and-claim.md),行为以
[系统规格](specification.md) 为准。本页替代原 registry 准备与恢复合同;领域依赖已撤除,完整应用/controller 链路尚未接入。 [系统规格](specification.md) 为准。本页替代原 registry 准备与恢复合同;领域依赖已撤除,Instance 应用/controller 观测链路已接入。
## 职责 ## 职责
@@ -85,4 +85,6 @@ finalizer 不阻止并发申请 CR 创建;新请求见 Instance 删除中/不
Instance 领域代码、adapter 与测试的 registry 依赖已撤除。AssessManagement 根据完整观察 Instance 领域代码、adapter 与测试的 registry 依赖已撤除。AssessManagement 根据完整观察
直接完成验证;AssessReadiness 失败进入 Validating,依赖恢复后重新验证。领域测试覆盖各检查项 直接完成验证;AssessReadiness 失败进入 Validating,依赖恢复后重新验证。领域测试覆盖各检查项
在这两个入口的失败与恢复,但完整权限探测、Instance controller 和三资源生命周期尚未完成。 在这两个入口的失败与恢复。原生权限检查、Instance controller、metadata-only Secret watch
和引用删除保护已接入,验证矩阵见 [模块说明](README.md#instance-原生管理观测);
Database/Tenant 的供应、交付和回收仍未完成。
+27 -9
View File
@@ -1,6 +1,6 @@
# Database 领域模型 # Database 领域模型
状态:资源模型已确认,字段与绑定协议待细化。日期:2026-09-24。 状态:资源模型已确认,字段与绑定协议待细化。日期:2026-09-25。
行为以 [系统规格](specification.md) 为准;决策依据见 行为以 [系统规格](specification.md) 为准;决策依据见
[ADR-0009](../decisions/0009-database-resource-and-claim.md)。 [ADR-0009](../decisions/0009-database-resource-and-claim.md)。
@@ -19,6 +19,10 @@
Instance 一对多 Database;每个 Database 同时零或一个 Tenant,Tenant 最多一个 Database。 Instance 一对多 Database;每个 Database 同时零或一个 Tenant,Tenant 最多一个 Database。
Instance 不持有全部资源的内存集合。三者以引用关联,操作一个资源无需加载整个实例集合。 Instance 不持有全部资源的内存集合。三者以引用关联,操作一个资源无需加载整个实例集合。
Instance 与 Database 是集群级资源,Tenant 位于 namespace。Tenant 按名称引用 Database,
Database 记录所绑定 Tenant 的 namespace/name/UID。Database 不属于应用 namespace;
平台管理员管理资源及回收策略,普通申请者不能自行将 Released 资源重新开放。
Database 的 instanceRef 表达资源归属,手工登记时也必须提供,不从 Tenant 反推。 Database 的 instanceRef 表达资源归属,手工登记时也必须提供,不从 Tenant 反推。
Tenant 动态申请才选择 Instance;引用已有 Database 时使用资源声明的 Instance。 Tenant 动态申请才选择 Instance;引用已有 Database 时使用资源声明的 Instance。
释放使用绑定不改变 Database 的实例归属,修改引用不能实现外部数据库迁移。 释放使用绑定不改变 Database 的实例归属,修改引用不能实现外部数据库迁移。
@@ -35,19 +39,26 @@ registry。管理凭据来源和连接刷新由应用层协调,连接池由 pg
Database 保护目标和管理范围、排他绑定、导入验证与 Retain/Delete 规则。资源首次外部操作前 Database 保护目标和管理范围、排他绑定、导入验证与 Retain/Delete 规则。资源首次外部操作前
必须已有持久记录;完成记录与外部存在性分别检查。数据库名称不是归属证明。 必须已有持久记录;完成记录与外部存在性分别检查。数据库名称不是归属证明。
Tenant 表达申请与交付要求;显式选择已有资源不自动授权使用。绑定后检查数据库满足要求、 Tenant 表达申请与交付要求;可显式申请未绑定且可用的资源,不增加反向授权名单。
绑定后检查数据库满足要求、
应用凭据可登录且 ESO 投射完成,才可 Ready。Tenant 删除意味着释放使用关系。 应用凭据可登录且 ESO 投射完成,才可 Ready。Tenant 删除意味着释放使用关系。
LoginRole 与 CredentialLocation 的生命周期必须随独立资源保留,不能因为 Tenant 消失就 第一版 Database 的生命周期边界包含一个 database、一个兼任 owner 的 LoginRole 及其
失去定位或未经授权被删除;具体字段归属及导入时的管理范围需继续评审。不要因此新增 Role、 应用凭据。LoginRole 与 CredentialLocation 随 Database 保留,不能因为 Tenant 消失就
Credential 或 Claim CRD。当前首版仍是单 database + 单 login owner。 失去定位或未经授权被删除;导入时的管理授权与具体字段仍需评审。不新增 Role、Credential
或 Claim CRD,也不预留多账号集合;若出现一库多账号的实际需求,再通过后续 API 版本演进。
## 生命周期与恢复 ## 生命周期与恢复
- 动态创建与显式导入最终形成同一种 Database 资源,但导入本身不允许改密、改 owner 或删除。 - 动态创建与显式导入最终形成同一种 Database 资源,但导入本身不允许改密、改 owner 或删除。
- Retain 后 Database 保持 Released 与旧绑定身份,人工确认数据、权限和凭据后才可重新绑定。 - Retain 后 Database 保持 Released 与旧绑定身份,人工确认数据、权限和凭据后才可重新绑定。
- 回收策略属于资源侧;Tenant 与 Database 不是可随申请级联 GC 的父子关系。 - 回收策略属于资源侧;Tenant 与 Database 不是可随申请级联 GC 的父子关系。
- 回收策略默认 Retain,进入删除流程前可由资源管理者修改,进入后固定;Delete 无额外审批。
- 动态凭据位置按 Database UID 确定,导入显式关联已有凭据;Released 不自动改密。
- 绑定 UID 防止同名新申请继承权限。双向记录的单边写入不代表绑定完成。 - 绑定 UID 防止同名新申请继承权限。双向记录的单边写入不代表绑定完成。
- 动态 Database 名称由 Tenant UID 确定;先持久化资源侧 Tenant 引用,再更新 Tenant status
的 Database 引用。后一写入失败由 reconcile 核对身份后补齐,不回滚资源侧记录;
其他 Tenant 已占用则报冲突。双向一致后才供应或交付,绑定不等于 Ready。
- 普通失败按 reconcile 重试;可靠确认的步骤幂等继续;不确定创建/未知同名对象报告 Conflict。 - 普通失败按 reconcile 重试;可靠确认的步骤幂等继续;不确定创建/未知同名对象报告 Conflict。
- Kubernetes status 是持久进度和观察,不是外部事实,也不是 controller 内存。 - Kubernetes status 是持久进度和观察,不是外部事实,也不是 controller 内存。
不引入“status 任意丢失后自动恢复所有权”的附加要求。 不引入“status 任意丢失后自动恢复所有权”的附加要求。
@@ -58,18 +69,25 @@ Credential 或 Claim CRD。当前首版仍是单 database + 单 login owner。
| --- | --- | | --- | --- |
| 领域 | 值、身份、允许动作、不变量、完成与冲突判定;不做 IO | | 领域 | 值、身份、允许动作、不变量、完成与冲突判定;不做 IO |
| 应用 | 装载记录与事实、协调 API 更新和 adapter、回读、交回领域判定 | | 应用 | 装载记录与事实、协调 API 更新和 adapter、回读、交回领域判定 |
| controller | watch/调度、映射、conditions/status/finalizer;不重写领域规则 | | controller | watch/调度、调用用例、请求资源呈现与安排重试;不判断绑定资格 |
| adapter | Kubernetes、PostgreSQL、OpenBao、ESO 的具体访问与安全错误分类 | | adapter | Kubernetes 资源映射与呈现(含 conditions/status/finalizer)、后端访问与安全错误分类 |
| 装配 | 客户端与成熟连接池的生命周期,不是领域状态 | | 装配 | 客户端与成熟连接池的生命周期,不是领域状态 |
不引入通用 Repository CRUD、跨系统 Unit of Work、事务队列或第二套 phase 存储。 不引入通用 Repository CRUD、跨系统 Unit of Work、事务队列或第二套 phase 存储。
resourceVersion 解决 API 对象并发更新,不宣称 PostgreSQL 与 Kubernetes 原子提交。 resourceVersion 解决 API 对象并发更新,不宣称 PostgreSQL 与 Kubernetes 原子提交。
绑定实现中,`domain/binding` 承载请求默认值、资源身份匹配、实例就绪与排他绑定规则;
`application/BindingService` 协调固定申请、资源侧写入和回读确认,返回待呈现结果。
两层均不依赖 Kubernetes API 类型。`adapter/kubernetes/BindingResources` 将 CR 转换为事实
快照,并负责保留其他字段、检查快照版本、写入 finalizer 和呈现 Conditions/status。
controller 仅连接事件、service 与呈现层,不把资源写入细节和领域判断塞进 Reconcile。
这里的接口只列出绑定用例所需操作,不扩展成通用 CRUD、Repository 或事务框架。
## API 切片前需明确 ## API 切片前需明确
- Database 的 scope、引用格式、谁可以预留/绑定/释放,以及绑定字段和更新顺序。 - 引用与绑定字段的最终格式及校验、管理员与 controller 的权限落实。
- 资源侧回收策略与 Tenant/Database finalizer 配合。 - 资源侧回收策略与 Tenant/Database finalizer 配合。
- 导入时角色/凭据的管理范围,稳定凭据定位、旧使用者撤权及新投射授权。 - 导入时角色/凭据的管理范围及关联字段、旧使用者撤权及投射清理。
- 绑定/导入同一实际目标的重复声明如何拒绝,且不引入 registry。 - 绑定/导入同一实际目标的重复声明如何拒绝,且不引入 registry。
- 管理员确认冲突、解除旧绑定的具体可审计操作入口。 - 管理员确认冲突、解除旧绑定的具体可审计操作入口。
+4 -3
View File
@@ -7,7 +7,8 @@
| 最后更新 | 2026-09-24 | | 最后更新 | 2026-09-24 |
当前设计支持管理员显式登记已有 Database;未知同名资源仍不得自动认领。 当前设计支持管理员显式登记已有 Database;未知同名资源仍不得自动认领。
导入不要求移动数据,不隐含改密码、owner、授权或删除权限。API 尚未实现,以下导入步骤 导入不要求移动数据,不隐含改密码、owner、授权或删除权限。API schema 与绑定协调已实现,
但导入观察和凭据交付尚未实现,以下导入步骤
是验收要求而非可直接执行的命令。 是验收要求而非可直接执行的命令。
## 显式导入与保留资源复用 ## 显式导入与保留资源复用
@@ -15,7 +16,7 @@
1. 核对 Instance、数据库、owner、角色权限、扩展、使用者与备份,确定允许管理的范围。 1. 核对 Instance、数据库、owner、角色权限、扩展、使用者与备份,确定允许管理的范围。
2. 由管理员声明 Database,指定已有目标,回收策略默认 Retain;导入验证初始只读。 2. 由管理员声明 Database,指定已有目标,回收策略默认 Retain;导入验证初始只读。
3. 安全关联现有应用凭据;具体 API 待定,不把密码写入 CR,不因验证失败重置密码。 3. 安全关联现有应用凭据;具体 API 待定,不把密码写入 CR,不因验证失败重置密码。
4. 管理员授权目标 Tenant;controller 验证资源与申请符合要求并建立排他绑定。 4. Tenant 显式引用未绑定且可用的 Database;controller 验证要求并建立排他绑定,无额外名单审批。
5. 验证实际登录与 ESO 交付;不满足时停止,不以修改原数据库作为默认修复。 5. 验证实际登录与 ESO 交付;不满足时停止,不以修改原数据库作为默认修复。
Released 资源复用前另需核实旧使用者的访问权限、数据交接与投射处置。保留旧绑定身份直到 Released 资源复用前另需核实旧使用者的访问权限、数据交接与投射处置。保留旧绑定身份直到
@@ -135,7 +136,7 @@ extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预
发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录: 发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录:
- 显式导入与 Released 重新绑定的授权、旧访问处置和失败不修改原资源; - 显式导入的管理权限、Released 重新开放前的旧访问处置和失败不修改原资源;
- 使用的 PostgreSQL major version 和命令版本; - 使用的 PostgreSQL major version 和命令版本;
- dump/restore 返回码和对象差异; - dump/restore 返回码和对象差异;
- DNS/IP TLS 登录结果; - DNS/IP TLS 登录结果;
+16
View File
@@ -3,8 +3,24 @@
状态:设计合同,操作入口待 API 实现与隔离环境演练。日期:2026-09-24。 状态:设计合同,操作入口待 API 实现与隔离环境演练。日期:2026-09-24。
依据 [系统规格](specification.md),不再查询或维护 PostgreSQL registry。 依据 [系统规格](specification.md),不再查询或维护 PostgreSQL registry。
## 当前绑定切片的限制
源码已接入绑定 controller,未接入 PostgreSQL 供应、OpenBao/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;后续必须补齐清理与验收。
## 日常检查 ## 日常检查
Instance 观察已实现:先确认 manager 配置了 `--database-secret-namespace` 或 `POD_NAMESPACE`,
再检查 Ready Reason、observedGeneration 与管理 Secret 名称/字段映射,切勿导出其 data。
`InsufficientPrivileges` 表示当前原生方案要求的非 superuser、CREATEDB/CREATEROLE 不满足;
`CredentialsChanged` 会丢弃中途轮换的结果并重验;`InstanceInUse` 消息定位阻塞删除的资源。
Secret 事件立即入队,30 秒重查覆盖 PostgreSQL 权限等没有 Kubernetes 事件的外部变化。
Instance 删除不要求 PostgreSQL 可达,但必须可读取所有 Database/Tenant 引用。
先看 Instance、Database、Tenant 的 Ready Condition、绑定 UID、阶段与 observedGeneration, 先看 Instance、Database、Tenant 的 Ready Condition、绑定 UID、阶段与 observedGeneration,
再核对 PostgreSQL catalog、OpenBao metadata、ExternalSecret 与 Secret 投射状态。 再核对 PostgreSQL catalog、OpenBao metadata、ExternalSecret 与 Secret 投射状态。
具体 kubectl 资源名、finalizer 名称与人工确认字段在 API 实现后补齐,不提供猜测的 patch 命令。 具体 kubectl 资源名、finalizer 名称与人工确认字段在 API 实现后补齐,不提供猜测的 patch 命令。
+9 -6
View File
@@ -3,7 +3,7 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | Review | | 状态 | Review |
| 最后更新 | 2026-09-24 | | 最后更新 | 2026-09-25 |
## 保护目标 ## 保护目标
@@ -47,12 +47,15 @@ ESO 身份只读管理路径,租户 ESO 身份只读 tenant base path,二者
使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance 使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance
不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。 不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。
PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 函数承载创建或 2026-09-25 维护者确认第一版使用原生非 superuser 管理 role,具有 CREATEDB/CREATEROLE,
删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向 不引入 SECURITY DEFINER 接口。Instance 检查拒绝 superuser;具体已有资源的 owner 和
controller role 授予 EXECUTE。controller 不调用 shell 或 `psql` 拼接用户输入。 membership 仍需逐资源验证,不能把基础能力用于接管他人资源。扩展按实际权限安装,
不因可用列表包含某个扩展就默认能安装它。controller 不调用 shell 或 `psql` 拼接用户输入。
当前检查与真实权限矩阵见 [Instance 原生管理观测](README.md#instance-原生管理观测)。
Kubernetes RBAC 应把 Instance 管理、Database 导入、预留、重新绑定授权和回收限制给平台管理员。 Kubernetes RBAC 应把 Instance 管理、Database 导入、Released 重新开放和回收限制给平台管理员。
知道 Database 名称不等于有权使用;Tenant editor 有权创建 Tenant 的申请者可显式申请未绑定且可用的 Database,不增加资源侧允许绑定名单
或逐 Tenant 审批。Released 必须先由管理员处理旧访问并重新开放。Tenant editor
不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。 不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。
## 删除保护 ## 删除保护
+35 -10
View File
@@ -4,7 +4,7 @@
| --- | --- | | --- | --- |
| 状态 | 资源模型与生命周期已批准;字段协议待 API 评审 | | 状态 | 资源模型与生命周期已批准;字段协议待 API 评审 |
| 目标 API | `database.ayatori.ddupan.top/v1alpha1` | | 目标 API | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-24 | | 最后更新 | 2026-09-25 |
| 决策 | [ADR-0009](../decisions/0009-database-resource-and-claim.md) | | 决策 | [ADR-0009](../decisions/0009-database-resource-and-claim.md) |
本文是当前行为合同,替代旧的 Tenant 同时承担申请与资源生命周期、PostgreSQL registry 本文是当前行为合同,替代旧的 Tenant 同时承担申请与资源生命周期、PostgreSQL registry
@@ -34,9 +34,16 @@ Instance
| Database | 描述外部数据库、管理范围、绑定与回收策略 | 充当第二套 registry 或通用资源框架 | | Database | 描述外部数据库、管理范围、绑定与回收策略 | 充当第二套 registry 或通用资源框架 |
| Tenant | 声明需求或显式选择资源,申请使用并交付凭据 | 删除时隐式销毁独立资源记录 | | Tenant | 声明需求或显式选择资源,申请使用并交付凭据 | 删除时隐式销毁独立资源记录 |
Instance 保持 cluster-scoped,Tenant 保持 namespaced。Database 的工作名称是 Instance 与 Database 为 cluster-scoped,Tenant 为 namespaced。Database 的工作名称是
PostgreSQLDatabase;scope、字段拼写和管理角色/凭据的具体边界待 API 评审。 PostgreSQLDatabase;字段拼写与导入授权细节待 API 评审。
单 database + 单 login owner 的首版使用场景不变,不新增独立 Role 或 Claim CRD。 Database 由平台管理员管理,不属于应用 namespace,不引入资源专用 namespace。
Tenant 按名称引用 Database;Database 绑定记录包含 Tenant 的 namespace/name/UID。
普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放。
2026-09-25 确认:第一版以一个 database、一个兼任 owner 的 login role 及其应用凭据
作为 Database 的生命周期边界,Tenant 负责申请与交付,不单独拥有账号或凭据生命周期。
不预留多账号字段,不新增独立 Role、Credential 或 Claim CRD。一库多账号若出现实际需求,
通过后续 API 版本演进处理,不纳入 v1alpha1。此边界不扩大导入资源的管理授权。
Database 自身必须声明 `instanceRef`,手工登记时同时指定实际数据库名;无需先存在 Tenant, Database 自身必须声明 `instanceRef`,手工登记时同时指定实际数据库名;无需先存在 Tenant,
即可通过 Instance 验证目标。动态申请由 Tenant 选择 Instance,供应时把该引用写入 Database; 即可通过 Instance 验证目标。动态申请由 Tenant 选择 Instance,供应时把该引用写入 Database;
@@ -81,15 +88,25 @@ endpoint 变更由管理员负责评估,不验证物理服务器连续性,
## 5. 动态供应与排他绑定 ## 5. 动态供应与排他绑定
1. 校验 Tenant 请求、Instance 能力、名称和扩展要求。 1. 校验 Tenant 请求、Instance 能力、名称和扩展要求。
绑定 controller 在触及资源侧绑定前将 Tenant 进度记为 Binding,固定申请目标,
避免两次绑定写入之间修改引用占用第二个资源;该进度不是已绑定的声明。
2. 在首次外部写入前持久化独立 Database 记录、确定目标与管理范围。 2. 在首次外部写入前持久化独立 Database 记录、确定目标与管理范围。
3. 建立带 UID 的排他预留/绑定,避免两个 Tenant 同时使用同一 Database。 动态创建的 Database 名称由 Tenant UID 确定;重试复用同一记录,不重复创建。
3. 先在 Database 写入 Tenant namespace/name/UID,再在 Tenant status 写入 Database
name/UID;双向记录一致后才允许供应或交付。
4. 按已确认步骤建立凭据、role、database、授权和扩展,逐步回读。 4. 按已确认步骤建立凭据、role、database、授权和扩展,逐步回读。
5. 验证应用登录与 ESO 投射后,Tenant 才可 Ready。 5. 验证应用登录与 ESO 投射后,Tenant 才可 Ready。
绑定前固定有效目标;绑定或开始外部供应后不得通过修改名称或引用实施隐式迁移。 绑定前固定有效目标;绑定或开始外部供应后不得通过修改名称或引用实施隐式迁移。
每个 Database 最多一个使用者,每个 Tenant 最多一个 Database。 每个 Database 最多一个使用者,每个 Tenant 最多一个 Database。
绑定 API 写入采用 resourceVersion 并发控制;双向记录不原子,单边完成不得授予使用权限。 绑定 API 写入采用 resourceVersion 并发控制;双向记录不原子,单边完成不得授予使用权限。
具体字段、写入顺序与重启恢复协议须在 API 切片定义,并由真实 API server 验证。 采用 Kubernetes PV/PVC 的资源侧先写模式,参考
[官方 bind 实现](https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/volume/persistentvolume/pv_controller.go)。
Database 已绑定其他 Tenant 时报告 Conflict,不抢占;API 更新版本冲突时重新读取并判断,
不能盲目覆盖。资源侧成功而 Tenant status 写入失败时,下一次 reconcile 核对双方身份后
补写,不因单次失败撤销资源侧绑定。普通 controller 重启沿用这些持久记录继续协调。
这只处理 Kubernetes 绑定记录的部分完成,不提供外部数据库不确定创建结果的自动认领。
绑定成功不代表 Ready,具体字段及并发、重启、单边写入恢复必须由真实 API server 测试验证。
不同 Database 记录请求同一外部名称仍可能竞争,不能仅靠 Kubernetes 中的列表检查保证 不同 Database 记录请求同一外部名称仍可能竞争,不能仅靠 Kubernetes 中的列表检查保证
PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败方不得接管胜方资源。 PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败方不得接管胜方资源。
@@ -102,12 +119,16 @@ PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败
不得通过重置密码、改变 owner 或撤销现有访问来“完成导入”。 不得通过重置密码、改变 owner 或撤销现有访问来“完成导入”。
未显式导入的同名数据库一律 Conflict。导入资源默认 Retain,不隐含 Delete 授权。 未显式导入的同名数据库一律 Conflict。导入资源默认 Retain,不隐含 Delete 授权。
Tenant 显式引用已登记资源仍需通过绑定资格与授权检查;知道资源名称不等于有权使用。 有权创建 Tenant 的申请者可以显式引用已登记、未绑定且可用的 Database;不增加资源侧
资源侧预留/授权的具体 API 和已有凭据的安全关联入口待细化,完成前不能宣称可用。 允许绑定名单或逐 Tenant 的管理员审批。绑定仍检查目标、可用状态与排他关系。
Released 不在可申请范围,必须由管理员处理旧访问并重新开放。导入时显式关联已有凭据,
不通过隐式改密生成替代凭据;具体关联字段在 API 中定义。
## 7. Retain、重新绑定与 Delete ## 7. Retain、重新绑定与 Delete
回收策略属于 Database,默认 Retain;Tenant 删除是释放申请,不是独立资源的 GC 授权。 回收策略属于 Database,默认 Retain;Tenant 删除是释放申请,不是独立资源的 GC 授权。
有资源管理权限的主体可在进入删除流程前修改 Retain/Delete;进入删除流程后策略固定。
显式设置 Delete 就是删除授权,不增加第二次审批或确认字段。
Database 不得设置会让它随 Tenant 消失的 ownerReference。 Database 不得设置会让它随 Tenant 消失的 ownerReference。
### Retain ### Retain
@@ -150,6 +171,9 @@ status 缺失不假定发生于正常重启。Instance 可重新探测能力;D
## 9. 权限、凭据与扩展 ## 9. 权限、凭据与扩展
2026-09-25 确认第一版管理账号使用原生非 superuser + CREATEDB/CREATEROLE 方案,
不引入 SECURITY DEFINER 接口;权限检查与限制见 [安全合同](security.md#最小权限)。
动态供应继续使用一个兼任 database owner 的 LOGIN role;应用角色不得具备 superuser、 动态供应继续使用一个兼任 database owner 的 LOGIN role;应用角色不得具备 superuser、
CREATEDB、CREATEROLE 或 replication 权限。撤销 PUBLIC CONNECT,再授予目标角色; CREATEDB、CREATEROLE 或 replication 权限。撤销 PUBLIC CONNECT,再授予目标角色;
不修改无关数据库和角色。identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`,SQL 安全引用。 不修改无关数据库和角色。identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`,SQL 安全引用。
@@ -164,7 +188,8 @@ Kubernetes 应用由 ESO 投射同 namespace Secret,controller 不直接写明
凭据仍输出 username/password/database/host/hostaddr/port/sslmode 七键,不生成带密码 URI。 凭据仍输出 username/password/database/host/hostaddr/port/sslmode 七键,不生成带密码 URI。
Tenant status 提供 Secret 引用与无认证信息的 OpenBao API URL。 Tenant status 提供 Secret 引用与无认证信息的 OpenBao API URL。
mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenant namespace/name 固定 mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenant namespace/name 固定
推导路径的规则需修订为可支持资源保留与重新绑定的定位协议,本轮不定字段或新路径格式。 推导路径的规则撤除。动态供应的凭据路径按 Database UID 确定;导入时显式关联已有凭据
位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。
不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。 不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求 TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
@@ -189,7 +214,7 @@ ProvisioningFailed、CredentialProjectionFailed。Released 应明确显示未可
| --- | --- | | --- | --- |
| Instance 登记与重验 | 无 registry 依赖;真实凭据/TLS/管理权限检查 | | Instance 登记与重验 | 无 registry 依赖;真实凭据/TLS/管理权限检查 |
| 动态供应与重复 reconcile | 独立资源记录、排他绑定、密码不变、实际登录与投射成功 | | 动态供应与重复 reconcile | 独立资源记录、排他绑定、密码不变、实际登录与投射成功 |
| 显式导入 | 无数据/密码/owner 隐式修改;错误目标及未经授权申请被拒绝 | | 显式导入 | 无数据/密码/owner 隐式修改;错误目标、已占用或 Released 资源的申请被拒绝 |
| 同名未知资源 | Conflict,原数据库/角色/凭据不变 | | 同名未知资源 | Conflict,原数据库/角色/凭据不变 |
| 并发申请与单边绑定 | 最多一个使用者;失败方不能开始危险外部操作 | | 并发申请与单边绑定 | 最多一个使用者;失败方不能开始危险外部操作 |
| controller 重启 | 已确认步骤正常继续;不确定创建报告人工可诊断冲突 | | controller 重启 | 已确认步骤正常继续;不确定创建报告人工可诊断冲突 |
@@ -0,0 +1,61 @@
package kubernetes
import (
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/types"
)
func bindingTenant(object *databasev1alpha1.PostgreSQLTenant) *application.BindingTenant {
tenant := &application.BindingTenant{
Revision: object.ResourceVersion, Generation: object.Generation,
}
tenant.Tenant = binding.Tenant{
Identity: binding.TenantIdentity{Namespace: object.Namespace, Name: object.Name, UID: string(object.UID)},
Phase: object.Status.Phase, Deleting: !object.DeletionTimestamp.IsZero(),
}
if request := object.Spec.Provision; request != nil {
tenant.Request.Provision = &binding.ProvisionRequest{
Instance: string(request.InstanceRef.Name), Database: string(request.Database), LoginRole: string(request.LoginRole),
}
}
if object.Spec.DatabaseRef != nil {
tenant.Request.ExistingDatabase = string(object.Spec.DatabaseRef.Name)
}
if ref := object.Status.DatabaseRef; ref != nil {
tenant.Database = &binding.Identity{Name: string(ref.Name), UID: string(ref.UID)}
}
return tenant
}
func bindingDatabase(object *databasev1alpha1.PostgreSQLDatabase) *application.BindingDatabase {
database := &application.BindingDatabase{
Revision: object.ResourceVersion,
}
database.Database = binding.Database{
Identity: binding.Identity{Name: object.Name, UID: string(object.UID)},
Instance: string(object.Spec.InstanceRef.Name), InstanceUID: string(object.Status.InstanceUID),
Name: string(object.Spec.Database), LoginRole: string(object.Spec.LoginRole), Source: object.Spec.Source,
Phase: object.Status.Phase, Deleting: !object.DeletionTimestamp.IsZero(),
Ready: currentReady(object.Generation, object.Status.Conditions),
}
if ref := object.Spec.TenantRef; ref != nil {
database.Tenant = &binding.TenantIdentity{Namespace: ref.Namespace, Name: string(ref.Name), UID: string(ref.UID)}
}
return database
}
func tenantReference(tenant binding.TenantIdentity) *databasev1alpha1.TenantReference {
return &databasev1alpha1.TenantReference{
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: types.UID(tenant.UID),
}
}
// BindingTargetName 供 informer 索引使用;不把无效请求丢出事件映射。
func BindingTargetName(tenant *databasev1alpha1.PostgreSQLTenant) string {
if tenant.Spec.DatabaseRef != nil {
return string(tenant.Spec.DatabaseRef.Name)
}
return binding.DynamicDatabaseName(string(tenant.UID))
}
@@ -0,0 +1,191 @@
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"
)
const (
TenantFinalizer = "database.ayatori.ddupan.top/tenant-protection"
DatabaseFinalizer = "database.ayatori.ddupan.top/database-protection"
)
// BindingResources 读取领域所需事实,并把用例结果呈现为 CR、finalizer 与 Conditions。
// 重新读取后校验快照版本,保留不属于本用例的字段;不决定绑定资格或恢复顺序。
type BindingResources struct {
Client client.Client
Reader client.Reader
}
var _ application.BindingResources = (*BindingResources)(nil)
func (r *BindingResources) Tenant(ctx context.Context, namespace, name string) (*application.BindingTenant, error) {
object := &databasev1alpha1.PostgreSQLTenant{}
if err := r.Reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return bindingTenant(object), nil
}
func (r *BindingResources) Database(ctx context.Context, name string) (*application.BindingDatabase, error) {
object := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return bindingDatabase(object), nil
}
func (r *BindingResources) Instance(ctx context.Context, name string) (*binding.Instance, error) {
object := &databasev1alpha1.PostgreSQLInstance{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return &binding.Instance{
Identity: binding.Identity{Name: object.Name, UID: string(object.UID)},
Deleting: !object.DeletionTimestamp.IsZero(), Ready: currentReady(object.Generation, object.Status.Conditions),
}, nil
}
func (r *BindingResources) BeginBinding(ctx context.Context, tenant *application.BindingTenant,
checkpoint *application.BindingStatus) (*application.BindingTenant, error) {
object, err := r.tenantAtVersion(ctx, tenant)
if err != nil {
return nil, err
}
if controllerutil.AddFinalizer(object, TenantFinalizer) {
if err := r.Client.Update(ctx, object); err != nil {
return nil, err
}
}
if checkpoint != nil {
if err := r.presentStatus(ctx, object, *checkpoint); err != nil {
return nil, err
}
}
return bindingTenant(object), nil
}
func (r *BindingResources) CreateDatabase(ctx context.Context, target binding.Target,
tenant binding.TenantIdentity) (*application.BindingDatabase, error) {
object := &databasev1alpha1.PostgreSQLDatabase{}
object.Name = target.Name
object.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(target.Provision.Instance)},
Database: databasev1alpha1.PostgreSQLIdentifier(target.Provision.Database),
LoginRole: databasev1alpha1.PostgreSQLIdentifier(target.Provision.LoginRole),
Source: "Provision", ReclaimPolicy: databasev1alpha1.ReclaimRetain, TenantRef: tenantReference(tenant),
}
controllerutil.AddFinalizer(object, DatabaseFinalizer)
if err := r.Client.Create(ctx, object); err != nil {
return nil, err
}
return bindingDatabase(object), nil
}
func (r *BindingResources) RecordInstance(ctx context.Context, database *application.BindingDatabase,
instanceUID string) (*application.BindingDatabase, error) {
object, err := r.databaseAtVersion(ctx, database)
if err != nil {
return nil, err
}
object.Status.InstanceUID = types.UID(instanceUID)
if err := r.Client.Status().Update(ctx, object); err != nil {
return nil, err
}
return bindingDatabase(object), nil
}
func (r *BindingResources) BindDatabase(ctx context.Context, database *application.BindingDatabase,
tenant binding.TenantIdentity) (*application.BindingDatabase, error) {
object, err := r.databaseAtVersion(ctx, database)
if err != nil {
return nil, err
}
wanted := tenantReference(tenant)
changed := controllerutil.AddFinalizer(object, DatabaseFinalizer)
if object.Spec.TenantRef == nil || *object.Spec.TenantRef != *wanted {
object.Spec.TenantRef = wanted
changed = true
}
if changed {
if err := r.Client.Update(ctx, object); err != nil {
return nil, err
}
}
return bindingDatabase(object), nil
}
func (r *BindingResources) Present(ctx context.Context, result application.BindingResult) error {
if result.Tenant == nil {
return nil
}
object, err := r.tenantAtVersion(ctx, result.Tenant)
if err != nil {
return client.IgnoreNotFound(err)
}
return r.presentStatus(ctx, object, result.Status)
}
func (r *BindingResources) presentStatus(ctx context.Context, object *databasev1alpha1.PostgreSQLTenant,
status application.BindingStatus) error {
previous := object.Status.DeepCopy()
object.Status.Phase = status.Phase
object.Status.ObservedGeneration = object.Generation
if status.Database != nil {
object.Status.DatabaseRef = &databasev1alpha1.BoundDatabaseReference{
Name: databasev1alpha1.ObjectName(status.Database.Name), UID: types.UID(status.Database.UID),
}
}
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
Type: "Ready", Status: metav1.ConditionFalse, Reason: status.Reason, Message: status.Message,
ObservedGeneration: object.Generation,
})
if equality.Semantic.DeepEqual(*previous, object.Status) {
return nil
}
return r.Client.Status().Update(ctx, object)
}
func (r *BindingResources) tenantAtVersion(ctx context.Context, tenant *application.BindingTenant) (*databasev1alpha1.PostgreSQLTenant, error) {
object := &databasev1alpha1.PostgreSQLTenant{}
key := types.NamespacedName{Namespace: tenant.Identity.Namespace, Name: tenant.Identity.Name}
if err := r.Reader.Get(ctx, key, object); err != nil {
return nil, err
}
if string(object.UID) != tenant.Identity.UID || object.ResourceVersion != tenant.Revision {
return nil, bindingVersionConflict("postgresqltenants", object.Name)
}
return object, nil
}
func (r *BindingResources) databaseAtVersion(ctx context.Context, database *application.BindingDatabase) (*databasev1alpha1.PostgreSQLDatabase, error) {
object := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: database.Identity.Name}, object); err != nil {
return nil, err
}
if string(object.UID) != database.Identity.UID || object.ResourceVersion != database.Revision {
return nil, bindingVersionConflict("postgresqldatabases", object.Name)
}
return object, nil
}
func bindingVersionConflict(resource, name string) error {
return apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource(resource).GroupResource(), name,
fmt.Errorf("绑定快照已过期,请重新读取后判断"))
}
func currentReady(generation int64, conditions []metav1.Condition) bool {
condition := meta.FindStatusCondition(conditions, "Ready")
return condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == generation
}
@@ -0,0 +1,45 @@
package kubernetes
import (
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/instance"
)
func instanceRecord(object *databasev1alpha1.PostgreSQLInstance) (*application.InstanceRecord, error) {
identity, err := instance.NewIdentity(string(object.UID), object.Name)
if err != nil {
return nil, err
}
revision, err := instance.NewRevision(object.Generation)
if err != nil {
return nil, err
}
spec := object.Spec
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: spec.Endpoint.Host, HostAddr: spec.Endpoint.HostAddr,
Port: int(spec.Endpoint.Port), ManagementDatabase: string(spec.Endpoint.Database),
TLSMode: instance.TLSMode(spec.Endpoint.SSLMode),
})
if err != nil {
return nil, err
}
credential, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: string(spec.AdminCredentialRef.Name),
UsernameKey: spec.AdminCredentialRef.UsernameKey, PasswordKey: spec.AdminCredentialRef.PasswordKey,
})
if err != nil {
return nil, err
}
definition, err := instance.NewDefinition(endpoint, credential)
if err != nil {
return nil, err
}
target, err := instance.NewObservationTarget(identity, revision, definition)
if err != nil {
return nil, err
}
return &application.InstanceRecord{
Target: target, Revision: object.ResourceVersion, Deleting: !object.DeletionTimestamp.IsZero(),
}, nil
}
@@ -0,0 +1,112 @@
package kubernetes
import (
"context"
"errors"
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/instance"
"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"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
const InstanceFinalizer = "database.ayatori.ddupan.top/instance-protection"
type InstanceResources struct {
Client client.Client
Reader client.Reader
}
func (r *InstanceResources) LoadInstance(ctx context.Context, name string) (*application.InstanceRecord, error) {
object := &databasev1alpha1.PostgreSQLInstance{}
if err := r.Reader.Get(ctx, client.ObjectKey{Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return instanceRecord(object)
}
func (r *InstanceResources) ProtectInstance(ctx context.Context, record *application.InstanceRecord) (*application.InstanceRecord, error) {
object, err := r.instanceAtVersion(ctx, record)
if err != nil {
return nil, err
}
if controllerutil.AddFinalizer(object, InstanceFinalizer) {
if err := r.Client.Update(ctx, object); err != nil {
return nil, err
}
}
return instanceRecord(object)
}
func (r *InstanceResources) InstanceReferences(ctx context.Context, name string) (string, error) {
// 删除判断必须直读 API;包含 Released、删除中的 Database 和尚未绑定的申请。
// 不按旧 Instance UID 忽略引用,也不依赖 informer 索引的及时性。
databases := &databasev1alpha1.PostgreSQLDatabaseList{}
if err := r.Reader.List(ctx, databases); err != nil {
return "", err
}
for _, database := range databases.Items {
if string(database.Spec.InstanceRef.Name) == name {
return "Database/" + database.Name, nil
}
}
tenants := &databasev1alpha1.PostgreSQLTenantList{}
if err := r.Reader.List(ctx, tenants); err != nil {
return "", err
}
for _, tenant := range tenants.Items {
if tenant.Spec.Provision != nil && string(tenant.Spec.Provision.InstanceRef.Name) == name {
return "Tenant/" + tenant.Namespace + "/" + tenant.Name, nil
}
}
return "", nil
}
func (r *InstanceResources) PresentInstance(ctx context.Context, result application.InstanceResult) error {
if result.Record == nil {
return nil
}
object, err := r.instanceAtVersion(ctx, result.Record)
if err != nil {
return client.IgnoreNotFound(err)
}
previous := object.Status.DeepCopy()
object.Status.Phase = string(result.Snapshot.Phase)
object.Status.ObservedGeneration = object.Generation
object.Status.PostgreSQLVersion = result.Snapshot.ReportedVersion
ready := metav1.ConditionFalse
if result.Snapshot.Readiness == instance.Ready {
ready = metav1.ConditionTrue
}
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
Type: "Ready", Status: ready, ObservedGeneration: object.Generation,
Reason: result.Reason, Message: result.Message,
})
if !equality.Semantic.DeepEqual(*previous, object.Status) {
if err := r.Client.Status().Update(ctx, object); err != nil {
return err
}
}
if result.RemoveProtection && controllerutil.RemoveFinalizer(object, InstanceFinalizer) {
return r.Client.Update(ctx, object)
}
return nil
}
func (r *InstanceResources) instanceAtVersion(ctx context.Context, record *application.InstanceRecord) (*databasev1alpha1.PostgreSQLInstance, error) {
object := &databasev1alpha1.PostgreSQLInstance{}
name := record.Target.Identity().Name()
if err := r.Reader.Get(ctx, client.ObjectKey{Name: name}, object); err != nil {
return nil, err
}
if string(object.UID) != record.Target.Identity().UID() || object.ResourceVersion != record.Revision {
return nil, apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource("postgresqlinstances").GroupResource(),
name, errors.New("Instance 快照已过期,请重新观察"))
}
return object, nil
}
@@ -31,6 +31,7 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/envtest"
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes" secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
@@ -40,6 +41,10 @@ import (
) )
const ( const (
fixtureAddress = "127.0.0.1"
managementUsernameKey = "login"
managementPasswordKey = "credential"
unrelatedNamespace = "unrelated"
fixtureHost = "fixture.invalid" fixtureHost = "fixture.invalid"
fixtureUser = "postgres" fixtureUser = "postgres"
fixtureExtension = "plpgsql" fixtureExtension = "plpgsql"
@@ -79,7 +84,7 @@ func postgresFixture(t *testing.T, ctx context.Context) (string, int) {
t.Fatal("invalid fixture port") t.Fatal("invalid fixture port")
} }
// 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。 // 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", "127.0.0.1", "-U", fixtureUser).Run() != nil { for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", fixtureAddress, "-U", fixtureUser).Run() != nil {
select { select {
case <-ctx.Done(): case <-ctx.Done():
t.Fatal("fixture startup timed out") t.Fatal("fixture startup timed out")
@@ -154,7 +159,7 @@ func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationT
} }
endpoint, err := instance.NewEndpoint(instance.EndpointValues{ endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: fixtureHost, Host: fixtureHost,
HostAddr: "127.0.0.1", HostAddr: fixtureAddress,
Port: port, Port: port,
ManagementDatabase: fixtureUser, ManagementDatabase: fixtureUser,
TLSMode: mode, TLSMode: mode,
@@ -164,8 +169,8 @@ func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationT
} }
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{ ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: secretName, Name: secretName,
UsernameKey: "login", UsernameKey: managementUsernameKey,
PasswordKey: "credential", PasswordKey: managementPasswordKey,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -196,6 +201,7 @@ func (r *gatedReader) Read(ctx context.Context, ref instance.CredentialReference
// credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。 // credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。
type credentialFixture struct { type credentialFixture struct {
config *rest.Config
ctx context.Context ctx context.Context
client *kubernetes.Clientset client *kubernetes.Clientset
reader *secretadapter.SecretCredentials reader *secretadapter.SecretCredentials
@@ -227,7 +233,7 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
if err != nil { if err != nil {
t.Fatal("cannot create test client") t.Fatal("cannot create test client")
} }
for _, namespace := range []string{controllerNamespace, "unrelated"} { for _, namespace := range []string{controllerNamespace, unrelatedNamespace} {
_, err := client.CoreV1().Namespaces().Create( _, err := client.CoreV1().Namespaces().Create(
ctx, ctx,
&corev1.Namespace{Name: namespace}, &corev1.Namespace{Name: namespace},
@@ -260,6 +266,7 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
t.Cleanup(service.Close) t.Cleanup(service.Close)
return &credentialFixture{ return &credentialFixture{
config: config,
ctx: ctx, ctx: ctx,
client: client, client: client,
reader: reader, reader: reader,
@@ -277,8 +284,8 @@ func (f *credentialFixture) createSecret(t *testing.T, namespace string) {
secret := &corev1.Secret{ secret := &corev1.Secret{
Name: secretName, Name: secretName,
Data: map[string][]byte{ Data: map[string][]byte{
"login": []byte(fixtureUser), managementUsernameKey: []byte(fixtureUser),
"credential": []byte(fixturePassword), managementPasswordKey: []byte(fixturePassword),
}, },
} }
if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil { if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil {
@@ -0,0 +1,210 @@
//go:build integration
package postgresql_test
import (
"bytes"
"context"
"os"
"testing"
"time"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
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/runtime"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
"sigs.k8s.io/controller-runtime/pkg/envtest"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/yaml"
)
const watchRevisionAnnotation = "test.ayatori/observation"
func TestInstanceControllerWithRealPostgreSQL(t *testing.T) {
f := newCredentialFixture(t)
if _, err := envtest.InstallCRDs(f.config, envtest.CRDInstallOptions{
Paths: []string{"../../../../config/crd/bases"}, ErrorIfPathMissing: true,
}); err != nil {
t.Fatal(err)
}
scheme := runtime.NewScheme()
for _, install := range []func(*runtime.Scheme) error{databasev1alpha1.AddToScheme, corev1.AddToScheme, rbacv1.AddToScheme} {
if err := install(scheme); err != nil {
t.Fatal(err)
}
}
apiClient, err := client.New(f.config, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
restricted := instanceControllerRBAC(t, f, apiClient)
credentials, err := secretadapter.NewSecretCredentials(restricted, controllerNamespace)
if err != nil {
t.Fatal(err)
}
service, err := application.NewInstanceService(credentials, postgresql.Connector{})
if err != nil {
t.Fatal(err)
}
// 同进程 -count 重复启动测试 manager;生产继续校验 controller 名称唯一。
skipRepeatedName := true
manager, err := ctrl.NewManager(restricted, ctrl.Options{
Scheme: scheme, Cache: databasecontroller.InstanceCacheOptions(controllerNamespace),
Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedName},
})
if err != nil {
t.Fatal(err)
}
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: controllerNamespace}
if err := reconciler.SetupWithManager(manager); err != nil {
t.Fatal(err)
}
managerContext, stop := context.WithCancel(f.ctx)
done := make(chan error, 1)
go func() { done <- manager.Start(managerContext) }()
t.Cleanup(func() {
stop()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(20 * time.Second):
t.Error("Instance manager 未停止")
}
service.Close()
})
object := &databasev1alpha1.PostgreSQLInstance{}
object.Name = "native-instance"
object.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{
Host: fixtureHost, HostAddr: fixtureAddress, Port: int32(f.port), SSLMode: "disable",
}
object.Spec.AdminCredentialRef = databasev1alpha1.AdminCredentialReference{
Name: secretName, UsernameKey: managementUsernameKey, PasswordKey: managementPasswordKey,
}
if err := apiClient.Create(f.ctx, object); err != nil {
t.Fatal(err)
}
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
// 30 秒轮询前必须收到 Secret 创建事件;实际 controller 使用 namespace Role + metadata watch。
useNativeManager(t, f)
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
before := f.backendIDs(t)
f.updateSecret(t, func(secret *corev1.Secret) {
secret.Annotations = map[string]string{watchRevisionAnnotation: "changed"}
})
// 用实际 API 事件触发重验,metadata 改动不应换池。
time.Sleep(200 * time.Millisecond)
if f.backendIDs(t) != before {
t.Fatal("无关 Secret metadata 修改重建了连接")
}
f.queryPostgres(t, "ALTER ROLE native_manager PASSWORD '"+rotatedPassword+"'")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte("invalid-test-password") })
awaitInstanceReason(t, f, apiClient, object, "AuthenticationFailed")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
if f.backendIDs(t) == before {
t.Fatal("凭据轮换没有替换旧连接")
}
f.queryPostgres(t, "ALTER ROLE native_manager NOCREATEROLE")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recheck" })
awaitInstanceReason(t, f, apiClient, object, "InsufficientPrivileges")
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recovered" })
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
if err := f.client.CoreV1().Secrets(controllerNamespace).Delete(f.ctx, secretName, metav1.DeleteOptions{}); err != nil {
t.Fatal(err)
}
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
if f.backendIDs(t) != "" {
t.Fatal("Secret 删除后旧连接未释放")
}
}
func awaitInstanceReason(t *testing.T, f *credentialFixture, apiClient client.Client,
object *databasev1alpha1.PostgreSQLInstance, reason string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if err := apiClient.Get(f.ctx, client.ObjectKeyFromObject(object), object); err != nil {
t.Fatal(err)
}
condition := meta.FindStatusCondition(object.Status.Conditions, "Ready")
if condition != nil && condition.Reason == reason && condition.ObservedGeneration == object.Generation {
if (condition.Status == metav1.ConditionTrue) != (reason == "ManagementReady") {
t.Fatal("Ready 与检查结果不一致")
}
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("watch 未及时推进到 %s", reason)
}
func instanceControllerRBAC(t *testing.T, f *credentialFixture, apiClient client.Client) *rest.Config {
t.Helper()
roleBytes, err := os.ReadFile("../../../../config/rbac/role.yaml")
if err != nil {
t.Fatal(err)
}
role := &rbacv1.ClusterRole{}
if err := yaml.Unmarshal(roleBytes, role); err != nil {
t.Fatal(err)
}
if err := apiClient.Create(f.ctx, role); err != nil {
t.Fatal(err)
}
user := "instance-controller-test"
binding := &rbacv1.ClusterRoleBinding{}
binding.Name = user
binding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}
binding.Subjects = []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: user}}
if err := apiClient.Create(f.ctx, binding); err != nil {
t.Fatal(err)
}
credentialBytes, err := os.ReadFile("../../../../config/rbac/database_credentials_role.yaml")
if err != nil {
t.Fatal(err)
}
namespaceRole := &rbacv1.Role{}
decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader(credentialBytes), 4096)
if err := decoder.Decode(namespaceRole); err != nil {
t.Fatal(err)
}
namespaceRole.Namespace = controllerNamespace
if err := apiClient.Create(f.ctx, namespaceRole); err != nil {
t.Fatal(err)
}
namespaceBinding := &rbacv1.RoleBinding{}
namespaceBinding.Name, namespaceBinding.Namespace = user, controllerNamespace
namespaceBinding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: namespaceRole.Name}
namespaceBinding.Subjects = binding.Subjects
if err := apiClient.Create(f.ctx, namespaceBinding); err != nil {
t.Fatal(err)
}
config := rest.CopyConfig(f.config)
config.Impersonate.UserName = user
restrictedClient, err := client.New(config, client.Options{Scheme: apiClient.Scheme()})
if err != nil {
t.Fatal(err)
}
secret := &corev1.Secret{}
err = restrictedClient.Get(f.ctx, client.ObjectKey{Namespace: unrelatedNamespace, Name: secretName}, secret)
if !apierrors.IsForbidden(err) {
t.Fatal("Instance controller 可以跨 namespace 读取 Secret")
}
return config
}
@@ -0,0 +1,58 @@
package postgresql
import (
"context"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
// 只读取当前执行角色的属性,不能从可继承的角色成员关系推导 CREATEDB/CREATEROLE。
// 所有事实来自同一条语句;不创建探针数据库,不修改管理账号或持久 schema。
const inspectManagementStatement = `
SELECT
pg_catalog.current_setting('server_version'),
ARRAY(SELECT name::text FROM pg_catalog.pg_available_extensions ORDER BY name),
role.rolsuper,
role.rolcreaterole,
role.rolcreatedb,
pg_catalog.pg_is_in_recovery() OR
pg_catalog.current_setting('transaction_read_only')::boolean
FROM pg_catalog.pg_roles AS role
WHERE role.rolname = current_user`
func (d *database) InspectManagement(ctx context.Context) (application.DatabaseMetadata, error) {
var metadata application.DatabaseMetadata
var superuser, createRole, createDatabase, readOnly bool
err := d.pool.QueryRow(ctx, inspectManagementStatement).Scan(
&metadata.Version, &metadata.AvailableExtensions,
&superuser, &createRole, &createDatabase, &readOnly,
)
if err != nil {
return application.DatabaseMetadata{}, safeError(err, application.ErrObservation)
}
checks := instance.ManagementChecks{
Connection: instance.CheckPassed,
Metadata: instance.CheckPassed,
Roles: nativePrivilege(createRole && !superuser),
Databases: nativePrivilege(createDatabase && !superuser),
// CREATEROLE 可管理自己新建角色的 membership;供应时必须显式取得 SET 权限,
// 再以 owner 操作数据库 ACL。这里不授权操作任意导入角色或他人数据库。
Grants: nativePrivilege(createRole && createDatabase && !superuser),
// 新建数据库 owner 可安装 trusted 扩展。具体扩展仍需逐请求执行和回读,
// 非 trusted 扩展不能因出现在 available 列表就视为可安装。
Extensions: nativePrivilege(createRole && createDatabase && !superuser),
}
if readOnly {
checks.Databases = instance.CheckUnavailable
}
metadata.Management = checks
return metadata, nil
}
func nativePrivilege(allowed bool) instance.CheckResult {
if allowed {
return instance.CheckPassed
}
return instance.CheckInsufficientPrivileges
}
@@ -0,0 +1,152 @@
//go:build integration
package postgresql_test
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5"
corev1 "k8s.io/api/core/v1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const nativeManager = "native_manager"
func useNativeManager(t *testing.T, f *credentialFixture) {
t.Helper()
f.queryPostgres(t, "CREATE ROLE native_manager LOGIN CREATEDB CREATEROLE PASSWORD '"+fixturePassword+"'")
f.createSecret(t, controllerNamespace)
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(nativeManager) })
}
func assessManagement(t *testing.T, f *credentialFixture) instance.Snapshot {
t.Helper()
observation, err := f.service.ObserveManagement(f.ctx, f.target)
if err != nil {
t.Fatal(err)
}
aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false)
if err != nil {
t.Fatal(err)
}
if err := aggregate.BeginValidation(); err != nil {
t.Fatal(err)
}
capabilities, err := observation.Capabilities()
if err != nil {
t.Fatal(err)
}
if err := aggregate.AssessManagement(capabilities); err != nil {
t.Fatal(err)
}
return aggregate.Snapshot()
}
func TestNativeManagementPrivileges(t *testing.T) {
f := newCredentialFixture(t)
useNativeManager(t, f)
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
t.Fatal("原生非 superuser 管理账号未通过检查")
}
before := f.backendIDs(t)
for _, attribute := range []string{"NOCREATEROLE", "NOCREATEDB"} {
f.queryPostgres(t, "ALTER ROLE native_manager "+attribute)
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
t.Fatal("已有连接忽略了管理权限撤回")
}
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE CREATEDB")
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
t.Fatal("管理权限恢复后无法重新就绪")
}
}
if f.backendIDs(t) != before {
t.Fatal("权限检查不应要求重建连接才生效")
}
f.queryPostgres(t, "ALTER ROLE native_manager SET default_transaction_read_only = on")
f.service.Forget(f.target.Identity().Name())
if snapshot := assessManagement(t, f); snapshot.Failure != instance.DependencyUnavailable {
t.Fatal("只读会话不应标记可供应")
}
f.queryPostgres(t, "ALTER ROLE native_manager RESET default_transaction_read_only")
f.service.Forget(f.target.Identity().Name())
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
t.Fatal("恢复可写会话后没有就绪")
}
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(fixtureUser) })
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
t.Fatal("不应以 superuser 绕过非特权账号合同")
}
reads := 0
f.gate.beforeRead = func() {
reads++
if reads == 2 {
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
}
}
observation, err := f.service.ObserveManagement(f.ctx, f.target)
if !errors.Is(err, application.ErrCredentialsChanged) || observation.Target().Validate() == nil {
t.Fatal("管理观察期间凭据轮换应丢弃全部能力结果")
}
if f.backendIDs(t) != "" {
t.Fatal("中途轮换后不应保留旧管理连接")
}
}
// 以实际非 superuser 会话验证能力矩阵的依据,不用超级用户执行 SQL 模拟管理账号。
// 这些固定名称只存在于本测试独占容器,生产观察本身不会创建探针对象。
func TestNativeManagementSupplyContract(t *testing.T) {
f := newCredentialFixture(t)
useNativeManager(t, f)
config, err := pgx.ParseConfig("")
if err != nil {
t.Fatal("无法装配隔离测试连接")
}
config.Host, config.Port = fixtureAddress, uint16(f.port)
config.Database, config.User, config.Password = fixtureUser, nativeManager, fixturePassword
config.TLSConfig, config.Fallbacks = nil, nil
connection, err := pgx.ConnectConfig(f.ctx, config)
if err != nil {
t.Fatal("非 superuser 测试连接失败")
}
t.Cleanup(func() { _ = connection.Close(context.Background()) })
execute := func(statement string) {
t.Helper()
if _, err := connection.Exec(f.ctx, statement); err != nil {
t.Fatalf("原生管理能力合同未满足,步骤 %q", statement)
}
}
execute("CREATE ROLE managed_owner LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION")
execute("GRANT managed_owner TO native_manager WITH SET TRUE")
execute("CREATE DATABASE managed_database OWNER managed_owner")
execute("SET ROLE managed_owner")
execute("REVOKE CONNECT ON DATABASE managed_database FROM PUBLIC")
execute("GRANT CONNECT ON DATABASE managed_database TO managed_owner")
execute("RESET ROLE")
config.Database = "managed_database"
tenantConnection, err := pgx.ConnectConfig(f.ctx, config)
if err != nil {
t.Fatal("管理账号无法访问其受管数据库")
}
defer func() { _ = tenantConnection.Close(context.Background()) }()
if _, err := tenantConnection.Exec(f.ctx, "SET ROLE managed_owner; CREATE EXTENSION hstore"); err != nil {
t.Fatal("owner 无法安装 trusted 扩展")
}
var installed bool
if err := tenantConnection.QueryRow(f.ctx, "SELECT EXISTS (SELECT FROM pg_catalog.pg_extension WHERE extname = 'hstore')").Scan(&installed); err != nil || !installed {
t.Fatal("扩展安装后实际回读失败")
}
if _, err := tenantConnection.Exec(f.ctx, "CREATE EXTENSION file_fdw"); err == nil {
t.Fatal("非 trusted 扩展不应被 Ready 隐式授权")
}
if err := tenantConnection.Close(f.ctx); err != nil {
t.Fatal("关闭目标数据库连接失败")
}
execute("SET ROLE managed_owner")
execute("DROP DATABASE managed_database")
execute("RESET ROLE")
execute("DROP ROLE managed_owner")
}
@@ -0,0 +1,170 @@
package application
import (
"context"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
)
// 快照版本只用于协调读写,不进入领域规则。Generation 用于确认整个申请未在回读期间变化。
type BindingTenant struct {
binding.Tenant
Revision string
Generation int64
}
type BindingDatabase struct {
binding.Database
Revision string
}
// BindingResources 是这个用例所需的操作,不是通用 Repository 或跨系统事务接口。
// 查不到对象时返回 nil;写入必须检查传入快照的版本,不能覆盖并发修改。
type BindingResources interface {
Tenant(context.Context, string, string) (*BindingTenant, error)
Database(context.Context, string) (*BindingDatabase, error)
Instance(context.Context, string) (*binding.Instance, error)
BeginBinding(context.Context, *BindingTenant, *BindingStatus) (*BindingTenant, error)
CreateDatabase(context.Context, binding.Target, binding.TenantIdentity) (*BindingDatabase, error)
RecordInstance(context.Context, *BindingDatabase, string) (*BindingDatabase, error)
BindDatabase(context.Context, *BindingDatabase, binding.TenantIdentity) (*BindingDatabase, error)
}
// BindingStatus 是用例结果,资源呈现层决定如何写成 Conditions/status。
type BindingStatus struct {
Phase string
Reason string
Message string
Database *binding.Identity
}
type BindingResult struct {
Tenant *BindingTenant
Status BindingStatus
RetrySoon bool
}
type BindingService struct {
Resources BindingResources
}
func (s BindingService) Reconcile(ctx context.Context, namespace, name string) (BindingResult, error) {
tenant, err := s.Resources.Tenant(ctx, namespace, name)
if err != nil || tenant == nil {
return BindingResult{}, err
}
if tenant.Deleting {
return bindingResult(tenant, binding.Deleting, "DeletionPending",
"删除清理尚未接入;保留 finalizer 和 Database 绑定,未执行后端删除"), nil
}
target, err := tenant.Request.Resolve(tenant.Identity)
if err != nil {
return bindingResult(tenant, tenant.Phase, "InvalidRequest", err.Error()), nil
}
// 持久固定申请,再创建/绑定资源;不是预先宣告双向绑定成功。
var checkpoint *BindingStatus
if tenant.Phase != binding.Binding && tenant.Phase != binding.Bound {
checkpoint = &BindingStatus{Phase: binding.Binding, Reason: "BindingPending", Message: "申请目标已固定,等待资源侧绑定"}
}
tenant, err = s.Resources.BeginBinding(ctx, tenant, checkpoint)
if err != nil {
return BindingResult{}, err
}
database, issue, err := s.resolveDatabase(ctx, tenant, target)
if err != nil {
return BindingResult{}, err
}
if issue != nil {
return bindingResult(tenant, tenant.Phase, issue.Reason, issue.Message), nil
}
if issue := database.CanBind(tenant.Tenant); issue != nil {
return bindingResult(tenant, tenant.Phase, issue.Reason, issue.Message), nil
}
database, err = s.Resources.BindDatabase(ctx, database, tenant.Identity)
if err != nil {
return BindingResult{}, err
}
return s.confirmBinding(ctx, tenant, database)
}
func (s BindingService) resolveDatabase(ctx context.Context, tenant *BindingTenant, target binding.Target) (
*BindingDatabase, *binding.Issue, error,
) {
database, err := s.Resources.Database(ctx, target.Name)
if err != nil {
return nil, nil, err
}
if database == nil && target.Provision == nil {
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "指定的 Database 尚不存在,等待资源出现"}, nil
}
instanceName := ""
var observed *binding.Database
if database != nil {
instanceName, observed = database.Instance, &database.Database
}
if target.Provision != nil {
instanceName = target.Provision.Instance
if database != nil && !database.MatchesProvision(target, tenant.Identity) {
return nil, &binding.Issue{Reason: binding.Conflict,
Message: "动态 Database 名称已存在,但目标或 Tenant UID 不匹配;请核实记录,未自动认领"}, nil
}
}
instance, err := s.Resources.Instance(ctx, instanceName)
if err != nil {
return nil, nil, err
}
if instance == nil {
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "引用的 Instance 尚不存在"}, nil
}
if issue := instance.Check(observed); issue != nil {
return nil, issue, nil
}
if database == nil {
database, err = s.Resources.CreateDatabase(ctx, target, tenant.Identity)
if err != nil {
return nil, nil, err
}
}
// 只有首次动态记录可以补入实例身份;导入必须先有资源观察。
if database.InstanceUID == "" && target.Provision != nil {
database, err = s.Resources.RecordInstance(ctx, database, instance.Identity.UID)
if err != nil {
return nil, nil, err
}
}
if database.InstanceUID == "" {
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "Database 尚未完成实例身份验证"}, nil
}
return database, nil, nil
}
func (s BindingService) confirmBinding(ctx context.Context, tenant *BindingTenant, database *BindingDatabase) (BindingResult, error) {
latest, err := s.Resources.Tenant(ctx, tenant.Identity.Namespace, tenant.Identity.Name)
if err != nil || latest == nil {
return BindingResult{}, err
}
if latest.Identity != tenant.Identity || latest.Deleting {
return BindingResult{}, nil
}
if latest.Generation != tenant.Generation {
return BindingResult{RetrySoon: true}, nil
}
observed, err := s.Resources.Database(ctx, database.Identity.Name)
if err != nil {
return BindingResult{}, err
}
if observed == nil || observed.Identity != database.Identity || observed.Tenant == nil {
return bindingResult(latest, latest.Phase, binding.Conflict, "资源侧身份或绑定已变化,未完成申请侧绑定"), nil
}
if issue := observed.CanBind(latest.Tenant); issue != nil {
return bindingResult(latest, latest.Phase, issue.Reason, issue.Message), nil
}
result := bindingResult(latest, binding.Bound, "BindingComplete",
"双向绑定已记录;尚未执行供应、应用登录验证或凭据交付,不能 Ready")
result.Status.Database = &observed.Identity
return result, nil
}
func bindingResult(tenant *BindingTenant, phase, reason, message string) BindingResult {
return BindingResult{Tenant: tenant, Status: BindingStatus{Phase: phase, Reason: reason, Message: message}}
}
@@ -0,0 +1,181 @@
package application
import (
"context"
"errors"
"reflect"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
)
const (
bindingReadTenant = "tenant"
bindingBegin = "begin"
bindingReadInstance = "instance"
bindingCreate = "create"
bindingRecordInstance = "record-instance"
bindingWriteResource = "bind"
bindingDatabaseOperation = "database"
bindingTestNamespace = "apps"
bindingTestName = "app"
bindingTestInstance = "shared"
)
func TestBindingServiceOrder(t *testing.T) {
resources := bindingFixture()
service := BindingService{Resources: resources}
result, err := service.Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if err != nil {
t.Fatal(err)
}
want := []string{bindingReadTenant, bindingBegin, bindingDatabaseOperation, bindingReadInstance, bindingCreate, bindingRecordInstance, bindingWriteResource, bindingReadTenant, bindingDatabaseOperation}
if !reflect.DeepEqual(resources.calls, want) {
t.Fatalf("协调顺序 = %v, want %v", resources.calls, want)
}
if result.Status.Phase != binding.Bound || result.Status.Database == nil || result.Status.Database.UID != "database-uid" {
t.Fatalf("绑定结果不符: %+v", result.Status)
}
if resources.tenant.Phase != binding.Binding || resources.tenant.Database != nil {
t.Fatal("service 只能返回待呈现结果,不能提前写申请侧绑定")
}
if resources.database.Tenant == nil || *resources.database.Tenant != resources.tenant.Identity {
t.Fatal("返回完成结果之前必须写入资源侧绑定")
}
}
func TestBindingServiceStopsOnIOFailure(t *testing.T) {
for _, operation := range []string{bindingReadTenant, bindingBegin, bindingDatabaseOperation, bindingReadInstance, bindingCreate, bindingRecordInstance, bindingWriteResource} {
t.Run(operation, func(t *testing.T) {
resources := bindingFixture()
resources.failAt = operation
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if !errors.Is(err, errBindingTest) || result.Status.Database != nil {
t.Fatalf("IO 失败不应被转换为绑定成功: result=%+v, err=%v", result, err)
}
if resources.calls[len(resources.calls)-1] != operation {
t.Fatalf("失败后继续执行了操作: %v", resources.calls)
}
})
}
}
func TestBindingServiceDoesNotWriteWhenDeletingOrInvalid(t *testing.T) {
for _, deleting := range []bool{false, true} {
resources := bindingFixture()
resources.tenant.Deleting = deleting
resources.tenant.Request = binding.Request{}
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if err != nil || len(resources.calls) != 1 || result.Status.Database != nil {
t.Fatalf("删除或无效申请不应触及资源: calls=%v, err=%v", resources.calls, err)
}
want := "InvalidRequest"
if deleting {
want = "DeletionPending"
}
if result.Status.Reason != want {
t.Fatalf("Reason = %s, want %s", result.Status.Reason, want)
}
}
}
func TestBindingServiceConfirmsIdentityAgain(t *testing.T) {
resources := bindingFixture()
resources.replaceOnReadback = true
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if err != nil {
t.Fatal(err)
}
if result.Status.Reason != binding.Conflict || result.Status.Database != nil {
t.Fatal("资源侧写入后发生身份变化时不能返回绑定完成")
}
}
// 这里只记录用例操作,不模拟 Kubernetes 校验;真实 IO 契约由 controller envtest 覆盖。
type bindingTestResources struct {
tenant *BindingTenant
database *BindingDatabase
instance *binding.Instance
calls []string
failAt string
replaceOnReadback bool
bound bool
}
var errBindingTest = errors.New("injected resource operation failure")
func bindingFixture() *bindingTestResources {
tenant := &BindingTenant{Generation: 1}
tenant.Tenant = binding.Tenant{
Identity: binding.TenantIdentity{Namespace: bindingTestNamespace, Name: bindingTestName, UID: "tenant-uid"},
Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: bindingTestInstance}},
}
return &bindingTestResources{
tenant: tenant,
instance: &binding.Instance{Identity: binding.Identity{Name: bindingTestInstance, UID: "instance-uid"}, Ready: true},
}
}
func (r *bindingTestResources) record(operation string) error {
r.calls = append(r.calls, operation)
if r.failAt == operation {
return errBindingTest
}
return nil
}
func (r *bindingTestResources) Tenant(context.Context, string, string) (*BindingTenant, error) {
return r.tenant, r.record(bindingReadTenant)
}
func (r *bindingTestResources) Database(context.Context, string) (*BindingDatabase, error) {
if r.bound && r.replaceOnReadback {
replaced := *r.database
replaced.Identity.UID = "replacement"
return &replaced, r.record(bindingDatabaseOperation)
}
return r.database, r.record(bindingDatabaseOperation)
}
func (r *bindingTestResources) Instance(context.Context, string) (*binding.Instance, error) {
return r.instance, r.record(bindingReadInstance)
}
func (r *bindingTestResources) BeginBinding(_ context.Context, tenant *BindingTenant, status *BindingStatus) (*BindingTenant, error) {
if err := r.record(bindingBegin); err != nil {
return nil, err
}
if status != nil {
r.tenant.Phase = status.Phase
}
return tenant, nil
}
func (r *bindingTestResources) CreateDatabase(_ context.Context, target binding.Target, tenant binding.TenantIdentity) (*BindingDatabase, error) {
if err := r.record(bindingCreate); err != nil {
return nil, err
}
r.database = &BindingDatabase{}
r.database.Database = binding.Database{
Identity: binding.Identity{Name: target.Name, UID: "database-uid"}, Tenant: &tenant,
Instance: target.Provision.Instance, Name: target.Provision.Database, LoginRole: target.Provision.LoginRole, Source: "Provision",
}
return r.database, nil
}
func (r *bindingTestResources) RecordInstance(_ context.Context, database *BindingDatabase, uid string) (*BindingDatabase, error) {
if err := r.record(bindingRecordInstance); err != nil {
return nil, err
}
database.InstanceUID = uid
return database, nil
}
func (r *bindingTestResources) BindDatabase(_ context.Context, database *BindingDatabase, tenant binding.TenantIdentity) (*BindingDatabase, error) {
if err := r.record(bindingWriteResource); err != nil {
return nil, err
}
database.Tenant = &tenant
r.bound = true
return database, nil
}
@@ -0,0 +1,149 @@
package application
import (
"context"
"errors"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const (
instanceDependencyUnavailable = "DependencyUnavailable"
instanceAuthenticationFailed = "AuthenticationFailed"
)
// InstanceRecord 是 API 快照;Revision 仅用于持久化并发保护,不是领域版本。
type InstanceRecord struct {
Target instance.ObservationTarget
Revision string
Deleting bool
}
type InstanceResources interface {
LoadInstance(context.Context, string) (*InstanceRecord, error)
ProtectInstance(context.Context, *InstanceRecord) (*InstanceRecord, error)
// InstanceReferences 返回一个可定位的阻塞引用;空字符串表示没有引用。
InstanceReferences(context.Context, string) (string, error)
}
type InstanceObserver interface {
ObserveManagement(context.Context, instance.ObservationTarget) (InstanceObservation, error)
Forget(string)
}
type InstanceResult struct {
Record *InstanceRecord
Snapshot instance.Snapshot
Reason string
Message string
RemoveProtection bool
}
// InstanceReconciliation 协调 API 保护、实时观察和领域判断,不拼装 Kubernetes status。
type InstanceReconciliation struct {
Resources InstanceResources
Observer InstanceObserver
}
func (s *InstanceReconciliation) Reconcile(ctx context.Context, name string) (InstanceResult, error) {
record, err := s.Resources.LoadInstance(ctx, name)
if err != nil {
return InstanceResult{}, err
}
if record == nil {
s.Observer.Forget(name)
return InstanceResult{}, nil
}
if record.Deleting {
return s.deleting(ctx, record)
}
record, err = s.Resources.ProtectInstance(ctx, record)
if err != nil {
return InstanceResult{}, err
}
// 每轮从无证据的领域对象开始;持久化 Ready 和连接存活不能替代本轮检查。
aggregate, err := instance.Reconstitute(record.Target, instance.Snapshot{}, false)
if err != nil {
return InstanceResult{}, err
}
if err := aggregate.BeginValidation(); err != nil {
return InstanceResult{}, err
}
observation, observationErr := s.Observer.ObserveManagement(ctx, record.Target)
result := InstanceResult{Record: record}
if observationErr != nil {
result.Snapshot = aggregate.Snapshot()
result.Snapshot.Readiness = instance.NotReady
result.Snapshot.ObservedRevision = record.Target.Revision().Value()
result.Reason, result.Message = observationFailure(observationErr)
return result, nil
}
capabilities, err := observation.Capabilities()
if err != nil {
return InstanceResult{}, err
}
if err := aggregate.AssessManagement(capabilities); err != nil {
return InstanceResult{}, err
}
result.Snapshot = aggregate.Snapshot()
result.Snapshot.ReportedVersion = observation.Version()
result.Reason, result.Message = managementResult(result.Snapshot.Failure)
return result, nil
}
func (s *InstanceReconciliation) deleting(ctx context.Context, record *InstanceRecord) (InstanceResult, error) {
name := record.Target.Identity().Name()
s.Observer.Forget(name)
aggregate, err := instance.Reconstitute(record.Target, instance.Snapshot{}, true)
if err != nil {
return InstanceResult{}, err
}
if err := aggregate.BeginDeletion(); err != nil {
return InstanceResult{}, err
}
result := InstanceResult{Record: record, Snapshot: aggregate.Snapshot(), Reason: "Deleting"}
reference, err := s.Resources.InstanceReferences(ctx, name)
if err != nil {
result.Reason = instanceDependencyUnavailable
result.Message = "无法确认 Database/Tenant 引用已解除;保留 Instance 删除保护并重试"
return result, nil
}
if reference != "" {
result.Reason = "InstanceInUse"
result.Message = "仍被 " + reference + " 引用;先处理该资源,不会级联删除外部数据库"
return result, nil
}
result.Message = "引用已解除,仅移除登记保护;不删除 PostgreSQL 或凭据"
result.RemoveProtection = true
return result, nil
}
func observationFailure(err error) (string, string) {
switch {
case errors.Is(err, ErrAuthentication):
return instanceAuthenticationFailed, "管理连接认证或 TLS 校验失败;检查管理 Secret 和 CA/证书配置"
case errors.Is(err, ErrCredentialsInvalid):
return "InvalidCredentials", "管理 Secret 的用户名或密码字段缺失;检查引用字段映射"
case errors.Is(err, ErrCredentialsChanged):
return "CredentialsChanged", "观察期间管理凭据变化,已丢弃结果并关闭旧连接;等待重新验证"
case errors.Is(err, ErrCredentialsUnavailable):
return instanceDependencyUnavailable, "无法读取管理 Secret;检查其是否存在及 controller namespace 内的读取权限"
default:
return instanceDependencyUnavailable, "管理连接或能力查询失败;检查 PostgreSQL 可达性、catalog 读取权限和超时"
}
}
func managementResult(failure instance.Failure) (string, string) {
switch failure {
case instance.NoFailure:
return "ManagementReady", "当前管理能力检查通过;具体资源授权和扩展安装仍需执行时验证"
case instance.InsufficientPrivileges:
return "InsufficientPrivileges", "原生管理要求非 superuser 且具备 CREATEDB/CREATEROLE;不会自动修改账号权限"
case instance.DependencyUnavailable:
return instanceDependencyUnavailable, "当前 PostgreSQL 不可写或所需管理能力暂不可用"
case instance.AuthenticationFailed:
return instanceAuthenticationFailed, "当前管理能力检查未通过认证"
default:
return "ObservationIncomplete", "管理能力检查尚有缺项,不能仅凭 metadata 查询成功标记 Ready"
}
}
@@ -0,0 +1,79 @@
package application
import (
"context"
"errors"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestInstanceFailurePresentation(t *testing.T) {
cases := []struct {
err error
reason string
}{
{ErrAuthentication, "AuthenticationFailed"},
{ErrCredentialsInvalid, "InvalidCredentials"},
{ErrCredentialsChanged, "CredentialsChanged"},
{ErrCredentialsUnavailable, instanceDependencyUnavailable},
{context.DeadlineExceeded, instanceDependencyUnavailable},
{errors.New("private backend detail"), instanceDependencyUnavailable},
}
for _, test := range cases {
reason, message := observationFailure(test.err)
if reason != test.reason || message == "" || message == test.err.Error() {
t.Fatal("观察失败没有安全且可诊断的状态")
}
}
for _, failure := range []instance.Failure{
instance.NoFailure, instance.ObservationIncomplete, instance.DependencyUnavailable,
instance.AuthenticationFailed, instance.InsufficientPrivileges,
} {
reason, message := managementResult(failure)
if reason == "" || message == "" || (reason == "ManagementReady") != (failure == instance.NoFailure) {
t.Fatal("领域能力判定与状态不一致")
}
}
}
func TestMetadataCannotEstablishManagementReadiness(t *testing.T) {
source := &sourceStub{}
source.credentials, _ = NewCredentials("test", serviceTestPassword)
connector := &connectorStub{}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
target := serviceTarget(t, "uid", "postgres.test", "management", 1)
if _, err := service.ObserveMetadata(t.Context(), target); err != nil {
t.Fatal(err)
}
connector.databases[0].metadata.Management = instance.ManagementChecks{
Connection: instance.CheckPassed, Metadata: instance.CheckPassed,
Roles: instance.CheckPassed, Databases: instance.CheckPassed,
Grants: instance.CheckPassed, Extensions: instance.CheckPassed,
}
observation, err := service.ObserveMetadata(t.Context(), target)
if err != nil {
t.Fatal(err)
}
capabilities, err := observation.Capabilities()
if err != nil {
t.Fatal(err)
}
aggregate, err := instance.Reconstitute(target, instance.Snapshot{}, false)
if err != nil {
t.Fatal(err)
}
if err := aggregate.BeginValidation(); err != nil {
t.Fatal(err)
}
if err := aggregate.AssessManagement(capabilities); err != nil {
t.Fatal(err)
}
if aggregate.Snapshot().Failure != instance.ObservationIncomplete {
t.Fatal("metadata 入口不应携带完整管理检查")
}
}
@@ -36,6 +36,7 @@ var (
// Metadata 只查询版本与可用扩展,不能产生领域 Ready。 // Metadata 只查询版本与可用扩展,不能产生领域 Ready。
type Database interface { type Database interface {
InspectMetadata(context.Context) (DatabaseMetadata, error) InspectMetadata(context.Context) (DatabaseMetadata, error)
InspectManagement(context.Context) (DatabaseMetadata, error)
Close() Close()
} }
@@ -82,17 +83,26 @@ func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.Ob
// ObserveMetadata 返回当前目标和凭据下的版本与扩展;任何失败均丢弃全部结果。 // ObserveMetadata 返回当前目标和凭据下的版本与扩展;任何失败均丢弃全部结果。
// 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。 // 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。
func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.ObservationTarget) (MetadataObservation, error) { func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.ObservationTarget) (InstanceObservation, error) {
return s.observe(ctx, target, false)
}
// ObserveManagement 复用同一凭据刷新与回读边界,但每轮重新检查原生管理能力。
func (s *InstanceService) ObserveManagement(ctx context.Context, target instance.ObservationTarget) (InstanceObservation, error) {
return s.observe(ctx, target, true)
}
func (s *InstanceService) observe(ctx context.Context, target instance.ObservationTarget, management bool) (InstanceObservation, error) {
if err := target.Validate(); err != nil { if err := target.Validate(); err != nil {
return MetadataObservation{}, err return InstanceObservation{}, err
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.closed { if s.closed {
return MetadataObservation{}, ErrClosed return InstanceObservation{}, ErrClosed
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return MetadataObservation{}, err return InstanceObservation{}, err
} }
// 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。 // 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。
@@ -100,11 +110,11 @@ func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.O
credentials, err := s.source.Read(ctx, target.Definition().AdminCredential()) credentials, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil { if err != nil {
s.release(name) s.release(name)
return MetadataObservation{}, credentialError(err) return InstanceObservation{}, credentialError(err)
} }
if credentials.username == "" || credentials.password == "" { if credentials.username == "" || credentials.password == "" {
s.release(name) s.release(name)
return MetadataObservation{}, ErrCredentialsInvalid return InstanceObservation{}, ErrCredentialsInvalid
} }
// 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。 // 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。
@@ -117,7 +127,7 @@ func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.O
if current == nil { if current == nil {
database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials) database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials)
if err != nil { if err != nil {
return MetadataObservation{}, err return InstanceObservation{}, err
} }
current = &entry{ current = &entry{
target: target, target: target,
@@ -127,30 +137,38 @@ func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.O
s.entries[name] = current s.entries[name] = current
} }
metadata, err := current.database.InspectMetadata(ctx) var metadata DatabaseMetadata
if management {
metadata, err = current.database.InspectManagement(ctx)
} else {
metadata, err = current.database.InspectMetadata(ctx)
// 即使 adapter 误填权限,也不能把只读 metadata 入口升级为 Ready。
metadata.Management = instance.ManagementChecks{}
}
if err != nil { if err != nil {
s.release(name) s.release(name)
return MetadataObservation{}, err return InstanceObservation{}, err
} }
if metadata.Version == "" { if metadata.Version == "" {
s.release(name) s.release(name)
return MetadataObservation{}, ErrObservation return InstanceObservation{}, ErrObservation
} }
// 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。 // 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。
latest, err := s.source.Read(ctx, target.Definition().AdminCredential()) latest, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil { if err != nil {
s.release(name) s.release(name)
return MetadataObservation{}, credentialError(err) return InstanceObservation{}, credentialError(err)
} }
if latest != credentials { if latest != credentials {
s.release(name) s.release(name)
return MetadataObservation{}, ErrCredentialsChanged return InstanceObservation{}, ErrCredentialsChanged
} }
return MetadataObservation{ return InstanceObservation{
target: target, target: target,
version: metadata.Version, version: metadata.Version,
extensions: instance.ObserveExtensionSupport(metadata.AvailableExtensions), extensions: instance.ObserveExtensionSupport(metadata.AvailableExtensions),
management: metadata.Management,
}, nil }, nil
} }
@@ -42,6 +42,10 @@ type databaseStub struct {
metadata DatabaseMetadata metadata DatabaseMetadata
} }
func (d *databaseStub) InspectManagement(ctx context.Context) (DatabaseMetadata, error) {
return d.InspectMetadata(ctx)
}
func (d *databaseStub) InspectMetadata(context.Context) (DatabaseMetadata, error) { func (d *databaseStub) InspectMetadata(context.Context) (DatabaseMetadata, error) {
return d.metadata, d.err return d.metadata, d.err
} }
+14 -6
View File
@@ -18,23 +18,31 @@ package application
import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance" import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
// DatabaseMetadata 是一次只读查询的事实,不包含管理权限或完整就绪结论。 // DatabaseMetadata 是一次只读查询的事实,可附带原生管理检查,但不包含就绪结论。
// AvailableExtensions 是服务器提供的可用列表,不是已安装列表或安装授权。 // AvailableExtensions 是服务器提供的可用列表,不是已安装列表或安装授权。
type DatabaseMetadata struct { type DatabaseMetadata struct {
Version string Version string
AvailableExtensions []string AvailableExtensions []string
// Management 仅由 InspectManagement 填充;metadata 查询必须保持未观察。
Management instance.ManagementChecks
} }
// MetadataObservation 只在查询成功且有效凭据再次核对一致后产生。 // InstanceObservation 只在查询成功且有效凭据再次核对一致后产生。
// target 绑定本次调用,而非连接最初创建时的 generation;零值表示没有观察。 // target 绑定本次调用,而非连接最初创建时的 generation;零值表示没有观察。
type MetadataObservation struct { type InstanceObservation struct {
target instance.ObservationTarget target instance.ObservationTarget
version string version string
extensions instance.ExtensionSupport extensions instance.ExtensionSupport
management instance.ManagementChecks
} }
func (o MetadataObservation) Target() instance.ObservationTarget { return o.target } // Capabilities 保留缺项为未观察;不能从 metadata 的成功补齐管理检查。
func (o MetadataObservation) Version() string { return o.version } func (o InstanceObservation) Capabilities() (instance.CapabilityObservation, error) {
func (o MetadataObservation) Extensions() instance.ExtensionSupport { return instance.NewCapabilityObservation(o.target, o.version, o.management)
}
func (o InstanceObservation) Target() instance.ObservationTarget { return o.target }
func (o InstanceObservation) Version() string { return o.version }
func (o InstanceObservation) Extensions() instance.ExtensionSupport {
return o.extensions return o.extensions
} }
@@ -0,0 +1,47 @@
// Package controller 将 Database 用例接入 Kubernetes 事件和重试调度。
package controller
import (
"context"
"time"
"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"
)
const dependencyRetry = 30 * time.Second
type BindingReconciler struct {
Client client.Client
Reader client.Reader
}
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants/finalizers,verbs=update
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases/finalizers,verbs=update
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch
func (r *BindingReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
resources := &kubernetes.BindingResources{Client: r.Client, Reader: r.Reader}
service := application.BindingService{Resources: resources}
result, err := service.Reconcile(ctx, request.Namespace, request.Name)
if err != nil {
return ctrl.Result{}, err
}
if err := resources.Present(ctx, result); err != nil {
return ctrl.Result{}, err
}
if result.RetrySoon {
return ctrl.Result{RequeueAfter: time.Millisecond}, nil
}
if result.Tenant == nil {
return ctrl.Result{}, nil
}
// watch 是主入口,低频重查覆盖依赖事件映射失败,不做冲突忙循环。
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
}
@@ -0,0 +1,518 @@
package controller
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
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/runtime"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/envtest"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/yaml"
)
const (
bindingNamespace = "binding-tests"
phaseBinding = "Binding"
phaseBound = "Bound"
reasonConflict = "Conflict"
reasonDependency = "DependencyUnavailable"
TenantFinalizer = kubernetes.TenantFinalizer
DatabaseFinalizer = kubernetes.DatabaseFinalizer
)
func targetDatabaseName(tenant *databasev1alpha1.PostgreSQLTenant) string {
return "tenant-" + string(tenant.UID)
}
func tenantReference(tenant *databasev1alpha1.PostgreSQLTenant) *databasev1alpha1.TenantReference {
return &databasev1alpha1.TenantReference{
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
}
}
func TestBindingController(t *testing.T) {
apiClient, config, scheme := bindingEnvironment(t)
t.Run("动态申请和幂等重试", func(t *testing.T) { testDynamicBinding(t, apiClient) })
t.Run("双向写入之间重启", func(t *testing.T) { testBindingRestart(t, apiClient) })
t.Run("并发申请只有一个绑定", func(t *testing.T) { testConcurrentBinding(t, apiClient) })
t.Run("Released和同名重建", func(t *testing.T) { testBindingIdentity(t, apiClient) })
t.Run("目标固定与删除保护", func(t *testing.T) { testBindingProtection(t, apiClient) })
t.Run("拒绝陈旧观察和新实例身份", func(t *testing.T) { testStaleObservation(t, apiClient) })
t.Run("呈现结果不覆盖并发修改", func(t *testing.T) { testPresentationVersion(t, apiClient) })
t.Run("依赖稍后出现的watch", func(t *testing.T) { testBindingWatch(t, apiClient, config, scheme) })
}
func bindingEnvironment(t *testing.T) (client.Client, *rest.Config, *runtime.Scheme) {
t.Helper()
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
t.Skip("运行 make test 启动真实 API server")
}
scheme := runtime.NewScheme()
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := rbacv1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
crdPath, err := filepath.Abs("../../../config/crd/bases")
if err != nil {
t.Fatal(err)
}
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}, ErrorIfCRDPathMissing: true}
config, err := environment.Start()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Error(err)
}
})
apiClient, err := client.New(config, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
namespace := &corev1.Namespace{}
namespace.Name = bindingNamespace
requireCreate(t, apiClient, namespace)
return apiClient, config, scheme
}
func testDynamicBinding(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "dynamic-instance")
tenant := provisionTenant("dynamic", instance.Name)
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef == nil || tenant.Status.Phase != phaseBound {
t.Fatal("动态申请未建立双向绑定")
}
database := &databasev1alpha1.PostgreSQLDatabase{}
database.Name = targetDatabaseName(tenant)
reload(t, apiClient, database)
if database.Spec.Database != "dynamic" || database.Spec.LoginRole != "dynamic" ||
database.Spec.ReclaimPolicy != databasev1alpha1.ReclaimRetain ||
*database.Spec.TenantRef != *tenantReference(tenant) || database.Status.InstanceUID != instance.UID {
t.Fatal("动态资源目标、默认值或身份不符")
}
if len(database.OwnerReferences) != 0 || !controllerutil.ContainsFinalizer(database, DatabaseFinalizer) {
t.Fatal("Database 不应随 Tenant GC,且必须先有删除保护")
}
beforeTenant, beforeDatabase := tenant.ResourceVersion, database.ResourceVersion
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
reload(t, apiClient, database)
if tenant.ResourceVersion != beforeTenant || database.ResourceVersion != beforeDatabase {
t.Fatal("幂等重试产生了无意义写入")
}
assertNotReady(t, tenant, "BindingComplete")
}
// 只在真实 API 调用边界注入错误,底层仍使用 API server 的并发、status 与 CEL 语义。
type failedTenantStatusClient struct {
client.Client
}
func (c *failedTenantStatusClient) Status() client.SubResourceWriter {
return &failedTenantStatusWriter{SubResourceWriter: c.Client.Status()}
}
type failedTenantStatusWriter struct {
client.SubResourceWriter
}
func (w *failedTenantStatusWriter) Update(ctx context.Context, object client.Object, options ...client.SubResourceUpdateOption) error {
if tenant, ok := object.(*databasev1alpha1.PostgreSQLTenant); ok && tenant.Status.DatabaseRef != nil {
return errors.New("injected tenant status write failure")
}
return w.SubResourceWriter.Update(ctx, object, options...)
}
func testBindingRestart(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "restart-instance")
tenant := provisionTenant("restart", instance.Name)
requireCreate(t, apiClient, tenant)
first := &BindingReconciler{Client: &failedTenantStatusClient{Client: apiClient}, Reader: apiClient}
if _, err := first.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)}); err == nil {
t.Fatal("预期第二次绑定写入失败")
}
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef != nil || tenant.Status.Phase != phaseBinding {
t.Fatal("失败后不应伪造申请侧完成")
}
database := &databasev1alpha1.PostgreSQLDatabase{}
database.Name = targetDatabaseName(tenant)
reload(t, apiClient, database)
if database.Spec.TenantRef == nil || database.Spec.TenantRef.UID != tenant.UID {
t.Fatal("失败后资源侧绑定不应回滚")
}
// 新建 reconciler,无旧内存,只从 API 中读取进度。
restarted := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, restarted, tenant)
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef == nil || tenant.Status.DatabaseRef.UID != database.UID {
t.Fatal("重启后未补齐同一资源绑定")
}
}
func testConcurrentBinding(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "concurrent-instance")
database := availableDatabase(t, apiClient, "concurrent-db", instance)
tenants := []*databasev1alpha1.PostgreSQLTenant{
existingTenant("contender-one", database.Name), existingTenant("contender-two", database.Name),
}
for _, tenant := range tenants {
requireCreate(t, apiClient, tenant)
}
var workers sync.WaitGroup
results := make(chan error, len(tenants))
for _, tenant := range tenants {
workers.Go(func() {
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)})
results <- err
})
}
workers.Wait()
close(results)
for err := range results {
if err != nil && !apierrors.IsConflict(err) {
t.Fatalf("并发协调出现非版本冲突错误: %v", err)
}
}
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
bound := 0
for _, tenant := range tenants {
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef != nil {
bound++
} else {
assertNotReady(t, tenant, reasonConflict)
}
}
if bound != 1 {
t.Fatalf("绑定申请数 = %d, want 1", bound)
}
}
func testBindingIdentity(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "identity-instance")
database := availableDatabase(t, apiClient, "released-db", instance)
database.Spec.TenantRef = &databasev1alpha1.TenantReference{
Namespace: bindingNamespace, Name: "identity", UID: "previous-tenant-uid",
}
if err := apiClient.Update(t.Context(), database); err != nil {
t.Fatal(err)
}
database.Status.Phase = "Released"
if err := apiClient.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
tenant := existingTenant("identity", database.Name)
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonConflict)
if tenant.Status.DatabaseRef != nil {
t.Fatal("同名新 Tenant 不应继承旧 UID 的绑定")
}
// 同名动态记录没有匹配 UID,不能通过名称猜测这是先前创建的资源。
dynamic := provisionTenant("collision", instance.Name)
requireCreate(t, apiClient, dynamic)
collision := availableDatabase(t, apiClient, targetDatabaseName(dynamic), instance)
reconcileOK(t, reconciler, dynamic)
reload(t, apiClient, dynamic)
assertNotReady(t, dynamic, reasonConflict)
reload(t, apiClient, collision)
if collision.Spec.TenantRef != nil {
t.Fatal("同名未知记录被认领")
}
}
func testBindingProtection(t *testing.T, apiClient client.Client) {
tenant := provisionTenant("protection", "missing-instance")
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonDependency)
original := tenant.DeepCopy()
tenant.Spec.Provision.InstanceRef.Name = "other-instance"
if err := apiClient.Update(t.Context(), tenant); !apierrors.IsInvalid(err) {
t.Fatalf("Binding 后目标修改 = %v, want Invalid", err)
}
tenant = original
if err := apiClient.Delete(t.Context(), tenant); err != nil {
t.Fatal(err)
}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
if tenant.DeletionTimestamp.IsZero() || !controllerutil.ContainsFinalizer(tenant, TenantFinalizer) {
t.Fatal("未实现清理时不应提前移除删除保护")
}
assertNotReady(t, tenant, "DeletionPending")
}
func testStaleObservation(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "stale-instance")
database := availableDatabase(t, apiClient, "stale-database", instance)
// 被观察后不能更换实际数据库目标,修改回收策略仍允许。
changed := database.DeepCopy()
changed.Spec.Database = "different"
if err := apiClient.Update(t.Context(), changed); !apierrors.IsInvalid(err) {
t.Fatalf("已观察目标修改 = %v, want Invalid", err)
}
database.Spec.ReclaimPolicy = databasev1alpha1.ReclaimDelete
if err := apiClient.Update(t.Context(), database); err != nil {
t.Fatal(err)
}
tenant := existingTenant("stale", database.Name)
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonDependency)
// 即使同名新 Instance 已 Ready,也不能覆盖 Database 记录的旧 Instance UID。
if err := apiClient.Delete(t.Context(), instance); err != nil {
t.Fatal(err)
}
readyInstance(t, apiClient, instance.Name)
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonConflict)
}
func testBindingWatch(t *testing.T, apiClient client.Client, config *rest.Config, scheme *runtime.Scheme) {
controllerConfig := bindingControllerConfig(t, apiClient, config)
// controller-runtime 的名称登记跨 manager 生命周期保留;允许 go test -count 重复顺序启动。
// 每轮 cleanup 等待旧 manager 退出,生产 manager 不关闭名称校验。
skipRepeatedTestName := true
manager, err := ctrl.NewManager(controllerConfig, ctrl.Options{
Scheme: scheme, Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedTestName},
})
if err != nil {
t.Fatal(err)
}
reconciler := &BindingReconciler{}
if err := reconciler.SetupWithManager(t.Context(), manager); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- manager.Start(ctx) }()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(10 * time.Second):
t.Error("manager 未及时停止")
}
})
if !manager.GetCache().WaitForCacheSync(ctx) {
t.Fatal("cache 未同步")
}
tenant := existingTenant("watch", "late-database")
requireCreate(t, apiClient, tenant)
waitForTenant(t, apiClient, tenant, func(current *databasev1alpha1.PostgreSQLTenant) bool {
condition := meta.FindStatusCondition(current.Status.Conditions, "Ready")
return condition != nil && condition.Reason == reasonDependency
})
instance := readyInstance(t, apiClient, "late-instance")
availableDatabase(t, apiClient, "late-database", instance)
// 小于低频重试周期,只能靠 informer/watch 事件收敛,而不是手工调用 Reconcile。
waitForTenant(t, apiClient, tenant, func(current *databasev1alpha1.PostgreSQLTenant) bool {
return current.Status.Phase == phaseBound && current.Status.DatabaseRef != nil
})
}
func testPresentationVersion(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "presentation-instance")
tenant := provisionTenant("presentation", instance.Name)
requireCreate(t, apiClient, tenant)
resources := &kubernetes.BindingResources{Client: apiClient, Reader: apiClient}
service := application.BindingService{Resources: resources}
result, err := service.Reconcile(t.Context(), tenant.Namespace, tenant.Name)
if err != nil {
t.Fatal(err)
}
// 用例完成资源侧写入后,模拟另一个客户端修改不属于绑定目标的字段。
reload(t, apiClient, tenant)
tenant.Spec.SecretName = "updated-delivery"
tenant.Annotations = map[string]string{"example.test/keep": "preserved"}
if err := apiClient.Update(t.Context(), tenant); err != nil {
t.Fatal(err)
}
if err := resources.Present(t.Context(), result); !apierrors.IsConflict(err) {
t.Fatalf("过期结果呈现 = %v, want Conflict", err)
}
reconcileOK(t, &BindingReconciler{Client: apiClient, Reader: apiClient}, tenant)
reload(t, apiClient, tenant)
if tenant.Status.Phase != phaseBound || tenant.Spec.SecretName != "updated-delivery" ||
tenant.Annotations["example.test/keep"] != "preserved" {
t.Fatal("重新协调未完成绑定或覆盖了其他字段")
}
}
func bindingControllerConfig(t *testing.T, apiClient client.Client, config *rest.Config) *rest.Config {
t.Helper()
content, err := os.ReadFile("../../../config/rbac/role.yaml")
if err != nil {
t.Fatal(err)
}
role := &rbacv1.ClusterRole{}
if err := yaml.Unmarshal(content, role); err != nil {
t.Fatal(err)
}
requireCreate(t, apiClient, role)
binding := &rbacv1.ClusterRoleBinding{}
binding.Name = "binding-controller-test"
binding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}
binding.Subjects = []rbacv1.Subject{{APIGroup: rbacv1.GroupName, Kind: "User", Name: binding.Name}}
requireCreate(t, apiClient, binding)
controllerConfig := rest.CopyConfig(config)
controllerConfig.Impersonate = rest.ImpersonationConfig{UserName: binding.Name}
restricted, err := client.New(controllerConfig, client.Options{Scheme: apiClient.Scheme()})
if err != nil {
t.Fatal(err)
}
// 绑定角色没有凭据读取权限,也不需要测试中的管理用户权限。
secret := &corev1.Secret{}
if err := restricted.Get(t.Context(), client.ObjectKey{Namespace: bindingNamespace, Name: "not-readable"}, secret); !apierrors.IsForbidden(err) {
t.Fatalf("绑定 controller 读取 Secret = %v, want Forbidden", err)
}
return controllerConfig
}
func waitForTenant(t *testing.T, apiClient client.Client, tenant *databasev1alpha1.PostgreSQLTenant,
predicate func(*databasev1alpha1.PostgreSQLTenant) bool) {
t.Helper()
deadline := time.NewTimer(10 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
current := &databasev1alpha1.PostgreSQLTenant{}
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(tenant), current); err == nil && predicate(current) {
return
}
select {
case <-deadline.C:
t.Fatalf("Tenant %s 未在 watch 期限内收敛", tenant.Name)
case <-ticker.C:
}
}
}
func readyInstance(t *testing.T, apiClient client.Client, name string) *databasev1alpha1.PostgreSQLInstance {
t.Helper()
instance := &databasev1alpha1.PostgreSQLInstance{}
instance.Name = name
instance.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{Host: "postgres.example.test", HostAddr: "127.0.0.1"}
instance.Spec.AdminCredentialRef.Name = "admin"
requireCreate(t, apiClient, instance)
instance.Status.Conditions = readyConditions(instance.Generation)
if err := apiClient.Status().Update(t.Context(), instance); err != nil {
t.Fatal(err)
}
return instance
}
func availableDatabase(t *testing.T, apiClient client.Client, name string,
instance *databasev1alpha1.PostgreSQLInstance) *databasev1alpha1.PostgreSQLDatabase {
t.Helper()
database := &databasev1alpha1.PostgreSQLDatabase{}
database.Name = name
database.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(instance.Name)},
Database: "existing", LoginRole: "existing", Source: "Import",
CredentialRef: &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing/app"},
}
requireCreate(t, apiClient, database)
database.Status.InstanceUID = instance.UID
database.Status.Phase = "Available"
database.Status.Conditions = readyConditions(database.Generation)
if err := apiClient.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
return database
}
func readyConditions(generation int64) []metav1.Condition {
return []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Verified",
Message: "测试提供的后端观察", ObservedGeneration: generation, LastTransitionTime: metav1.Now()}}
}
func provisionTenant(name, instance string) *databasev1alpha1.PostgreSQLTenant {
tenant := &databasev1alpha1.PostgreSQLTenant{}
tenant.Name, tenant.Namespace = name, bindingNamespace
tenant.Spec.Provision = &databasev1alpha1.DatabaseProvisionRequest{
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(instance)},
}
return tenant
}
func existingTenant(name, database string) *databasev1alpha1.PostgreSQLTenant {
tenant := &databasev1alpha1.PostgreSQLTenant{}
tenant.Name, tenant.Namespace = name, bindingNamespace
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: databasev1alpha1.ObjectName(database)}
return tenant
}
func requireCreate(t *testing.T, apiClient client.Client, object client.Object) {
t.Helper()
if err := apiClient.Create(t.Context(), object); err != nil {
t.Fatal(err)
}
}
func reload(t *testing.T, apiClient client.Client, object client.Object) {
t.Helper()
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(object), object); err != nil {
t.Fatal(err)
}
}
func reconcileOK(t *testing.T, reconciler *BindingReconciler, tenant *databasev1alpha1.PostgreSQLTenant) {
t.Helper()
if _, err := reconciler.Reconcile(t.Context(), ctrl.Request{
NamespacedName: client.ObjectKeyFromObject(tenant),
}); err != nil {
t.Fatal(err)
}
}
func assertNotReady(t *testing.T, tenant *databasev1alpha1.PostgreSQLTenant, reason string) {
t.Helper()
condition := meta.FindStatusCondition(tenant.Status.Conditions, "Ready")
if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != reason {
t.Fatalf("Ready condition 不符: %+v", condition)
}
}
@@ -0,0 +1,75 @@
package controller
import (
"context"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
)
const targetDatabaseIndex = "database.bindingTarget"
func (r *BindingReconciler) SetupWithManager(ctx context.Context, manager ctrl.Manager) error {
if r.Client == nil {
r.Client = manager.GetClient()
}
if r.Reader == nil {
r.Reader = manager.GetAPIReader()
}
if err := manager.GetFieldIndexer().IndexField(ctx, &databasev1alpha1.PostgreSQLTenant{},
targetDatabaseIndex, func(object client.Object) []string {
tenant := object.(*databasev1alpha1.PostgreSQLTenant)
return []string{kubernetes.BindingTargetName(tenant)}
}); err != nil {
return err
}
return ctrl.NewControllerManagedBy(manager).
Named("database-binding").
For(&databasev1alpha1.PostgreSQLTenant{}).
Watches(&databasev1alpha1.PostgreSQLDatabase{}, handler.EnqueueRequestsFromMapFunc(r.requestsForDatabase)).
Watches(&databasev1alpha1.PostgreSQLInstance{}, handler.EnqueueRequestsFromMapFunc(r.requestsForInstance)).
Complete(r)
}
func (r *BindingReconciler) requestsForDatabase(ctx context.Context, object client.Object) []ctrl.Request {
tenants := &databasev1alpha1.PostgreSQLTenantList{}
if err := r.Client.List(ctx, tenants, client.MatchingFields{targetDatabaseIndex: object.GetName()}); err != nil {
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Database 事件;等待低频重试")
return nil
}
requests := make([]ctrl.Request, 0, len(tenants.Items))
for _, tenant := range tenants.Items {
requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tenant)})
}
return requests
}
func (r *BindingReconciler) requestsForInstance(ctx context.Context, object client.Object) []ctrl.Request {
// 当前只有 homelab 内部申请,使用 cache 列表过滤,不维护另一份实例/租户集合。
tenants := &databasev1alpha1.PostgreSQLTenantList{}
if err := r.Client.List(ctx, tenants); err != nil {
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Instance 事件;等待低频重试")
return nil
}
requests := make([]ctrl.Request, 0, len(tenants.Items))
for _, tenant := range tenants.Items {
instanceName := ""
if tenant.Spec.Provision != nil {
instanceName = string(tenant.Spec.Provision.InstanceRef.Name)
} else if tenant.Spec.DatabaseRef != nil {
database := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Client.Get(ctx, types.NamespacedName{Name: string(tenant.Spec.DatabaseRef.Name)}, database); err != nil {
continue
}
instanceName = string(database.Spec.InstanceRef.Name)
}
if instanceName == object.GetName() {
requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tenant)})
}
}
return requests
}
@@ -0,0 +1,42 @@
package controller
import (
"context"
"time"
"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"
)
type InstanceReconciler struct {
Client client.Client
Reader client.Reader
Observer application.InstanceObserver
SecretNamespace string
}
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/finalizers,verbs=update
// Secret 权限单独声明为 namespace Role,不放入生成的 ClusterRole。
func (r *InstanceReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
resources := &kubernetes.InstanceResources{Client: r.Client, Reader: r.Reader}
service := application.InstanceReconciliation{Resources: resources, Observer: r.Observer}
observationContext, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
result, err := service.Reconcile(observationContext, request.Name)
if err != nil {
return ctrl.Result{}, err
}
// 查询超时后仍用 worker context 保存安全失败结果;manager 停止时不强行写入。
if err := resources.PresentInstance(ctx, result); err != nil {
return ctrl.Result{}, err
}
if result.Record == nil || result.RemoveProtection {
return ctrl.Result{}, nil
}
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
}
@@ -0,0 +1,192 @@
package controller
import (
"context"
"errors"
"testing"
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"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"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"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
type instanceBackend struct {
checks instance.ManagementChecks
err error
inspect func()
closed int
}
func (b *instanceBackend) Read(context.Context, instance.CredentialReference) (application.Credentials, error) {
return application.NewCredentials("fixture", "test-only-instance-password")
}
func (b *instanceBackend) Connect(context.Context, instance.Endpoint, application.Credentials) (application.Database, error) {
return b, nil
}
func (b *instanceBackend) InspectMetadata(context.Context) (application.DatabaseMetadata, error) {
return application.DatabaseMetadata{Version: "18"}, nil
}
func (b *instanceBackend) InspectManagement(context.Context) (application.DatabaseMetadata, error) {
if b.inspect != nil {
b.inspect()
}
return application.DatabaseMetadata{Version: "18", Management: b.checks}, b.err
}
func (b *instanceBackend) Close() { b.closed++ }
func newInstanceReconciler(t *testing.T, apiClient client.Client, backend *instanceBackend) *InstanceReconciler {
t.Helper()
service, err := application.NewInstanceService(backend, backend)
if err != nil {
t.Fatal(err)
}
t.Cleanup(service.Close)
return &InstanceReconciler{Client: apiClient, Reader: apiClient, Observer: service}
}
func reconcileInstance(t *testing.T, reconciler *InstanceReconciler, object *databasev1alpha1.PostgreSQLInstance) {
t.Helper()
if _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(object)}); err != nil {
t.Fatal(err)
}
}
func assertInstanceReason(t *testing.T, object *databasev1alpha1.PostgreSQLInstance, reason string) {
t.Helper()
condition := meta.FindStatusCondition(object.Status.Conditions, "Ready")
if condition == nil || condition.Reason != reason || condition.ObservedGeneration != object.Generation {
t.Fatalf("Instance 状态不是当前 generation 的 %s", reason)
}
if reason != "ManagementReady" && condition.Status != metav1.ConditionFalse {
t.Fatal("失败状态仍为 Ready")
}
}
func TestInstanceObservationAPI(t *testing.T) {
apiClient, _, _ := bindingEnvironment(t)
backend := &instanceBackend{checks: instance.ManagementChecks{
Connection: instance.CheckPassed, Metadata: instance.CheckPassed,
Roles: instance.CheckPassed, Databases: instance.CheckPassed,
Grants: instance.CheckPassed, Extensions: instance.CheckPassed,
}}
reconciler := newInstanceReconciler(t, apiClient, backend)
object := readyInstance(t, apiClient, "observed-instance")
backend.inspect = func() {
current := &databasev1alpha1.PostgreSQLInstance{}
current.Name = object.Name
reload(t, apiClient, current)
if !controllerutil.ContainsFinalizer(current, kubernetes.InstanceFinalizer) {
t.Fatal("观察早于 finalizer 持久化")
}
}
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "ManagementReady")
if object.Status.Phase != string(instance.PhaseReady) || object.Status.PostgreSQLVersion != "18" {
t.Fatal("当前成功观察未呈现")
}
before := object.ResourceVersion
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
if object.ResourceVersion != before {
t.Fatal("相同观察不应反复写入 status")
}
backend.err = application.ErrAuthentication
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "AuthenticationFailed")
if backend.closed != 1 || object.Status.PostgreSQLVersion != "" {
t.Fatal("观察失败应释放连接并清除旧版本结果")
}
backend.err = nil
backend.checks.Grants = instance.CheckUnobserved
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "ObservationIncomplete")
backend.checks.Grants = instance.CheckPassed
// 用新 service/reconciler 恢复;不依赖上轮领域对象或 Ready。
reconciler = newInstanceReconciler(t, apiClient, backend)
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "ManagementReady")
backend.inspect = func() {
reload(t, apiClient, object)
object.Annotations = map[string]string{"concurrent": "kept-by-instance-test"}
if err := apiClient.Update(t.Context(), object); err != nil {
t.Fatal(err)
}
}
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(object)})
if !apierrors.IsConflict(err) {
t.Fatal("旧观察不应覆盖在途 API 修改")
}
backend.inspect = nil
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
if object.Annotations["concurrent"] != "kept-by-instance-test" {
t.Fatal("重试覆盖了其他字段")
}
}
type failedReferenceReader struct{ client.Reader }
func (*failedReferenceReader) List(context.Context, client.ObjectList, ...client.ListOption) error {
return errors.New("injected reference list failure")
}
func TestInstanceDeletionProtection(t *testing.T) {
apiClient, _, _ := bindingEnvironment(t)
backend := &instanceBackend{}
reconciler := newInstanceReconciler(t, apiClient, backend)
object := readyInstance(t, apiClient, "protected-instance")
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
database := availableDatabase(t, apiClient, "retained-database", object)
database.Status.Phase = "Released"
if err := apiClient.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
tenant := provisionTenant("pending-request", object.Name)
requireCreate(t, apiClient, tenant)
if err := apiClient.Delete(t.Context(), object); err != nil {
t.Fatal(err)
}
backend.inspect = func() { t.Fatal("删除中不应连接 PostgreSQL") }
reconciler.Reader = &failedReferenceReader{Reader: apiClient}
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, reasonDependency)
reconciler.Reader = apiClient
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "InstanceInUse")
if object.Status.Phase != string(instance.PhaseDeleting) || backend.closed != 1 {
t.Fatal("删除没有停止本地观察")
}
if err := apiClient.Delete(t.Context(), database); err != nil {
t.Fatal(err)
}
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "InstanceInUse")
if err := apiClient.Delete(t.Context(), tenant); err != nil {
t.Fatal(err)
}
reconcileInstance(t, reconciler, object)
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(object), object); !apierrors.IsNotFound(err) {
t.Fatal("最后一个引用解除后 Instance 应可删除")
}
reconcileInstance(t, reconciler, object)
}
@@ -0,0 +1,77 @@
package controller
import (
"context"
"errors"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/validation"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
)
// InstanceCacheOptions 必须在创建 manager 时使用;只 watch 固定 namespace 的 Secret metadata。
// SecretCredentials 始终直读 API,不会令共享 cache 保存密码。
func InstanceCacheOptions(namespace string) cache.Options {
return cache.Options{ByObject: map[client.Object]cache.ByObject{
&corev1.Secret{}: {Namespaces: map[string]cache.Config{namespace: {}}},
}}
}
func (r *InstanceReconciler) SetupWithManager(manager ctrl.Manager) error {
if r.Observer == nil || len(validation.IsDNS1123Label(r.SecretNamespace)) != 0 {
return errors.New("instance observer and valid management Secret namespace required")
}
if r.Client == nil {
r.Client = manager.GetClient()
}
if r.Reader == nil {
r.Reader = manager.GetAPIReader()
}
return ctrl.NewControllerManagedBy(manager).
Named("database-instance").
For(&databasev1alpha1.PostgreSQLInstance{}).
WatchesMetadata(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.instancesForSecret)).
Watches(&databasev1alpha1.PostgreSQLDatabase{}, handler.EnqueueRequestsFromMapFunc(r.instanceForReference)).
Watches(&databasev1alpha1.PostgreSQLTenant{}, handler.EnqueueRequestsFromMapFunc(r.instanceForReference)).
Complete(r)
}
func (r *InstanceReconciler) instancesForSecret(ctx context.Context, object client.Object) []ctrl.Request {
if object.GetNamespace() != r.SecretNamespace {
return nil
}
instances := &databasev1alpha1.PostgreSQLInstanceList{}
if err := r.Client.List(ctx, instances); err != nil {
ctrl.LoggerFrom(ctx).Error(err, "无法映射管理 Secret 事件;等待低频重试")
return nil
}
var requests []ctrl.Request
for _, item := range instances.Items {
if string(item.Spec.AdminCredentialRef.Name) == object.GetName() {
request := ctrl.Request{Name: item.Name}
requests = append(requests, request)
}
}
return requests
}
func (r *InstanceReconciler) instanceForReference(_ context.Context, object client.Object) []ctrl.Request {
var name string
switch item := object.(type) {
case *databasev1alpha1.PostgreSQLDatabase:
name = string(item.Spec.InstanceRef.Name)
case *databasev1alpha1.PostgreSQLTenant:
if item.Spec.Provision != nil {
name = string(item.Spec.Provision.InstanceRef.Name)
}
}
if name == "" {
return nil
}
request := ctrl.Request{Name: name}
return []ctrl.Request{request}
}
+136
View File
@@ -0,0 +1,136 @@
// Package binding 定义资源与申请的纯绑定规则,不访问 Kubernetes 或数据库。
package binding
import (
"fmt"
"regexp"
)
const (
Binding = "Binding"
Bound = "Bound"
Deleting = "Deleting"
Conflict = "Conflict"
DependencyUnavailable = "DependencyUnavailable"
)
type Identity struct {
Name string
UID string
}
type TenantIdentity struct {
Namespace string
Name string
UID string
}
// Request 保留用户输入;Resolve 产生默认值已确定的目标,不修改原请求。
type Request struct {
Provision *ProvisionRequest
ExistingDatabase string
}
type ProvisionRequest struct {
Instance string
Database string
LoginRole string
}
type Target struct {
Name string
Provision *ProvisionRequest
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
func (r Request) Resolve(tenant TenantIdentity) (Target, error) {
if (r.Provision == nil) == (r.ExistingDatabase == "") {
return Target{}, fmt.Errorf("必须且只能选择动态申请或已有 Database")
}
if r.Provision == nil {
return Target{Name: r.ExistingDatabase}, nil
}
provision := *r.Provision
if provision.Database == "" {
provision.Database = tenant.Name
}
if provision.LoginRole == "" {
provision.LoginRole = tenant.Name
}
if !identifier.MatchString(provision.Database) || !identifier.MatchString(provision.LoginRole) {
return Target{}, fmt.Errorf("动态 database/loginRole 必须符合 PostgreSQL identifier 规则;省略时使用 Tenant 名称")
}
return Target{Name: DynamicDatabaseName(tenant.UID), Provision: &provision}, nil
}
func DynamicDatabaseName(tenantUID string) string { return "tenant-" + tenantUID }
type Tenant struct {
Identity TenantIdentity
Request Request
Phase string
Deleting bool
Database *Identity
}
// Database 是绑定所需的资源事实,不包含存储版本、Conditions 或客户端对象。
type Database struct {
Identity Identity
Instance string
InstanceUID string
Name string
LoginRole string
Source string
Tenant *TenantIdentity
Phase string
Deleting bool
Ready bool
}
type Instance struct {
Identity Identity
Deleting bool
Ready bool
}
type Issue struct {
Reason string
Message string
}
func (d Database) MatchesProvision(target Target, tenant TenantIdentity) bool {
return target.Provision != nil && d.Source == "Provision" && d.Instance == target.Provision.Instance &&
d.Name == target.Provision.Database && d.LoginRole == target.Provision.LoginRole &&
d.Tenant != nil && *d.Tenant == tenant
}
func (i Instance) Check(database *Database) *Issue {
if i.Deleting || !i.Ready {
return &Issue{DependencyUnavailable, "Instance 正在删除或尚无当前版本的 Ready 观察"}
}
if database != nil && database.InstanceUID != "" && database.InstanceUID != i.Identity.UID {
return &Issue{Conflict, "Instance UID 已变化;请核实实例身份,未迁移或接管资源"}
}
return nil
}
func (d Database) CanBind(tenant Tenant) *Issue {
if tenant.Database != nil && tenant.Database.UID != d.Identity.UID {
return &Issue{Conflict, fmt.Sprintf("Database %s 的 UID 与已记录绑定不同;请核实同名重建,未接管新对象", d.Identity.Name)}
}
if d.Deleting || d.Phase == "Released" || d.Phase == Deleting {
return &Issue{Conflict, "Database 正在删除或处于 Released;请由管理员核实并处理,未重新分配"}
}
if d.Tenant != nil {
if *d.Tenant != tenant.Identity {
return &Issue{Conflict, fmt.Sprintf("Database %s 已绑定 Tenant %s/%s(UID %s);未抢占",
d.Identity.Name, d.Tenant.Namespace, d.Tenant.Name, d.Tenant.UID)}
}
return nil
}
if d.Phase != "Available" || !d.Ready {
return &Issue{DependencyUnavailable, "Database 尚未完成验证并进入 Available,等待资源观察"}
}
return nil
}
@@ -0,0 +1,134 @@
package binding
import "testing"
const (
testNamespace = "apps"
testApp = "app"
testInstance = "shared"
testOwner = "owner"
testExisting = "existing"
testOther = "other"
)
func TestRequestResolve(t *testing.T) {
tenant := TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "tenant-uid"}
tests := []struct {
name string
request Request
valid bool
}{
{"动态默认值", Request{Provision: &ProvisionRequest{Instance: testInstance}}, true},
{"显式名称", Request{Provision: &ProvisionRequest{Instance: testInstance, Database: "custom", LoginRole: testOwner}}, true},
{"已有资源", Request{ExistingDatabase: testExisting}, true},
{"没有入口", Request{}, false},
{"同时指定入口", Request{Provision: &ProvisionRequest{}, ExistingDatabase: testExisting}, false},
{"非法库名", Request{Provision: &ProvisionRequest{Database: "bad-name"}}, false},
{"非法角色名", Request{Provision: &ProvisionRequest{LoginRole: "bad-name"}}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
target, err := tc.request.Resolve(tenant)
if (err == nil) != tc.valid {
t.Fatalf("Resolve() = %v, valid = %v", err, tc.valid)
}
if !tc.valid {
return
}
if tc.request.Provision == nil {
if target.Name != testExisting || target.Provision != nil {
t.Fatal("已有资源不应推导 Instance 或供应请求")
}
return
}
if target.Name != "tenant-tenant-uid" || target.Provision == tc.request.Provision {
t.Fatal("目标名称不稳定,或 Resolve 未复制输入")
}
if tc.request.Provision.Database == "" && target.Provision.Database != tenant.Name {
t.Fatal("数据库默认名称不符")
}
if tc.request.Provision.LoginRole == "" && target.Provision.LoginRole != tenant.Name {
t.Fatal("角色默认名称不符")
}
})
}
}
func TestDatabaseCanBind(t *testing.T) {
tenant := Tenant{Identity: TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "current"}}
tests := []struct {
name string
change func(*Database, *Tenant)
reason string
}{
{"空闲且就绪", func(*Database, *Tenant) {}, ""},
{"同一绑定重试", func(d *Database, t *Tenant) { d.Tenant = &t.Identity; d.Ready = false }, ""},
{"尚未观察", func(d *Database, _ *Tenant) { d.Ready = false }, DependencyUnavailable},
{"尚未Available", func(d *Database, _ *Tenant) { d.Phase = "Pending" }, DependencyUnavailable},
{"Released", func(d *Database, _ *Tenant) { d.Phase = "Released" }, Conflict},
{"删除标记", func(d *Database, _ *Tenant) { d.Deleting = true }, Conflict},
{"删除阶段", func(d *Database, _ *Tenant) { d.Phase = Deleting }, Conflict},
{"已被占用", func(d *Database, _ *Tenant) { d.Tenant = &TenantIdentity{UID: testOther} }, Conflict},
{"同名新申请", func(d *Database, t *Tenant) { old := t.Identity; old.UID = "old"; d.Tenant = &old }, Conflict},
{"同名新资源", func(_ *Database, t *Tenant) { t.Database = &Identity{Name: "resource", UID: "old"} }, Conflict},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
database := Database{Identity: Identity{Name: "resource", UID: "database-uid"}, Phase: "Available", Ready: true}
currentTenant := tenant
tc.change(&database, &currentTenant)
checkIssue(t, database.CanBind(currentTenant), tc.reason)
})
}
}
func TestInstanceCheck(t *testing.T) {
instance := Instance{Identity: Identity{UID: "instance"}, Ready: true}
checkIssue(t, instance.Check(nil), "")
checkIssue(t, instance.Check(&Database{InstanceUID: "instance"}), "")
checkIssue(t, instance.Check(&Database{InstanceUID: "replaced"}), Conflict)
instance.Ready = false
checkIssue(t, instance.Check(nil), DependencyUnavailable)
instance.Ready, instance.Deleting = true, true
checkIssue(t, instance.Check(nil), DependencyUnavailable)
}
func TestMatchesProvision(t *testing.T) {
tenant := TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "tenant"}
target := Target{Provision: &ProvisionRequest{Instance: testInstance, Database: testApp, LoginRole: testOwner}}
database := Database{Source: "Provision", Instance: testInstance, Name: testApp, LoginRole: testOwner, Tenant: &tenant}
if !database.MatchesProvision(target, tenant) {
t.Fatal("相同目标与身份应允许重试")
}
mutations := []func(*Database){
func(d *Database) { d.Source = "Import" },
func(d *Database) { d.Instance = testOther },
func(d *Database) { d.Name = testOther },
func(d *Database) { d.LoginRole = testOther },
func(d *Database) { d.Tenant = nil },
func(d *Database) { d.Tenant = &TenantIdentity{UID: testOther} },
}
for _, mutate := range mutations {
changed := database
mutate(&changed)
if changed.MatchesProvision(target, tenant) {
t.Fatal("不匹配的记录不能仅靠名称被认领")
}
}
if database.MatchesProvision(Target{}, tenant) {
t.Fatal("已有资源申请不是动态供应重试")
}
}
func checkIssue(t *testing.T, issue *Issue, reason string) {
t.Helper()
if reason == "" {
if issue != nil {
t.Fatalf("不应拒绝: %+v", issue)
}
return
}
if issue == nil || issue.Reason != reason || issue.Message == "" {
t.Fatalf("issue = %+v, want %s 与可读诊断", issue, reason)
}
}