From 7e9e8e828bfcbdc8d4e2fcb6bc3d13727712a82c Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Fri, 25 Sep 2026 04:09:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Database=20?= =?UTF-8?q?=E4=B8=89=E8=B5=84=E6=BA=90=20API=20=E4=B8=8E=E5=88=86=E5=B1=82?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=8D=8F=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确定单库单账号、集群级 Database、资源侧先写绑定和凭据定位合同。领域层承载纯规则,service 协調流程,Kubernetes adapter 负责资源呈现与版本保护。 验证:全量 make test、三轮 race、真实 API server 并发与重启补写、最小 RBAC/watch、lint 和文档检查通过。供应、凭据交付及删除清理尚未实现,保留 DeletionPending/finalizer 边界。 --- api/database/v1alpha1/common_types.go | 67 +++ api/database/v1alpha1/groupversion_info.go | 25 + .../v1alpha1/postgresqldatabase_types.go | 62 +++ .../v1alpha1/postgresqlinstance_types.go | 80 +++ .../v1alpha1/postgresqltenant_types.go | 70 +++ api/database/v1alpha1/validation_test.go | 312 +++++++++++ .../v1alpha1/zz_generated.deepcopy.go | 452 +++++++++++++++ cmd/main.go | 8 + ...yatori.ddupan.top_postgresqldatabases.yaml | 226 ++++++++ ...yatori.ddupan.top_postgresqlinstances.yaml | 191 +++++++ ....ayatori.ddupan.top_postgresqltenants.yaml | 223 ++++++++ config/crd/kustomization.yaml | 3 + config/rbac/role.yaml | 52 +- .../database_v1alpha1_postgresqldatabase.yaml | 15 + .../database_v1alpha1_postgresqlinstance.yaml | 11 + .../database_v1alpha1_postgresqltenant.yaml | 25 + config/samples/kustomization.yaml | 3 + docs/database/api-reference.md | 106 +++- docs/database/development.md | 18 +- docs/database/domain-model.md | 36 +- docs/database/migration.md | 7 +- docs/database/operations.md | 9 + docs/database/security.md | 5 +- docs/database/specification.md | 42 +- .../adapter/kubernetes/binding_mapping.go | 61 +++ .../adapter/kubernetes/binding_resources.go | 191 +++++++ .../database/application/binding_service.go | 170 ++++++ .../application/binding_service_test.go | 181 ++++++ .../database/controller/binding_controller.go | 47 ++ .../controller/binding_controller_test.go | 518 ++++++++++++++++++ internal/database/controller/binding_setup.go | 75 +++ internal/database/domain/binding/binding.go | 136 +++++ .../database/domain/binding/binding_test.go | 134 +++++ 33 files changed, 3516 insertions(+), 45 deletions(-) create mode 100644 api/database/v1alpha1/common_types.go create mode 100644 api/database/v1alpha1/groupversion_info.go create mode 100644 api/database/v1alpha1/postgresqldatabase_types.go create mode 100644 api/database/v1alpha1/postgresqlinstance_types.go create mode 100644 api/database/v1alpha1/postgresqltenant_types.go create mode 100644 api/database/v1alpha1/validation_test.go create mode 100644 api/database/v1alpha1/zz_generated.deepcopy.go create mode 100644 config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml create mode 100644 config/crd/bases/database.ayatori.ddupan.top_postgresqlinstances.yaml create mode 100644 config/crd/bases/database.ayatori.ddupan.top_postgresqltenants.yaml create mode 100644 config/samples/database_v1alpha1_postgresqldatabase.yaml create mode 100644 config/samples/database_v1alpha1_postgresqlinstance.yaml create mode 100644 config/samples/database_v1alpha1_postgresqltenant.yaml create mode 100644 internal/database/adapter/kubernetes/binding_mapping.go create mode 100644 internal/database/adapter/kubernetes/binding_resources.go create mode 100644 internal/database/application/binding_service.go create mode 100644 internal/database/application/binding_service_test.go create mode 100644 internal/database/controller/binding_controller.go create mode 100644 internal/database/controller/binding_controller_test.go create mode 100644 internal/database/controller/binding_setup.go create mode 100644 internal/database/domain/binding/binding.go create mode 100644 internal/database/domain/binding/binding_test.go diff --git a/api/database/v1alpha1/common_types.go b/api/database/v1alpha1/common_types.go new file mode 100644 index 0000000..ecbf3f4 --- /dev/null +++ b/api/database/v1alpha1/common_types.go @@ -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"` +} diff --git a/api/database/v1alpha1/groupversion_info.go b/api/database/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..7f2596e --- /dev/null +++ b/api/database/v1alpha1/groupversion_info.go @@ -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 +) diff --git a/api/database/v1alpha1/postgresqldatabase_types.go b/api/database/v1alpha1/postgresqldatabase_types.go new file mode 100644 index 0000000..9166b40 --- /dev/null +++ b/api/database/v1alpha1/postgresqldatabase_types.go @@ -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"` +} diff --git a/api/database/v1alpha1/postgresqlinstance_types.go b/api/database/v1alpha1/postgresqlinstance_types.go new file mode 100644 index 0000000..d2d19f5 --- /dev/null +++ b/api/database/v1alpha1/postgresqlinstance_types.go @@ -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"` +} diff --git a/api/database/v1alpha1/postgresqltenant_types.go b/api/database/v1alpha1/postgresqltenant_types.go new file mode 100644 index 0000000..8774744 --- /dev/null +++ b/api/database/v1alpha1/postgresqltenant_types.go @@ -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"` +} diff --git a/api/database/v1alpha1/validation_test.go b/api/database/v1alpha1/validation_test.go new file mode 100644 index 0000000..e85aac8 --- /dev/null +++ b/api/database/v1alpha1/validation_test.go @@ -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 +} diff --git a/api/database/v1alpha1/zz_generated.deepcopy.go b/api/database/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..32e3e34 --- /dev/null +++ b/api/database/v1alpha1/zz_generated.deepcopy.go @@ -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 +} diff --git a/cmd/main.go b/cmd/main.go index d5bb38c..8a676d6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/tls" "flag" "os" @@ -19,7 +20,9 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "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" + databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller" // +kubebuilder:scaffold:imports ) @@ -32,6 +35,7 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(executionv1alpha1.AddToScheme(scheme)) + utilruntime.Must(databasev1alpha1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -166,6 +170,10 @@ func main() { } // +kubebuilder:scaffold:builder + 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 { setupLog.Error(err, "Failed to set up health check") diff --git a/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml b/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml new file mode 100644 index 0000000..9ca893a --- /dev/null +++ b/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml @@ -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: {} diff --git a/config/crd/bases/database.ayatori.ddupan.top_postgresqlinstances.yaml b/config/crd/bases/database.ayatori.ddupan.top_postgresqlinstances.yaml new file mode 100644 index 0000000..10a580a --- /dev/null +++ b/config/crd/bases/database.ayatori.ddupan.top_postgresqlinstances.yaml @@ -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: {} diff --git a/config/crd/bases/database.ayatori.ddupan.top_postgresqltenants.yaml b/config/crd/bases/database.ayatori.ddupan.top_postgresqltenants.yaml new file mode 100644 index 0000000..2ff3df0 --- /dev/null +++ b/config/crd/bases/database.ayatori.ddupan.top_postgresqltenants.yaml @@ -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: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index aad1dec..4335a2f 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,6 +2,9 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default 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_jobclasses.yaml - bases/execution.ayatori.ddupan.top_kubernetesexecutionparameters.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 759ce82..00be889 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -1,11 +1,51 @@ +--- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - labels: - app.kubernetes.io/name: ayatori - app.kubernetes.io/managed-by: kustomize name: manager-role rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] +- apiGroups: + - database.ayatori.ddupan.top + resources: + - postgresqldatabases + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - database.ayatori.ddupan.top + resources: + - postgresqldatabases/finalizers + - postgresqltenants/finalizers + verbs: + - update +- apiGroups: + - database.ayatori.ddupan.top + resources: + - postgresqldatabases/status + - postgresqltenants/status + verbs: + - get + - patch + - update +- apiGroups: + - database.ayatori.ddupan.top + resources: + - postgresqlinstances + verbs: + - get + - list + - watch +- apiGroups: + - database.ayatori.ddupan.top + resources: + - postgresqltenants + verbs: + - get + - list + - patch + - update + - watch diff --git a/config/samples/database_v1alpha1_postgresqldatabase.yaml b/config/samples/database_v1alpha1_postgresqldatabase.yaml new file mode 100644 index 0000000..6054f2b --- /dev/null +++ b/config/samples/database_v1alpha1_postgresqldatabase.yaml @@ -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 diff --git a/config/samples/database_v1alpha1_postgresqlinstance.yaml b/config/samples/database_v1alpha1_postgresqlinstance.yaml new file mode 100644 index 0000000..45788a3 --- /dev/null +++ b/config/samples/database_v1alpha1_postgresqlinstance.yaml @@ -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 diff --git a/config/samples/database_v1alpha1_postgresqltenant.yaml b/config/samples/database_v1alpha1_postgresqltenant.yaml new file mode 100644 index 0000000..ed3055c --- /dev/null +++ b/config/samples/database_v1alpha1_postgresqltenant.yaml @@ -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 diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 2d006c3..3801e6b 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,5 +1,8 @@ ## Append samples of your project ## resources: +- database_v1alpha1_postgresqlinstance.yaml +- database_v1alpha1_postgresqldatabase.yaml +- database_v1alpha1_postgresqltenant.yaml - execution_v1alpha1_job.yaml - execution_v1alpha1_jobclass.yaml - execution_v1alpha1_kubernetesexecutionparameters.yaml diff --git a/docs/database/api-reference.md b/docs/database/api-reference.md index f152df8..5d719cd 100644 --- a/docs/database/api-reference.md +++ b/docs/database/api-reference.md @@ -2,17 +2,70 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 三资源模型已批准;新增字段与绑定协议待评审 | +| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 | | API group/version | `database.ayatori.ddupan.top/v1alpha1` | -| 最后更新 | 2026-09-24 | +| 最后更新 | 2026-09-25 | 以 [系统规格](specification.md) 与 -[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。尚未实现新 API, -本页不提供可直接 apply 的三资源 YAML,以免把工作名称和未决字段当作已发布合同。 +[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码, +尚未发布为可用 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-`,首次创建即包含资源侧绑定与 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 RBAC 仅授予绑定所需资源读写,不包含 Secret 读取或后端凭据权限。 + +当前有 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 用于进度展示, 不能单独作为写权限或所有权证明。 - 引用必须区分定位名称与已绑定 UID;同名新对象不继承绑定。 @@ -57,21 +110,30 @@ Tenant 引用。无引用才解除,不级联删除资源;查询失败不能 ## 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 补齐 | | 外部目标 | 实际 database 名称及已确认的管理范围;操作开始后不可隐式改目标 | | 来源 | 区分动态供应与管理员显式导入;不能从同名存在推断导入授权 | -| 申请预留/绑定 | 至多一个 Tenant,含 namespace/name/UID;Released 保留旧身份 | +| 绑定 | 至多一个 Tenant,含 namespace/name/UID;Released 保留旧身份;无允许绑定名单 | | 回收策略 | 资源侧 Retain(默认)或显式 Delete;普通 Tenant editor 不得扩大授权 | | 观察与进度 | 实际目标、当前阶段、条件和安全诊断,不保存秘密 | 概念生命周期包含供应/验证、可绑定、已绑定、Released 和删除;最终 phase 枚举待协议评审。 Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用中的资源受删除保护。 -导入的初始检查只读;存在不等于 Ready,也不等于有权交付给任意 Tenant。 +导入的初始检查只读;存在不等于 Ready。未绑定且可用的资源允许 Tenant 显式申请, +不要求管理员逐 Tenant 授权;已占用或 Released 的资源不能直接绑定。 默认保留不隐含密码、owner、授权或删除的变更许可。 ## PostgreSQLTenant @@ -90,16 +152,34 @@ Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用 仅按 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 边界。 -2. 资源侧排他记录的写入点、双向绑定顺序、API 冲突重试与单边完成恢复。 +1. 引用字段的最终格式,以及落实管理员资源管理、controller 协调与普通申请权限的 RBAC 规则。 +2. 绑定记录的最终字段及校验规则,落实上述写入顺序与恢复行为。 3. Tenant UID 变化、对象删除、Released 旧引用与管理员重新授权的判断。 4. 同一物理数据库重复登记的冲突处理;列表查询不是原子认领。 -5. Database 的 role/凭据管理范围、稳定凭据定位、旧访问处置与投射清理顺序。 -6. 删除开始后的策略固定点和 Tenant/Database finalizer 配合。 +5. 已有凭据关联字段、旧访问处置与投射清理顺序;动态凭据路径已确定按 Database UID 定位。 +6. Tenant/Database finalizer 配合;回收策略默认 Retain,删除流程前可改,进入后固定。 + +资源管理者设置 Delete 即表示删除授权,不增加额外审批字段。导入显式关联已有凭据; +Released 不自动改密,管理员处理旧访问后才重新开放。Tenant 不得自选任意 OpenBao 路径。 该协议使用 Kubernetes API 持久化,不为它新增 PostgreSQL registry。 未完成一致绑定不得供应或交付;外部创建结果不确定按 Conflict 人工处理。 diff --git a/docs/database/development.md b/docs/database/development.md index 9b8e32b..6ce46d7 100644 --- a/docs/database/development.md +++ b/docs/database/development.md @@ -18,7 +18,20 @@ 需故障注入外部成功而 API 写入失败、后端响应丢失、双 Tenant 竞争、同名新 UID、依赖稍后出现、 Instance 删除与 Released 引用。冲突必须给出可操作而不泄密的诊断;不要求自动认领不确定结果。 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 切片测试 @@ -53,7 +66,8 @@ metadata 测试验证版本与可用扩展的只读查询,包括未安装扩 Instance 领域测试不再提供 registry 状态,初次验证与 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 使用 envtest 或一次性 Kind,另外两个依赖使用一次性 diff --git a/docs/database/domain-model.md b/docs/database/domain-model.md index 7209e81..f88db8e 100644 --- a/docs/database/domain-model.md +++ b/docs/database/domain-model.md @@ -1,6 +1,6 @@ # Database 领域模型 -状态:资源模型已确认,字段与绑定协议待细化。日期:2026-09-24。 +状态:资源模型已确认,字段与绑定协议待细化。日期:2026-09-25。 行为以 [系统规格](specification.md) 为准;决策依据见 [ADR-0009](../decisions/0009-database-resource-and-claim.md)。 @@ -19,6 +19,10 @@ Instance 一对多 Database;每个 Database 同时零或一个 Tenant,Tenant 最多一个 Database。 Instance 不持有全部资源的内存集合。三者以引用关联,操作一个资源无需加载整个实例集合。 +Instance 与 Database 是集群级资源,Tenant 位于 namespace。Tenant 按名称引用 Database, +Database 记录所绑定 Tenant 的 namespace/name/UID。Database 不属于应用 namespace; +平台管理员管理资源及回收策略,普通申请者不能自行将 Released 资源重新开放。 + Database 的 instanceRef 表达资源归属,手工登记时也必须提供,不从 Tenant 反推。 Tenant 动态申请才选择 Instance;引用已有 Database 时使用资源声明的 Instance。 释放使用绑定不改变 Database 的实例归属,修改引用不能实现外部数据库迁移。 @@ -35,19 +39,26 @@ registry。管理凭据来源和连接刷新由应用层协调,连接池由 pg Database 保护目标和管理范围、排他绑定、导入验证与 Retain/Delete 规则。资源首次外部操作前 必须已有持久记录;完成记录与外部存在性分别检查。数据库名称不是归属证明。 -Tenant 表达申请与交付要求;显式选择已有资源不自动授权使用。绑定后检查数据库满足要求、 +Tenant 表达申请与交付要求;可显式申请未绑定且可用的资源,不增加反向授权名单。 +绑定后检查数据库满足要求、 应用凭据可登录且 ESO 投射完成,才可 Ready。Tenant 删除意味着释放使用关系。 -LoginRole 与 CredentialLocation 的生命周期必须随独立资源保留,不能因为 Tenant 消失就 -失去定位或未经授权被删除;具体字段归属及导入时的管理范围需继续评审。不要因此新增 Role、 -Credential 或 Claim CRD。当前首版仍是单 database + 单 login owner。 +第一版 Database 的生命周期边界包含一个 database、一个兼任 owner 的 LoginRole 及其 +应用凭据。LoginRole 与 CredentialLocation 随 Database 保留,不能因为 Tenant 消失就 +失去定位或未经授权被删除;导入时的管理授权与具体字段仍需评审。不新增 Role、Credential +或 Claim CRD,也不预留多账号集合;若出现一库多账号的实际需求,再通过后续 API 版本演进。 ## 生命周期与恢复 - 动态创建与显式导入最终形成同一种 Database 资源,但导入本身不允许改密、改 owner 或删除。 - Retain 后 Database 保持 Released 与旧绑定身份,人工确认数据、权限和凭据后才可重新绑定。 - 回收策略属于资源侧;Tenant 与 Database 不是可随申请级联 GC 的父子关系。 +- 回收策略默认 Retain,进入删除流程前可由资源管理者修改,进入后固定;Delete 无额外审批。 +- 动态凭据位置按 Database UID 确定,导入显式关联已有凭据;Released 不自动改密。 - 绑定 UID 防止同名新申请继承权限。双向记录的单边写入不代表绑定完成。 +- 动态 Database 名称由 Tenant UID 确定;先持久化资源侧 Tenant 引用,再更新 Tenant status + 的 Database 引用。后一写入失败由 reconcile 核对身份后补齐,不回滚资源侧记录; + 其他 Tenant 已占用则报冲突。双向一致后才供应或交付,绑定不等于 Ready。 - 普通失败按 reconcile 重试;可靠确认的步骤幂等继续;不确定创建/未知同名对象报告 Conflict。 - Kubernetes status 是持久进度和观察,不是外部事实,也不是 controller 内存。 不引入“status 任意丢失后自动恢复所有权”的附加要求。 @@ -58,18 +69,25 @@ Credential 或 Claim CRD。当前首版仍是单 database + 单 login owner。 | --- | --- | | 领域 | 值、身份、允许动作、不变量、完成与冲突判定;不做 IO | | 应用 | 装载记录与事实、协调 API 更新和 adapter、回读、交回领域判定 | -| controller | watch/调度、映射、conditions/status/finalizer;不重写领域规则 | -| adapter | Kubernetes、PostgreSQL、OpenBao、ESO 的具体访问与安全错误分类 | +| controller | watch/调度、调用用例、请求资源呈现与安排重试;不判断绑定资格 | +| adapter | Kubernetes 资源映射与呈现(含 conditions/status/finalizer)、后端访问与安全错误分类 | | 装配 | 客户端与成熟连接池的生命周期,不是领域状态 | 不引入通用 Repository CRUD、跨系统 Unit of Work、事务队列或第二套 phase 存储。 resourceVersion 解决 API 对象并发更新,不宣称 PostgreSQL 与 Kubernetes 原子提交。 +绑定实现中,`domain/binding` 承载请求默认值、资源身份匹配、实例就绪与排他绑定规则; +`application/BindingService` 协调固定申请、资源侧写入和回读确认,返回待呈现结果。 +两层均不依赖 Kubernetes API 类型。`adapter/kubernetes/BindingResources` 将 CR 转换为事实 +快照,并负责保留其他字段、检查快照版本、写入 finalizer 和呈现 Conditions/status。 +controller 仅连接事件、service 与呈现层,不把资源写入细节和领域判断塞进 Reconcile。 +这里的接口只列出绑定用例所需操作,不扩展成通用 CRUD、Repository 或事务框架。 + ## API 切片前需明确 -- Database 的 scope、引用格式、谁可以预留/绑定/释放,以及绑定字段和更新顺序。 +- 引用与绑定字段的最终格式及校验、管理员与 controller 的权限落实。 - 资源侧回收策略与 Tenant/Database finalizer 配合。 -- 导入时角色/凭据的管理范围,稳定凭据定位、旧使用者撤权及新投射授权。 +- 导入时角色/凭据的管理范围及关联字段、旧使用者撤权及投射清理。 - 绑定/导入同一实际目标的重复声明如何拒绝,且不引入 registry。 - 管理员确认冲突、解除旧绑定的具体可审计操作入口。 diff --git a/docs/database/migration.md b/docs/database/migration.md index 14faea1..9ff6a60 100644 --- a/docs/database/migration.md +++ b/docs/database/migration.md @@ -7,7 +7,8 @@ | 最后更新 | 2026-09-24 | 当前设计支持管理员显式登记已有 Database;未知同名资源仍不得自动认领。 -导入不要求移动数据,不隐含改密码、owner、授权或删除权限。API 尚未实现,以下导入步骤 +导入不要求移动数据,不隐含改密码、owner、授权或删除权限。API schema 与绑定协调已实现, +但导入观察和凭据交付尚未实现,以下导入步骤 是验收要求而非可直接执行的命令。 ## 显式导入与保留资源复用 @@ -15,7 +16,7 @@ 1. 核对 Instance、数据库、owner、角色权限、扩展、使用者与备份,确定允许管理的范围。 2. 由管理员声明 Database,指定已有目标,回收策略默认 Retain;导入验证初始只读。 3. 安全关联现有应用凭据;具体 API 待定,不把密码写入 CR,不因验证失败重置密码。 -4. 管理员授权目标 Tenant;controller 验证资源与申请符合要求并建立排他绑定。 +4. Tenant 显式引用未绑定且可用的 Database;controller 验证要求并建立排他绑定,无额外名单审批。 5. 验证实际登录与 ESO 交付;不满足时停止,不以修改原数据库作为默认修复。 Released 资源复用前另需核实旧使用者的访问权限、数据交接与投射处置。保留旧绑定身份直到 @@ -135,7 +136,7 @@ extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预 发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录: -- 显式导入与 Released 重新绑定的授权、旧访问处置和失败不修改原资源; +- 显式导入的管理权限、Released 重新开放前的旧访问处置和失败不修改原资源; - 使用的 PostgreSQL major version 和命令版本; - dump/restore 返回码和对象差异; - DNS/IP TLS 登录结果; diff --git a/docs/database/operations.md b/docs/database/operations.md index 27440d8..5f3260e 100644 --- a/docs/database/operations.md +++ b/docs/database/operations.md @@ -3,6 +3,15 @@ 状态:设计合同,操作入口待 API 实现与隔离环境演练。日期:2026-09-24。 依据 [系统规格](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、Database、Tenant 的 Ready Condition、绑定 UID、阶段与 observedGeneration, diff --git a/docs/database/security.md b/docs/database/security.md index 7dba105..24e641e 100644 --- a/docs/database/security.md +++ b/docs/database/security.md @@ -51,8 +51,9 @@ PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向 controller role 授予 EXECUTE。controller 不调用 shell 或 `psql` 拼接用户输入。 -Kubernetes RBAC 应把 Instance 管理、Database 导入、预留、重新绑定授权和回收限制给平台管理员。 -知道 Database 名称不等于有权使用;Tenant editor +Kubernetes RBAC 应把 Instance 管理、Database 导入、Released 重新开放和回收限制给平台管理员。 +有权创建 Tenant 的申请者可显式申请未绑定且可用的 Database,不增加资源侧允许绑定名单 +或逐 Tenant 审批。Released 必须先由管理员处理旧访问并重新开放。Tenant editor 不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。 ## 删除保护 diff --git a/docs/database/specification.md b/docs/database/specification.md index 0e8be59..356ac62 100644 --- a/docs/database/specification.md +++ b/docs/database/specification.md @@ -4,7 +4,7 @@ | --- | --- | | 状态 | 资源模型与生命周期已批准;字段协议待 API 评审 | | 目标 API | `database.ayatori.ddupan.top/v1alpha1` | -| 最后更新 | 2026-09-24 | +| 最后更新 | 2026-09-25 | | 决策 | [ADR-0009](../decisions/0009-database-resource-and-claim.md) | 本文是当前行为合同,替代旧的 Tenant 同时承担申请与资源生命周期、PostgreSQL registry @@ -34,9 +34,16 @@ Instance | Database | 描述外部数据库、管理范围、绑定与回收策略 | 充当第二套 registry 或通用资源框架 | | Tenant | 声明需求或显式选择资源,申请使用并交付凭据 | 删除时隐式销毁独立资源记录 | -Instance 保持 cluster-scoped,Tenant 保持 namespaced。Database 的工作名称是 -PostgreSQLDatabase;scope、字段拼写和管理角色/凭据的具体边界待 API 评审。 -单 database + 单 login owner 的首版使用场景不变,不新增独立 Role 或 Claim CRD。 +Instance 与 Database 为 cluster-scoped,Tenant 为 namespaced。Database 的工作名称是 +PostgreSQLDatabase;字段拼写与导入授权细节待 API 评审。 +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, 即可通过 Instance 验证目标。动态申请由 Tenant 选择 Instance,供应时把该引用写入 Database; @@ -81,15 +88,25 @@ endpoint 变更由管理员负责评估,不验证物理服务器连续性, ## 5. 动态供应与排他绑定 1. 校验 Tenant 请求、Instance 能力、名称和扩展要求。 + 绑定 controller 在触及资源侧绑定前将 Tenant 进度记为 Binding,固定申请目标, + 避免两次绑定写入之间修改引用占用第二个资源;该进度不是已绑定的声明。 2. 在首次外部写入前持久化独立 Database 记录、确定目标与管理范围。 -3. 建立带 UID 的排他预留/绑定,避免两个 Tenant 同时使用同一 Database。 + 动态创建的 Database 名称由 Tenant UID 确定;重试复用同一记录,不重复创建。 +3. 先在 Database 写入 Tenant namespace/name/UID,再在 Tenant status 写入 Database + name/UID;双向记录一致后才允许供应或交付。 4. 按已确认步骤建立凭据、role、database、授权和扩展,逐步回读。 5. 验证应用登录与 ESO 投射后,Tenant 才可 Ready。 绑定前固定有效目标;绑定或开始外部供应后不得通过修改名称或引用实施隐式迁移。 每个 Database 最多一个使用者,每个 Tenant 最多一个 Database。 绑定 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 中的列表检查保证 PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败方不得接管胜方资源。 @@ -102,12 +119,16 @@ PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败 不得通过重置密码、改变 owner 或撤销现有访问来“完成导入”。 未显式导入的同名数据库一律 Conflict。导入资源默认 Retain,不隐含 Delete 授权。 -Tenant 显式引用已登记资源仍需通过绑定资格与授权检查;知道资源名称不等于有权使用。 -资源侧预留/授权的具体 API 和已有凭据的安全关联入口待细化,完成前不能宣称可用。 +有权创建 Tenant 的申请者可以显式引用已登记、未绑定且可用的 Database;不增加资源侧 +允许绑定名单或逐 Tenant 的管理员审批。绑定仍检查目标、可用状态与排他关系。 +Released 不在可申请范围,必须由管理员处理旧访问并重新开放。导入时显式关联已有凭据, +不通过隐式改密生成替代凭据;具体关联字段在 API 中定义。 ## 7. Retain、重新绑定与 Delete 回收策略属于 Database,默认 Retain;Tenant 删除是释放申请,不是独立资源的 GC 授权。 +有资源管理权限的主体可在进入删除流程前修改 Retain/Delete;进入删除流程后策略固定。 +显式设置 Delete 就是删除授权,不增加第二次审批或确认字段。 Database 不得设置会让它随 Tenant 消失的 ownerReference。 ### Retain @@ -164,7 +185,8 @@ Kubernetes 应用由 ESO 投射同 namespace Secret,controller 不直接写明 凭据仍输出 username/password/database/host/hostaddr/port/sslmode 七键,不生成带密码 URI。 Tenant status 提供 Secret 引用与无认证信息的 OpenBao API URL。 mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenant namespace/name 固定 -推导路径的规则需修订为可支持资源保留与重新绑定的定位协议,本轮不定字段或新路径格式。 +推导路径的规则撤除。动态供应的凭据路径按 Database UID 确定;导入时显式关联已有凭据 +位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。 不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。 TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求 @@ -189,7 +211,7 @@ ProvisioningFailed、CredentialProjectionFailed。Released 应明确显示未可 | --- | --- | | Instance 登记与重验 | 无 registry 依赖;真实凭据/TLS/管理权限检查 | | 动态供应与重复 reconcile | 独立资源记录、排他绑定、密码不变、实际登录与投射成功 | -| 显式导入 | 无数据/密码/owner 隐式修改;错误目标及未经授权申请被拒绝 | +| 显式导入 | 无数据/密码/owner 隐式修改;错误目标、已占用或 Released 资源的申请被拒绝 | | 同名未知资源 | Conflict,原数据库/角色/凭据不变 | | 并发申请与单边绑定 | 最多一个使用者;失败方不能开始危险外部操作 | | controller 重启 | 已确认步骤正常继续;不确定创建报告人工可诊断冲突 | diff --git a/internal/database/adapter/kubernetes/binding_mapping.go b/internal/database/adapter/kubernetes/binding_mapping.go new file mode 100644 index 0000000..5349a39 --- /dev/null +++ b/internal/database/adapter/kubernetes/binding_mapping.go @@ -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)) +} diff --git a/internal/database/adapter/kubernetes/binding_resources.go b/internal/database/adapter/kubernetes/binding_resources.go new file mode 100644 index 0000000..ef23a0d --- /dev/null +++ b/internal/database/adapter/kubernetes/binding_resources.go @@ -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 +} diff --git a/internal/database/application/binding_service.go b/internal/database/application/binding_service.go new file mode 100644 index 0000000..876ed56 --- /dev/null +++ b/internal/database/application/binding_service.go @@ -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}} +} diff --git a/internal/database/application/binding_service_test.go b/internal/database/application/binding_service_test.go new file mode 100644 index 0000000..e27cfeb --- /dev/null +++ b/internal/database/application/binding_service_test.go @@ -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 +} diff --git a/internal/database/controller/binding_controller.go b/internal/database/controller/binding_controller.go new file mode 100644 index 0000000..0dff332 --- /dev/null +++ b/internal/database/controller/binding_controller.go @@ -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 +} diff --git a/internal/database/controller/binding_controller_test.go b/internal/database/controller/binding_controller_test.go new file mode 100644 index 0000000..cf1ac91 --- /dev/null +++ b/internal/database/controller/binding_controller_test.go @@ -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) + } +} diff --git a/internal/database/controller/binding_setup.go b/internal/database/controller/binding_setup.go new file mode 100644 index 0000000..6fa70f1 --- /dev/null +++ b/internal/database/controller/binding_setup.go @@ -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 +} diff --git a/internal/database/domain/binding/binding.go b/internal/database/domain/binding/binding.go new file mode 100644 index 0000000..b9f3c12 --- /dev/null +++ b/internal/database/domain/binding/binding.go @@ -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 +} diff --git a/internal/database/domain/binding/binding_test.go b/internal/database/domain/binding/binding_test.go new file mode 100644 index 0000000..6edadb3 --- /dev/null +++ b/internal/database/domain/binding/binding_test.go @@ -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, ¤tTenant) + 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) + } +}