Author SHA1 Message Date
panxiao81 2a4f622f44 test: 分离 OpenBao 镜像拉取与启动超时并保留诊断
Verify / test (pull_request) Successful in 5m24s
Verify / lint (pull_request) Successful in 11m53s
Verify / database-integration (pull_request) Successful in 13m32s
2026-09-25 16:30:09 +00:00
panxiao81 f6bb9e4599 feat: 增加 OpenBao 应用凭据安全存储切片
Verify / test (pull_request) Successful in 5m43s
Verify / lint (pull_request) Successful in 14m8s
Verify / database-integration (pull_request) Failing after 13m35s
2026-09-25 15:41:53 +00:00
panxiao81 f4deb98a7f Merge pull request 'feat: Instance 原生管理观测与删除保护' (#11) from feat/database-instance-observation into main 2026-09-25 14:32:25 +00:00
panxiao81 bc227bfdb4 feat: 接入 Instance 原生管理观测与删除保护
Verify / test (pull_request) Successful in 12m41s
Verify / lint (pull_request) Successful in 14m14s
Verify / database-integration (pull_request) Successful in 16m9s
2026-09-25 11:35:04 +00:00
panxiao81 55b269ce2e Merge pull request 'feat: Database 三资源 API 与分层绑定协调' (#10) from feat/database-resource-api into main 2026-09-25 04:44:13 +00:00
panxiao81 7e9e8e828b feat: 接入 Database 三资源 API 与分层绑定协调
Verify / test (pull_request) Successful in 11m39s
Verify / lint (pull_request) Successful in 12m51s
Verify / database-integration (pull_request) Successful in 13m12s
确定单库单账号、集群级 Database、资源侧先写绑定和凭据定位合同。领域层承载纯规则,service 协調流程,Kubernetes adapter 负责资源呈现与版本保护。

验证:全量 make test、三轮 race、真实 API server 并发与重启补写、最小 RBAC/watch、lint 和文档检查通过。供应、凭据交付及删除清理尚未实现,保留 DeletionPending/finalizer 边界。
2026-09-25 04:09:43 +00:00
panxiao81 347a667c0c Merge pull request 'refactor: 分离 Database 资源与申请并撤除 registry' (#9) from feat/database-registry-inspection into main 2026-09-24 17:22:53 +00:00
panxiao81 f347ee5292 ci: 避免 main 合并后重复全量验证
Verify / lint (pull_request) Successful in 16m40s
Verify / database-integration (pull_request) Successful in 17m50s
Verify / test (pull_request) Successful in 3m25s
2026-09-24 16:58:20 +00:00
panxiao81 e2e795a889 Merge pull request 'feat: 接入 Instance 版本与扩展可用性观测' (#8) from feat/database-metadata-observation into main
Verify / test (push) Successful in 10m53s
Verify / lint (push) Successful in 12m26s
Verify / database-integration (push) Successful in 16m44s
2026-09-24 16:51:48 +00:00
panxiao81 23a2d81b50 refactor: 移除 Database registry 与 Instance 初始化依赖
Verify / test (pull_request) Successful in 11m8s
Verify / lint (pull_request) Successful in 19m34s
Verify / database-integration (pull_request) Successful in 21m51s
2026-09-24 16:32:30 +00:00
panxiao81 6db8a495fb docs: 分离 Database 资源与 Tenant 申请生命周期 2026-09-24 16:21:17 +00:00
panxiao81 9441b568da feat: 接入 Instance 版本与扩展可用性观测
Verify / test (pull_request) Successful in 6m1s
Verify / lint (pull_request) Successful in 7m19s
Verify / database-integration (pull_request) Successful in 5m11s
2026-09-24 15:14:43 +00:00
panxiao81 6acba0ca46 Merge pull request 'feat: 迁移 PostgreSQL registry 所有权存储' (#7) from feat/database-registry into main
Verify / test (push) Successful in 8m36s
Verify / lint (push) Successful in 10m32s
Verify / database-integration (push) Successful in 6m9s
2026-09-24 14:46:52 +00:00
panxiao81 e590542b0b feat: 迁移 PostgreSQL registry 所有权存储与恢复测试
Verify / test (pull_request) Successful in 9m18s
Verify / lint (pull_request) Successful in 10m19s
Verify / database-integration (pull_request) Successful in 11m32s
2026-09-21 15:59:14 +00:00
78 changed files with 6724 additions and 1571 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
name: Verify
on:
# 合并前完成全量验证,合并到 main 后不重复运行同一套检查。
pull_request:
push:
branches: [main]
workflow_dispatch:
jobs:
test:
+1 -1
View File
@@ -68,7 +68,7 @@ lint: golangci-lint ## Run golangci-lint linter
"$(GOLANGCI_LINT)" run
.PHONY: test-database-integration
test-database-integration: setup-envtest ## 使用临时 API server 与独立 PostgreSQL 容器验证凭据读取和连接更新。
test-database-integration: setup-envtest ## 使用临时 API server、PostgreSQL 与 OpenBao 容器验证 Database 后端。
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test -tags=integration -race -count=1 ./internal/database/...
.PHONY: lint-database-integration
+1
View File
@@ -34,6 +34,7 @@ Ayatori 是 `ddupan.top` homelab 的内部基础设施控制平面。它以 Kube
- [ADR-0006:按实际管理缺口扩展资源 API](docs/decisions/0006-demand-driven-resource-scope.md)
- [ADR-0007:复用 Node API 建立按需实现的 Compute 能力](docs/decisions/0007-compute-node-and-vm-boundary.md)
- [ADR-0008:将 PostgreSQL Tenant Operator 合并为 Ayatori Database 模块](docs/decisions/0008-merge-postgresql-tenant-operator.md)
- [ADR-0009:分离 Database 资源与 Tenant 申请](docs/decisions/0009-database-resource-and-claim.md)
## 当前状态
+67
View File
@@ -0,0 +1,67 @@
package v1alpha1
import "k8s.io/apimachinery/pkg/types"
// ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`
type ObjectName string
// PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z][a-z0-9_]{0,62}$`
type PostgreSQLIdentifier string
// InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
type InstanceReference struct {
Name ObjectName `json:"name"`
}
// DatabaseReference 是 Tenant 对已有集群级 PostgreSQLDatabase 的选择。
type DatabaseReference struct {
Name ObjectName `json:"name"`
}
// BoundDatabaseReference 记录已经参与绑定的对象身份,而非仅记录可复用的名称。
type BoundDatabaseReference struct {
Name ObjectName `json:"name"`
// +kubebuilder:validation:Type=string
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=128
UID types.UID `json:"uid"`
}
// TenantReference 是 Database 的当前绑定记录,不是允许绑定名单。
type TenantReference struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
Namespace string `json:"namespace"`
Name ObjectName `json:"name"`
// +kubebuilder:validation:Type=string
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=128
UID types.UID `json:"uid"`
}
// ReclaimPolicy 控制资源释放后的处置,只有资源管理者可以修改。
// +kubebuilder:validation:Enum=Retain;Delete
type ReclaimPolicy string
const (
ReclaimRetain ReclaimPolicy = "Retain"
ReclaimDelete ReclaimPolicy = "Delete"
)
// CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
// 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
type CredentialReference struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
Mount string `json:"mount"`
// Path 是 mount 内的逻辑路径,不含 KV v2 的 data/ API 前缀。
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=1024
Path string `json:"path"`
}
@@ -0,0 +1,25 @@
// Package v1alpha1 定义 Database 领域的 Kubernetes API。
// +kubebuilder:object:generate=true
// +groupName=database.ayatori.ddupan.top
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var (
SchemeGroupVersion = schema.GroupVersion{Group: "database.ayatori.ddupan.top", Version: "v1alpha1"}
GroupVersion = SchemeGroupVersion
SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&PostgreSQLInstance{}, &PostgreSQLInstanceList{},
&PostgreSQLDatabase{}, &PostgreSQLDatabaseList{},
&PostgreSQLTenant{}, &PostgreSQLTenantList{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
})
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -0,0 +1,62 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
// PostgreSQLDatabaseSpec 是一库、一个 login owner 及凭据的独立资源声明。
// +kubebuilder:validation:XValidation:rule="(self.source == 'Import') == has(self.credentialRef)",message="only imported databases require an existing credentialRef"
type PostgreSQLDatabaseSpec struct {
InstanceRef InstanceReference `json:"instanceRef"`
Database PostgreSQLIdentifier `json:"database"`
LoginRole PostgreSQLIdentifier `json:"loginRole"`
// Source 明确区分创建与只读导入,不从后端同名对象推断。
// +kubebuilder:validation:Enum=Provision;Import
Source string `json:"source"`
// +optional
CredentialRef *CredentialReference `json:"credentialRef,omitempty"`
// +kubebuilder:default=Retain
// +optional
ReclaimPolicy ReclaimPolicy `json:"reclaimPolicy,omitempty"`
// TenantRef 由 controller 先写入;Released 时仍保留旧身份。
// +optional
TenantRef *TenantReference `json:"tenantRef,omitempty"`
}
type PostgreSQLDatabaseStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
// +optional
InstanceUID types.UID `json:"instanceUID,omitempty"`
// Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
// +optional
Phase string `json:"phase,omitempty"`
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID))) || (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database == oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) || self.spec.credentialRef == oldSelf.spec.credentialRef))",message="managed database target cannot change after observation or binding starts"
// +kubebuilder:printcolumn:name="Instance",type=string,JSONPath=`.spec.instanceRef.name`
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.database`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
type PostgreSQLDatabase struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec PostgreSQLDatabaseSpec `json:"spec"`
// +optional
Status PostgreSQLDatabaseStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type PostgreSQLDatabaseList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []PostgreSQLDatabase `json:"items"`
}
@@ -0,0 +1,80 @@
package v1alpha1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// PostgreSQLEndpoint 显式区分证书主机名与实际连接 IP,不进行 DNS 推导。
type PostgreSQLEndpoint struct {
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
Host string `json:"host"`
// +kubebuilder:validation:MaxLength=45
// +kubebuilder:validation:XValidation:rule="isIP(self)",message="hostaddr must be a single IPv4 or IPv6 address"
HostAddr string `json:"hostaddr"`
// +kubebuilder:default=5432
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +optional
Port int32 `json:"port,omitempty"`
// +kubebuilder:default=postgres
// +optional
Database PostgreSQLIdentifier `json:"database,omitempty"`
// +kubebuilder:default=verify-full
// +kubebuilder:validation:Enum=disable;require;verify-ca;verify-full
// +optional
SSLMode string `json:"sslMode,omitempty"`
}
// AdminCredentialReference 只能读取 controller namespace 的 Secret。
type AdminCredentialReference struct {
Name ObjectName `json:"name"`
// +kubebuilder:default=username
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$`
// +optional
UsernameKey string `json:"usernameKey,omitempty"`
// +kubebuilder:default=password
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$`
// +optional
PasswordKey string `json:"passwordKey,omitempty"`
}
type PostgreSQLInstanceSpec struct {
Endpoint PostgreSQLEndpoint `json:"endpoint"`
AdminCredentialRef AdminCredentialReference `json:"adminCredentialRef"`
}
type PostgreSQLInstanceStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +kubebuilder:validation:Enum=Pending;Validating;Ready;Deleting
// +optional
Phase string `json:"phase,omitempty"`
// +optional
PostgreSQLVersion string `json:"postgresqlVersion,omitempty"`
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
type PostgreSQLInstance struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec PostgreSQLInstanceSpec `json:"spec"`
// +optional
Status PostgreSQLInstanceStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type PostgreSQLInstanceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []PostgreSQLInstance `json:"items"`
}
@@ -0,0 +1,70 @@
package v1alpha1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// DatabaseProvisionRequest 仅用于动态申请,省略名称时由 controller 按 Tenant 名称解析。
type DatabaseProvisionRequest struct {
InstanceRef InstanceReference `json:"instanceRef"`
// +optional
Database PostgreSQLIdentifier `json:"database,omitempty"`
// +optional
LoginRole PostgreSQLIdentifier `json:"loginRole,omitempty"`
}
// PostgreSQLTenantSpec 显式选择动态申请或已有 Database,不重复声明来源。
// +kubebuilder:validation:XValidation:rule="has(self.provision) != has(self.databaseRef)",message="exactly one of provision and databaseRef is required"
type PostgreSQLTenantSpec struct {
// +optional
Provision *DatabaseProvisionRequest `json:"provision,omitempty"`
// +optional
DatabaseRef *DatabaseReference `json:"databaseRef,omitempty"`
// Extensions 保留后端扩展名称的原样拼写,不按 SQL identifier 限制。
// +listType=set
// +optional
Extensions []string `json:"extensions,omitempty"`
// SecretName 指定 Tenant namespace 内的投射目标,省略时使用合同约定的默认名称。
// +optional
SecretName ObjectName `json:"secretName,omitempty"`
}
type PostgreSQLTenantStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// DatabaseRef 只有在资源侧确认绑定后才写入。
// +optional
DatabaseRef *BoundDatabaseReference `json:"databaseRef,omitempty"`
// +optional
Phase string `json:"phase,omitempty"`
// SecretName 是已观察到的同 namespace 投射目标,不包含凭据值。
// +optional
SecretName ObjectName `json:"secretName,omitempty"`
// CredentialURL 只含 OpenBao API 位置,禁止嵌入认证信息。
// +optional
CredentialURL string `json:"credentialURL,omitempty"`
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase in ['Binding', 'Bound', 'Deleting']) || ((has(self.spec.provision) == has(oldSelf.spec.provision)) && (!has(oldSelf.spec.provision) || self.spec.provision == oldSelf.spec.provision) && (has(self.spec.databaseRef) == has(oldSelf.spec.databaseRef)) && (!has(oldSelf.spec.databaseRef) || self.spec.databaseRef == oldSelf.spec.databaseRef))",message="binding target cannot change after binding starts"
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase in ['Binding', 'Bound', 'Deleting']) || (has(self.status) && has(self.status.phase) && self.status.phase in ['Binding', 'Bound', 'Deleting'])",message="binding progress cannot return to an unbound state"
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.status.databaseRef.name`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
type PostgreSQLTenant struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec PostgreSQLTenantSpec `json:"spec"`
// +optional
Status PostgreSQLTenantStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
type PostgreSQLTenantList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []PostgreSQLTenant `json:"items"`
}
+312
View File
@@ -0,0 +1,312 @@
package v1alpha1_test
import (
"context"
"io"
"os"
"path/filepath"
"testing"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/yaml"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
const (
testNamespace = "database-api"
testInstanceName = "shared-postgres"
readyPhase = "Ready"
)
func TestDatabaseAPI(t *testing.T) {
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
t.Skip("KUBEBUILDER_ASSETS 未设置;运行 make test 执行真实 API server 测试")
}
scheme := runtime.NewScheme()
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
crdPath, err := filepath.Abs("../../../config/crd/bases")
if err != nil {
t.Fatal(err)
}
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}, ErrorIfCRDPathMissing: true}
config, err := environment.Start()
if err != nil {
t.Fatalf("启动 envtest: %v", err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Errorf("停止 envtest: %v", err)
}
})
client, err := ctrlclient.New(config, ctrlclient.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
namespace := &corev1.Namespace{}
namespace.Name = testNamespace
if err := client.Create(t.Context(), namespace); err != nil {
t.Fatal(err)
}
t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) })
t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(t, client) })
t.Run("status隔离和绑定并发", func(t *testing.T) { testBindingWrites(t, client) })
t.Run("仓库示例", func(t *testing.T) { testSamples(t, client, scheme) })
}
func testSamples(t *testing.T, client ctrlclient.Client, scheme *runtime.Scheme) {
paths := []string{
"database_v1alpha1_postgresqlinstance.yaml",
"database_v1alpha1_postgresqldatabase.yaml",
"database_v1alpha1_postgresqltenant.yaml",
}
for _, name := range paths {
t.Run(name, func(t *testing.T) {
file, err := os.Open(filepath.Join("../../../config/samples", name))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := file.Close(); err != nil {
t.Error(err)
}
})
decoder := yaml.NewYAMLOrJSONDecoder(file, 4096)
for {
var raw runtime.RawExtension
if err := decoder.Decode(&raw); err == io.EOF {
break
} else if err != nil {
t.Fatal(err)
}
object, _, err := serializer.NewCodecFactory(scheme).UniversalDeserializer().Decode(raw.Raw, nil, nil)
if err != nil {
t.Fatal(err)
}
resource, ok := object.(ctrlclient.Object)
if !ok {
t.Fatalf("示例不是资源对象: %T", object)
}
if resource.GetNamespace() != "" {
resource.SetNamespace(testNamespace)
}
if err := client.Create(t.Context(), resource); err != nil {
t.Fatalf("示例未通过 API 校验: %v", err)
}
}
})
}
}
func testDefaults(t *testing.T, client ctrlclient.Client) {
instance := validInstance("defaults")
if err := client.Create(t.Context(), instance); err != nil {
t.Fatal(err)
}
endpoint := instance.Spec.Endpoint
if endpoint.Port != 5432 || endpoint.Database != "postgres" || endpoint.SSLMode != "verify-full" {
t.Fatalf("连接默认值不符: %+v", endpoint)
}
credentials := instance.Spec.AdminCredentialRef
if credentials.UsernameKey != "username" || credentials.PasswordKey != "password" {
t.Fatal("管理 Secret 字段默认值不符")
}
database := validDatabase("defaults")
if err := client.Create(t.Context(), database); err != nil {
t.Fatal(err)
}
if database.Spec.ReclaimPolicy != databasev1alpha1.ReclaimRetain {
t.Fatalf("默认回收策略 = %q", database.Spec.ReclaimPolicy)
}
// 进入删除流程前允许双向修改策略,不要求第二次审批字段。
for _, policy := range []databasev1alpha1.ReclaimPolicy{databasev1alpha1.ReclaimDelete, databasev1alpha1.ReclaimRetain} {
database.Spec.ReclaimPolicy = policy
if err := client.Update(t.Context(), database); err != nil {
t.Fatalf("修改回收策略: %v", err)
}
}
objects := []struct {
object ctrlclient.Object
namespaced bool
}{
{instance, false}, {database, false}, {validTenant("scope"), true},
}
for _, item := range objects {
namespaced, err := client.IsObjectNamespaced(item.object)
if err != nil || namespaced != item.namespaced {
t.Fatalf("%T 作用域 = %v, error = %v", item.object, namespaced, err)
}
}
// 导入不要求 Tenant 或 Instance 对象已经存在,跨对象就绪由 controller 判断。
imported := validDatabase("imported")
imported.Spec.Source = "Import"
imported.Spec.CredentialRef = &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing/app"}
if err := client.Create(t.Context(), imported); err != nil {
t.Fatal(err)
}
for _, name := range []string{"first", "second"} {
tenant := validTenant(name)
tenant.Spec.Provision = nil
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: "imported"}
if err := client.Create(t.Context(), tenant); err != nil {
t.Fatalf("声明已有资源申请: %v", err)
}
}
// 两个申请都可被 API 接受,不代表二者都已绑定或获得凭据。
}
func testInvalidDeclarations(t *testing.T, client ctrlclient.Client) {
instanceCases := []struct {
name string
mutate func(*databasev1alpha1.PostgreSQLInstance)
}{
{"port", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.Port = -1 }},
{"address", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.HostAddr = "localhost" }},
{"scoped-address", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.HostAddr = "fe80::1%eth0" }},
{"tls", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.SSLMode = "prefer" }},
{"identifier", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.Endpoint.Database = "bad-name" }},
{"secret-key", func(i *databasev1alpha1.PostgreSQLInstance) { i.Spec.AdminCredentialRef.PasswordKey = "bad/key" }},
}
for _, tc := range instanceCases {
t.Run(tc.name, func(t *testing.T) {
object := validInstance(tc.name)
tc.mutate(object)
requireInvalidCreate(t, client, object)
})
}
databaseCases := []struct {
name string
mutate func(*databasev1alpha1.PostgreSQLDatabase)
}{
{"missing-instance", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.InstanceRef.Name = "" }},
{"missing-role", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.LoginRole = "" }},
{"unknown-source", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.Source = "Adopt" }},
{"missing-credentials", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.Source = "Import" }},
{"provision-credentials", func(d *databasev1alpha1.PostgreSQLDatabase) {
d.Spec.CredentialRef = &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing"}
}},
{"unknown-policy", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Spec.ReclaimPolicy = "Recycle" }},
{"binding-without-uid", func(d *databasev1alpha1.PostgreSQLDatabase) {
d.Spec.TenantRef = &databasev1alpha1.TenantReference{Namespace: testNamespace, Name: "tenant"}
}},
}
for _, tc := range databaseCases {
t.Run(tc.name, func(t *testing.T) {
object := validDatabase(tc.name)
tc.mutate(object)
requireInvalidCreate(t, client, object)
})
}
t.Run("互斥申请入口", func(t *testing.T) {
tenant := validTenant("ambiguous")
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: "existing"}
requireInvalidCreate(t, client, tenant)
tenant.Spec.Provision = nil
tenant.Spec.DatabaseRef = nil
requireInvalidCreate(t, client, tenant)
})
}
// 本测试验证 API 写入语义,不模拟或宣称已经实现 controller 的恢复循环。
func testBindingWrites(t *testing.T, client ctrlclient.Client) {
ctx := t.Context()
tenant := validTenant("binding")
tenant.Status.Phase = readyPhase
if err := client.Create(ctx, tenant); err != nil {
t.Fatal(err)
}
if tenant.Status.Phase != "" {
t.Fatal("普通 Create 不应写入 status")
}
database := validDatabase("binding")
if err := client.Create(ctx, database); err != nil {
t.Fatal(err)
}
stale := database.DeepCopy()
database.Spec.TenantRef = &databasev1alpha1.TenantReference{
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
}
if err := client.Update(ctx, database); err != nil {
t.Fatal(err)
}
stale.Spec.TenantRef = &databasev1alpha1.TenantReference{Namespace: tenant.Namespace, Name: "other", UID: "other-uid"}
if err := client.Update(ctx, stale); !apierrors.IsConflict(err) {
t.Fatalf("过期并发写入 = %v, want Conflict", err)
}
// 换用 API 回读的对象补第二步,证明恢复所需记录不依赖先前内存。
observedDatabase := &databasev1alpha1.PostgreSQLDatabase{}
if err := client.Get(ctx, ctrlclient.ObjectKeyFromObject(database), observedDatabase); err != nil {
t.Fatal(err)
}
if observedDatabase.Spec.TenantRef.UID != tenant.UID {
t.Fatal("资源侧绑定被竞争写入覆盖")
}
beforeGeneration := tenant.Generation
tenant.Status.DatabaseRef = &databasev1alpha1.BoundDatabaseReference{
Name: databasev1alpha1.ObjectName(database.Name), UID: database.UID,
}
if err := client.Status().Update(ctx, tenant); err != nil {
t.Fatal(err)
}
if tenant.Generation != beforeGeneration || tenant.Status.DatabaseRef.UID != database.UID {
t.Fatal("status 更新错误地影响 generation 或绑定身份")
}
tenant.Status.Phase = readyPhase
if err := client.Update(ctx, tenant); err != nil {
t.Fatal(err)
}
if tenant.Status.Phase != "" {
t.Fatal("普通 Update 不应修改 status")
}
condition := metav1.Condition{Type: readyPhase, Status: metav1.ConditionFalse,
Reason: "Pending", Message: "尚未验证后端", LastTransitionTime: metav1.Now()}
tenant.Status.Conditions = []metav1.Condition{condition, condition}
if err := client.Status().Update(ctx, tenant); !apierrors.IsInvalid(err) {
t.Fatalf("重复 Condition = %v, want Invalid", err)
}
}
func requireInvalidCreate(t *testing.T, client ctrlclient.Client, object ctrlclient.Object) {
t.Helper()
if err := client.Create(context.Background(), object); !apierrors.IsInvalid(err) {
t.Fatalf("Create %T = %v, want Invalid", object, err)
}
}
func validInstance(name string) *databasev1alpha1.PostgreSQLInstance {
object := &databasev1alpha1.PostgreSQLInstance{}
object.Name = name
object.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{Host: "postgres.example.test", HostAddr: "127.0.0.1"}
object.Spec.AdminCredentialRef.Name = "postgres-admin"
return object
}
func validDatabase(name string) *databasev1alpha1.PostgreSQLDatabase {
object := &databasev1alpha1.PostgreSQLDatabase{}
object.Name = name
object.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
InstanceRef: databasev1alpha1.InstanceReference{Name: testInstanceName},
Database: "app", LoginRole: "app", Source: "Provision",
}
return object
}
func validTenant(name string) *databasev1alpha1.PostgreSQLTenant {
object := &databasev1alpha1.PostgreSQLTenant{}
object.Name = name
object.Namespace = testNamespace
object.Spec.Provision = &databasev1alpha1.DatabaseProvisionRequest{
InstanceRef: databasev1alpha1.InstanceReference{Name: testInstanceName},
}
return object
}
@@ -0,0 +1,452 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AdminCredentialReference) DeepCopyInto(out *AdminCredentialReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdminCredentialReference.
func (in *AdminCredentialReference) DeepCopy() *AdminCredentialReference {
if in == nil {
return nil
}
out := new(AdminCredentialReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BoundDatabaseReference) DeepCopyInto(out *BoundDatabaseReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BoundDatabaseReference.
func (in *BoundDatabaseReference) DeepCopy() *BoundDatabaseReference {
if in == nil {
return nil
}
out := new(BoundDatabaseReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CredentialReference) DeepCopyInto(out *CredentialReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialReference.
func (in *CredentialReference) DeepCopy() *CredentialReference {
if in == nil {
return nil
}
out := new(CredentialReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseProvisionRequest) DeepCopyInto(out *DatabaseProvisionRequest) {
*out = *in
out.InstanceRef = in.InstanceRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseProvisionRequest.
func (in *DatabaseProvisionRequest) DeepCopy() *DatabaseProvisionRequest {
if in == nil {
return nil
}
out := new(DatabaseProvisionRequest)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseReference) DeepCopyInto(out *DatabaseReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseReference.
func (in *DatabaseReference) DeepCopy() *DatabaseReference {
if in == nil {
return nil
}
out := new(DatabaseReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *InstanceReference) DeepCopyInto(out *InstanceReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceReference.
func (in *InstanceReference) DeepCopy() *InstanceReference {
if in == nil {
return nil
}
out := new(InstanceReference)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLDatabase) DeepCopyInto(out *PostgreSQLDatabase) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabase.
func (in *PostgreSQLDatabase) DeepCopy() *PostgreSQLDatabase {
if in == nil {
return nil
}
out := new(PostgreSQLDatabase)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLDatabase) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLDatabaseList) DeepCopyInto(out *PostgreSQLDatabaseList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]PostgreSQLDatabase, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseList.
func (in *PostgreSQLDatabaseList) DeepCopy() *PostgreSQLDatabaseList {
if in == nil {
return nil
}
out := new(PostgreSQLDatabaseList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLDatabaseList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLDatabaseSpec) DeepCopyInto(out *PostgreSQLDatabaseSpec) {
*out = *in
out.InstanceRef = in.InstanceRef
if in.CredentialRef != nil {
in, out := &in.CredentialRef, &out.CredentialRef
*out = new(CredentialReference)
**out = **in
}
if in.TenantRef != nil {
in, out := &in.TenantRef, &out.TenantRef
*out = new(TenantReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseSpec.
func (in *PostgreSQLDatabaseSpec) DeepCopy() *PostgreSQLDatabaseSpec {
if in == nil {
return nil
}
out := new(PostgreSQLDatabaseSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLDatabaseStatus.
func (in *PostgreSQLDatabaseStatus) DeepCopy() *PostgreSQLDatabaseStatus {
if in == nil {
return nil
}
out := new(PostgreSQLDatabaseStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLEndpoint) DeepCopyInto(out *PostgreSQLEndpoint) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLEndpoint.
func (in *PostgreSQLEndpoint) DeepCopy() *PostgreSQLEndpoint {
if in == nil {
return nil
}
out := new(PostgreSQLEndpoint)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstance) DeepCopyInto(out *PostgreSQLInstance) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstance.
func (in *PostgreSQLInstance) DeepCopy() *PostgreSQLInstance {
if in == nil {
return nil
}
out := new(PostgreSQLInstance)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLInstance) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstanceList) DeepCopyInto(out *PostgreSQLInstanceList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]PostgreSQLInstance, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceList.
func (in *PostgreSQLInstanceList) DeepCopy() *PostgreSQLInstanceList {
if in == nil {
return nil
}
out := new(PostgreSQLInstanceList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLInstanceList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstanceSpec) DeepCopyInto(out *PostgreSQLInstanceSpec) {
*out = *in
out.Endpoint = in.Endpoint
out.AdminCredentialRef = in.AdminCredentialRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceSpec.
func (in *PostgreSQLInstanceSpec) DeepCopy() *PostgreSQLInstanceSpec {
if in == nil {
return nil
}
out := new(PostgreSQLInstanceSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLInstanceStatus) DeepCopyInto(out *PostgreSQLInstanceStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLInstanceStatus.
func (in *PostgreSQLInstanceStatus) DeepCopy() *PostgreSQLInstanceStatus {
if in == nil {
return nil
}
out := new(PostgreSQLInstanceStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenant) DeepCopyInto(out *PostgreSQLTenant) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenant.
func (in *PostgreSQLTenant) DeepCopy() *PostgreSQLTenant {
if in == nil {
return nil
}
out := new(PostgreSQLTenant)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLTenant) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenantList) DeepCopyInto(out *PostgreSQLTenantList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]PostgreSQLTenant, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantList.
func (in *PostgreSQLTenantList) DeepCopy() *PostgreSQLTenantList {
if in == nil {
return nil
}
out := new(PostgreSQLTenantList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PostgreSQLTenantList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenantSpec) DeepCopyInto(out *PostgreSQLTenantSpec) {
*out = *in
if in.Provision != nil {
in, out := &in.Provision, &out.Provision
*out = new(DatabaseProvisionRequest)
**out = **in
}
if in.DatabaseRef != nil {
in, out := &in.DatabaseRef, &out.DatabaseRef
*out = new(DatabaseReference)
**out = **in
}
if in.Extensions != nil {
in, out := &in.Extensions, &out.Extensions
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantSpec.
func (in *PostgreSQLTenantSpec) DeepCopy() *PostgreSQLTenantSpec {
if in == nil {
return nil
}
out := new(PostgreSQLTenantSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgreSQLTenantStatus) DeepCopyInto(out *PostgreSQLTenantStatus) {
*out = *in
if in.DatabaseRef != nil {
in, out := &in.DatabaseRef, &out.DatabaseRef
*out = new(BoundDatabaseReference)
**out = **in
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLTenantStatus.
func (in *PostgreSQLTenantStatus) DeepCopy() *PostgreSQLTenantStatus {
if in == nil {
return nil
}
out := new(PostgreSQLTenantStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TenantReference) DeepCopyInto(out *TenantReference) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantReference.
func (in *TenantReference) DeepCopy() *TenantReference {
if in == nil {
return nil
}
out := new(TenantReference)
in.DeepCopyInto(out)
return out
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
ctrl "sigs.k8s.io/controller-runtime"
)
func setupInstanceObservation(manager ctrl.Manager, namespace, rootCert string) (*application.InstanceService, error) {
credentials, err := kubernetes.NewSecretCredentials(manager.GetConfig(), namespace)
if err != nil {
return nil, err
}
service, err := application.NewInstanceService(credentials, postgresql.Connector{RootCert: rootCert})
if err != nil {
return nil, err
}
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: namespace}
if err := reconciler.SetupWithManager(manager); err != nil {
service.Close()
return nil, err
}
return service, nil
}
+33 -3
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"crypto/tls"
"flag"
"os"
@@ -19,7 +20,10 @@ 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"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
// +kubebuilder:scaffold:imports
)
@@ -32,11 +36,16 @@ func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(executionv1alpha1.AddToScheme(scheme))
utilruntime.Must(databasev1alpha1.AddToScheme(scheme))
// +kubebuilder:scaffold:scheme
}
// nolint:gocyclo
func main() {
var databaseNamespace, databaseRootCert string
flag.StringVar(&databaseNamespace, "database-secret-namespace", os.Getenv("POD_NAMESPACE"),
"固定管理 Secret namespace;为空时不启用 Instance 观测")
flag.StringVar(&databaseRootCert, "database-root-cert", "", "PostgreSQL 管理连接信任的公开 CA bundle 路径")
var metricsAddr string
var metricsCertPath, metricsCertName, metricsCertKey string
var webhookCertPath, webhookCertName, webhookCertKey string
@@ -141,7 +150,7 @@ func main() {
metricsServerOptions.KeyName = metricsCertKey
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
managerOptions := ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
@@ -159,13 +168,29 @@ func main() {
// if you are doing or is intended to do any operation such as perform cleanups
// after the manager stops then its usage might be unsafe.
// LeaderElectionReleaseOnCancel: true,
})
}
if databaseNamespace != "" {
managerOptions.Cache = databasecontroller.InstanceCacheOptions(databaseNamespace)
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), managerOptions)
if err != nil {
setupLog.Error(err, "Failed to start manager")
os.Exit(1)
}
// +kubebuilder:scaffold:builder
var instanceService *application.InstanceService
if databaseNamespace != "" {
instanceService, err = setupInstanceObservation(mgr, databaseNamespace, databaseRootCert)
if err != nil {
setupLog.Error(err, "Failed to set up Instance observation")
os.Exit(1)
}
}
if err := (&databasecontroller.BindingReconciler{}).SetupWithManager(context.Background(), mgr); err != nil {
setupLog.Error(err, "Failed to set up Database binding controller")
os.Exit(1)
}
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "Failed to set up health check")
@@ -177,7 +202,12 @@ func main() {
}
setupLog.Info("Starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
err = mgr.Start(ctrl.SetupSignalHandler())
// worker 完全停止后才释放 pgxpool,避免与在途观察竞争。
if instanceService != nil {
instanceService.Close()
}
if err != nil {
setupLog.Error(err, "Failed to run manager")
os.Exit(1)
}
@@ -0,0 +1,226 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: postgresqldatabases.database.ayatori.ddupan.top
spec:
group: database.ayatori.ddupan.top
names:
kind: PostgreSQLDatabase
listKind: PostgreSQLDatabaseList
plural: postgresqldatabases
singular: postgresqldatabase
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.instanceRef.name
name: Instance
type: string
- jsonPath: .spec.database
name: Database
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: PostgreSQLDatabaseSpec 是一库、一个 login owner 及凭据的独立资源声明。
properties:
credentialRef:
description: |-
CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
properties:
mount:
maxLength: 253
minLength: 1
type: string
path:
description: Path 是 mount 内的逻辑路径,不含 KV v2 的 data/ API 前缀。
maxLength: 1024
minLength: 1
type: string
required:
- mount
- path
type: object
database:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
instanceRef:
description: InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
required:
- name
type: object
loginRole:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role 使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
reclaimPolicy:
default: Retain
description: ReclaimPolicy 控制资源释放后的处置,只有资源管理者可以修改。
enum:
- Retain
- Delete
type: string
source:
description: Source 明确区分创建与只读导入,不从后端同名对象推断。
enum:
- Provision
- Import
type: string
tenantRef:
description: TenantRef 由 controller 先写入;Released 时仍保留旧身份。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
namespace:
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
type: string
uid:
description: |-
UID is a type that holds unique ID values, including UUIDs. Because we
don't ONLY use UUIDs, this is an alias to string. Being a type captures
intent and helps make sure that UIDs and names do not get conflated.
maxLength: 128
minLength: 1
type: string
required:
- name
- namespace
- uid
type: object
required:
- database
- instanceRef
- loginRole
- source
type: object
x-kubernetes-validations:
- message: only imported databases require an existing credentialRef
rule: (self.source == 'Import') == has(self.credentialRef)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
instanceUID:
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
type: string
observedGeneration:
format: int64
type: integer
phase:
description: Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
type: string
type: object
required:
- spec
type: object
x-kubernetes-validations:
- message: managed database target cannot change after observation or binding
starts
rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID)))
|| (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database
== oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole
&& self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef)
== has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef)
|| self.spec.credentialRef == oldSelf.spec.credentialRef))'
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,191 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: postgresqlinstances.database.ayatori.ddupan.top
spec:
group: database.ayatori.ddupan.top
names:
kind: PostgreSQLInstance
listKind: PostgreSQLInstanceList
plural: postgresqlinstances
singular: postgresqlinstance
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
adminCredentialRef:
description: AdminCredentialReference 只能读取 controller namespace 的
Secret。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
passwordKey:
default: password
maxLength: 253
minLength: 1
pattern: ^[-._a-zA-Z0-9]+$
type: string
usernameKey:
default: username
maxLength: 253
minLength: 1
pattern: ^[-._a-zA-Z0-9]+$
type: string
required:
- name
type: object
endpoint:
description: PostgreSQLEndpoint 显式区分证书主机名与实际连接 IP,不进行 DNS 推导。
properties:
database:
default: postgres
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
host:
maxLength: 253
minLength: 1
type: string
hostaddr:
maxLength: 45
type: string
x-kubernetes-validations:
- message: hostaddr must be a single IPv4 or IPv6 address
rule: isIP(self)
port:
default: 5432
format: int32
maximum: 65535
minimum: 1
type: integer
sslMode:
default: verify-full
enum:
- disable
- require
- verify-ca
- verify-full
type: string
required:
- host
- hostaddr
type: object
required:
- adminCredentialRef
- endpoint
type: object
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
format: int64
type: integer
phase:
enum:
- Pending
- Validating
- Ready
- Deleting
type: string
postgresqlVersion:
type: string
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,223 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: postgresqltenants.database.ayatori.ddupan.top
spec:
group: database.ayatori.ddupan.top
names:
kind: PostgreSQLTenant
listKind: PostgreSQLTenantList
plural: postgresqltenants
singular: postgresqltenant
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .status.databaseRef.name
name: Database
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: PostgreSQLTenantSpec 显式选择动态申请或已有 Database,不重复声明来源。
properties:
databaseRef:
description: DatabaseReference 是 Tenant 对已有集群级 PostgreSQLDatabase
的选择。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
required:
- name
type: object
extensions:
description: Extensions 保留后端扩展名称的原样拼写,不按 SQL identifier 限制。
items:
type: string
type: array
x-kubernetes-list-type: set
provision:
description: DatabaseProvisionRequest 仅用于动态申请,省略名称时由 controller 按
Tenant 名称解析。
properties:
database:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
instanceRef:
description: InstanceReference 仅引用同 API group 的集群级 PostgreSQLInstance。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group
引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
required:
- name
type: object
loginRole:
description: PostgreSQLIdentifier 是第一版受管 database 与 login role
使用的名称。
maxLength: 63
pattern: ^[a-z][a-z0-9_]{0,62}$
type: string
required:
- instanceRef
type: object
secretName:
description: SecretName 指定 Tenant namespace 内的投射目标,省略时使用合同约定的默认名称。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
type: object
x-kubernetes-validations:
- message: exactly one of provision and databaseRef is required
rule: has(self.provision) != has(self.databaseRef)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
credentialURL:
description: CredentialURL 只含 OpenBao API 位置,禁止嵌入认证信息。
type: string
databaseRef:
description: DatabaseRef 只有在资源侧确认绑定后才写入。
properties:
name:
description: ObjectName 定位集群级资源,不携带 namespace 或隐式跨 API group 引用。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
uid:
description: |-
UID is a type that holds unique ID values, including UUIDs. Because we
don't ONLY use UUIDs, this is an alias to string. Being a type captures
intent and helps make sure that UIDs and names do not get conflated.
maxLength: 128
minLength: 1
type: string
required:
- name
- uid
type: object
observedGeneration:
format: int64
type: integer
phase:
type: string
secretName:
description: SecretName 是已观察到的同 namespace 投射目标,不包含凭据值。
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$
type: string
type: object
required:
- spec
type: object
x-kubernetes-validations:
- message: binding target cannot change after binding starts
rule: '!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase
in [''Binding'', ''Bound'', ''Deleting'']) || ((has(self.spec.provision)
== has(oldSelf.spec.provision)) && (!has(oldSelf.spec.provision) || self.spec.provision
== oldSelf.spec.provision) && (has(self.spec.databaseRef) == has(oldSelf.spec.databaseRef))
&& (!has(oldSelf.spec.databaseRef) || self.spec.databaseRef == oldSelf.spec.databaseRef))'
- message: binding progress cannot return to an unbound state
rule: '!has(oldSelf.status) || !has(oldSelf.status.phase) || !(oldSelf.status.phase
in [''Binding'', ''Bound'', ''Deleting'']) || (has(self.status) && has(self.status.phase)
&& self.status.phase in [''Binding'', ''Bound'', ''Deleting''])'
served: true
storage: true
subresources:
status: {}
+3
View File
@@ -2,6 +2,9 @@
# since it depends on service name and namespace that are out of this kustomize package.
# 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
+5
View File
@@ -65,6 +65,11 @@ spec:
- --health-probe-bind-address=:8081
image: controller:latest
name: manager
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
ports:
- containerPort: 8081
name: health
@@ -0,0 +1,23 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: database-management-credentials
namespace: system
rules:
- apiGroups: [""]
resources: [secrets]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: database-management-credentials
namespace: system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: database-management-credentials
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+1
View File
@@ -7,6 +7,7 @@ resources:
- service_account.yaml
- role.yaml
- role_binding.yaml
- database_credentials_role.yaml
- leader_election_role.yaml
- leader_election_role_binding.yaml
# The following RBAC configurations are used to protect
+41 -6
View File
@@ -1,11 +1,46 @@
---
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
- postgresqlinstances/finalizers
- postgresqltenants/finalizers
verbs:
- update
- apiGroups:
- database.ayatori.ddupan.top
resources:
- postgresqldatabases/status
- postgresqlinstances/status
- postgresqltenants/status
verbs:
- get
- patch
- update
- apiGroups:
- database.ayatori.ddupan.top
resources:
- postgresqlinstances
- postgresqltenants
verbs:
- get
- list
- patch
- update
- watch
@@ -0,0 +1,15 @@
# 管理员登记已有数据库;不会因创建 CR 就修改数据库或凭据。
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLDatabase
metadata:
name: imported-app
spec:
instanceRef:
name: shared-postgres
database: existing_app
loginRole: existing_app
source: Import
credentialRef:
mount: secret
path: existing/app/postgresql
reclaimPolicy: Retain
@@ -0,0 +1,11 @@
# Instance 观察 controller 尚未接入;管理 Secret 由管理员在 controller namespace 提供。
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLInstance
metadata:
name: shared-postgres
spec:
endpoint:
host: postgres.example.test
hostaddr: 192.0.2.10
adminCredentialRef:
name: shared-postgres-admin
@@ -0,0 +1,25 @@
# 二选一:动态申请或显式引用已有 Database;当前只有绑定协调,没有供应/交付 controller。
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLTenant
metadata:
name: new-app
namespace: default
spec:
provision:
instanceRef:
name: shared-postgres
database: new_app
loginRole: new_app
extensions:
- pgcrypto
secretName: new-app-postgresql
---
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLTenant
metadata:
name: existing-app
namespace: default
spec:
databaseRef:
name: imported-app
secretName: existing-app-postgresql
+3
View File
@@ -1,5 +1,8 @@
## Append samples of your project ##
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
+5
View File
@@ -26,6 +26,11 @@ cloud-init 设备、bridge/VLAN 映射和默认 placement 由平台维护。
## 生命周期基线
新增资源、绑定、回收或恢复语义前,先引用 Kubernetes 官方对应资源设计与成熟 controller
模式,说明采用部分及有意偏离的原因。不要仅沿用字段名称而忽略生命周期与权限边界。
例如 Database 借鉴 PV/PVC 的资源与申请分离、排他绑定和 Retain,但不引入 CSI 协议、
存储调度或额外 registry;见 [ADR-0009](../decisions/0009-database-resource-and-claim.md)。
所有受管资源必须定义:
- `observedGeneration`
+13
View File
@@ -1,5 +1,18 @@
# 环境与发布
## CI 验证入口
Verify 工作流在 PR 上执行全量测试、lint 和 Database 集成测试;合并到 main 后不通过 push
事件重复运行。需要排障或验证直接推送的紧急修复时,可通过 workflow_dispatch 手动运行。
此约定不减少检查项目,也不修改分支保护设置;常规变更必须经过 PR,直接推送 main 不会自动验证。
Gitea 的 PR 工作流验证分支 head,而不是合并预览提交,见
[官方事件说明](https://docs.gitea.com/usage/actions/faq/)。合并前必须确认最新 head 检查通过,
且与当前 main 合并不会引入未经验证的内容组合;基线有实质变化时先更新分支并重验。
只改变基线引用且目标文件树不变时,不需要为了合并提交的 SHA 不同重复全量验证。
## 环境与制品晋级
Ayatori 首先建立 Dev。首个产品能力完成开发并达到可发布状态前,Prod 不实际存在;此时
没有生产制品需要承载,提前维护第二套环境没有收益。
+123 -9
View File
@@ -1,7 +1,19 @@
# Database 模块
Database 是 Ayatori 首批实际产品领域之一。第一个迁移切片只建立 PostgreSQL Instance 的纯领域
模型,不注册 CRD、不启动 controller,也不访问 PostgreSQL、OpenBao 或 Kubernetes Secret。
Database 是 Ayatori 首批实际产品领域之一。当前已包含三资源 API、分层绑定与 Instance 原生
管理能力观测;尚未完成 Database 供应/导入、Tenant 凭据交付与资源回收链路。
## 当前设计(2026-09-24)
采用 Instance → Database → Tenant 的资源与申请模型;Database 独立存在,支持显式导入、
Retain 后人工重新绑定与资源侧 Delete。撤销 PostgreSQL ownership registry 及任意 status
丢失自动恢复所有权的要求。未知同名资源或创建结果不确定时,清楚报告 Conflict 并人工处理。
依据 [ADR-0009](../decisions/0009-database-resource-and-claim.md),当前合同见
[系统规格](specification.md)。下面的迁移来源与已存在代码不反向约束新设计。
registry adapter、专属迁移/测试及 Instance 的 registry 判定现已撤除;Instance 根据完整管理
能力观察直接判定 Ready。Database 资源与绑定已接入,导入、角色/凭据供应及回收仍未完成。wiki 同步位置见
`homelab-wiki/services/postgresql-tenant-operator.md`,跨仓库发布状态由 wiki 的同步记录维护。
## 来源基线
@@ -13,7 +25,8 @@ Database 是 Ayatori 首批实际产品领域之一。第一个迁移切片只
- 迁移日期:2026-09-20
本目录迁入该基线的 specification、architecture、API、领域、部署、安全、开发、迁移与运维
文档,并只进行 Ayatori 产品归属、API group、目录和链接适配;其余已批准行为保持不变。
文档,最初只进行 Ayatori 产品归属、API group、目录和链接适配;2026-09-24 经维护者
批准的资源/申请分离修订明确替代 registry、自动恢复与原 Retain 合同,其余适用的安全约束保留。
迁移只使用该 commit 中已提交的文件。源仓库
`feature/instance-extension-observations` 工作树中的 `instance.go` 修改与
@@ -21,9 +34,9 @@ Database 是 Ayatori 首批实际产品领域之一。第一个迁移切片只
代码被移动到 Ayatori 的 `internal/database/domain/instance`,测试 import 和文档链接相应更新;
首个后续切片按已批准合同增加 Instance extension observation:观测与当前 target 绑定,进入重新
验证或删除时失效,且支持判定不授权 Tenant provisioning。后续 Ready 切片实现管理能力判定、
registry 准备决策与完整回读、Ready 重验及本轮 evidence 前置检查;沿用已批准合同,不能把旧运行
链路接回该模型。各层验证边界见 [Instance 领域规格](domain-instance.md)。
验证或删除时失效,且支持判定不授权 Tenant provisioning。已有 Ready 切片曾按原合同加入
registry 准备决策;这一依赖现已从代码移除,不能把旧运行链路接回模型。
各层验证边界见 [Instance 领域规格](domain-instance.md)。
## 边界
@@ -46,9 +59,10 @@ Secret metadata 和无关字段变化不重建连接。观测后再次读取 Sec
不把旧连接的成功作为新凭据有效的证据;这不构成跨 Kubernetes/PostgreSQL 的原子事务。
Instance UID、endpoint 或凭据引用变化也会释放旧连接;Forget/Close 只释放本地资源。
当前只有 `ObserveVersion` 版本查询用例,不能产生完整 CapabilityObservation 或 Ready。
controller 接入、Secret watch、finalizer、registry 与真实权限检查仍待后续切片;并发 CR 更新
必须由调用者通过 resourceVersion 校验。应用层沿用源实现的串行处理,本阶段未引入新的调度框架。
`ObserveMetadata` 读取服务器版本和可用扩展,`ObserveVersion` 是版本读取便捷入口;两者
不会填充管理检查,不能产生 Ready。`ObserveManagement` 使用同一凭据/连接边界读取完整
原生管理检查,返回绑定当前 target 的 `InstanceObservation`。controller 的资源呈现适配器
使用 resourceVersion 拒绝过期写入。应用层沿用串行处理,不引入新的调度框架。
运行 `make test-database-integration` 验证真实 API server + 一次性 PostgreSQL;fixture 不接受外部
DSN,镜像固定摘要,使用随机本机回环端口并在退出时删除测试容器。覆盖缺失/错误凭据、RBAC、
@@ -58,6 +72,106 @@ namespace 边界、有效值轮换、metadata 无关变化、中途轮换、重
integration 标签代码。领域单测、真实 API 行为与真实 PostgreSQL 行为分别验收,不以本切片
替代整个 Instance controller 的集成验收。
## 服务器 metadata 与扩展观测
SQL adapter 通过一条只读语句读取 `pg_catalog.current_setting('server_version')` 和
`pg_catalog.pg_available_extensions`,避免从已安装列表推断可用列表,且不依赖可修改的
`search_path`。查询失败丢弃整份结果;成功返回空列表与尚未观察严格区分。
参考 PostgreSQL 的 [pg_available_extensions](https://www.postgresql.org/docs/18/view-pg-available-extensions.html)
与 [CREATE EXTENSION](https://www.postgresql.org/docs/18/sql-createextension.html) 合同:可用列表
表示服务器提供的扩展,不证明管理账号有安装权限,也不保证依赖和其他安装前提满足。
`InstanceService` 保留原有 CredentialReader → Connector → Database 边界,复用同一个
凭据读取、连接刷新和串行释放流程,不新增连接池封装或任意查询回调。每次重新查询 metadata,
并在 Secret 有效值回读一致后生成不可变的 `InstanceObservation`,绑定本次 target(含当前
generation),不绑定建池时的旧 target。结果不包含凭据,扩展集合不与 driver 的可变 slice 共享。
凭据中途变化、读取失败或查询失败时,返回零值观察并释放连接,不复用旧的扩展列表。
调用方可将 `Target()` 与 `Extensions()` 交给 Instance 的 `ObserveExtensions`;应用调用链
仍负责同轮次使用,不能持久化或跨轮缓存这份证据。metadata 读取不安装扩展、不初始化 registry、
不设置 Ready,也不授予 Tenant 写权限。管理权限检查使用下面的独立入口。
真实 API server + PostgreSQL 测试验证未安装扩展可被观察、名称保持大小写、search_path 遮蔽
不改变查询来源、低权限账号读取、权限撤回失败与恢复、Secret 中途变化丢弃扩展结果。
单元测试补充成功空列表、查询附带部分数据时丢弃、结果与可变 slice 隔离、每轮重新读取和
generation 变化时的目标绑定;原凭据/TLS/超时/并发测试沿同一 metadata 路径继续运行。
## Registry 撤除
原 `adapter/postgresql/registry`、SQL 迁移、registry 专属集成测试及 tern 依赖已删除;
未提交的 inspection 实验也已撤除。来源仍可在 Git 历史追溯,没有删除外部 PostgreSQL 对象。
Instance 不再具有 InitializingRegistry 阶段、RegistryState、准备决策或回读方法;
首次完整管理能力观察即可完成验证,重验失败则撤销本轮就绪证据。
保留凭据读取与连接刷新、TLS、metadata/扩展观察及其真实后端测试。领域测试覆盖每项能力
在初次验证和 Ready 重验时失败、依赖恢复、重启后重新取证、错误目标/阶段及删除保护。
这不等于三资源供应、交付和回收已实现。
## Instance 原生管理观测
2026-09-25 维护者确认先使用原生非 superuser 方案,不引入 SECURITY DEFINER 接口。
`InspectManagement` 通过同一条只读语句读取当前执行角色的 `CREATEROLE`、`CREATEDB`、
superuser 属性、服务器可写状态、版本和扩展列表。不创建探针数据库/角色,不初始化 schema。
只有非 superuser、具备两项原生属性且当前服务器/会话可写时,基础管理能力才通过。
角色属性不可从继承成员关系推导;具体已有资源仍须检查 owner、membership 与授权范围。
权限依据和真实测试对应:
- role:当前角色具有 CREATEROLE,可创建普通登录角色;
- database:当前角色具有 CREATEDB,且会话非只读、服务器不在 recovery;
- grant:使用自己新建角色的管理权限,显式建立 SET membership,再以 owner 管理数据库 ACL;
- extension:新建数据库 owner 可安装 trusted 扩展;可用列表不是安装授权,非 trusted 或
其他前提不满足的扩展仍可能失败,必须逐请求执行和回读。导入不继承此动态供应授权。
参考 PostgreSQL 官方 [CREATE ROLE](https://www.postgresql.org/docs/18/sql-createrole.html)、
[CREATE DATABASE](https://www.postgresql.org/docs/18/sql-createdatabase.html) 与
[CREATE EXTENSION](https://www.postgresql.org/docs/18/sql-createextension.html)。这些检查是基础
能力观察,不是未来操作必然成功的保证;权限、容量、连接数等仍可能在执行时变化。
`InstanceReconciliation` 协调 finalizer、观察、领域判定和删除引用检查,controller 只连接
事件、用例、状态呈现与重试。每轮重建无证据的领域对象;旧 Ready 不授权新一轮操作。
失败清除当前版本结果并撤销 Ready;CR 在观察期间被修改则拒绝旧结果,下一轮重新读取。
连接/权限变化由 Secret watch 和 30 秒重查驱动,单轮 IO 最长 15 秒;不持续写入相同状态。
manager 通过 `--database-secret-namespace`(默认 `POD_NAMESPACE`)启用 Instance 观测;
为空时不启用。本地运行需显式提供该参数。Deployment 使用 downward API 获取自身 namespace;
Secret 的 get/list/watch 权限由该 namespace 的 Role 单独授予,不放入 ClusterRole。
watch 使用 controller-runtime 的 metadata-only cache,读取有效凭据仍直连 API server。
TLS 使用既有 endpoint 合同,公开 CA bundle 可由 `--database-root-cert` 指定;不会自动挂载
生产证书或创建管理 Secret。manager worker 停止后统一关闭 pgxpool。
Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Database(含 Released、删除中)
或动态 Tenant 申请都会阻止 finalizer 解除;列表查询失败也等待。仅在引用全部解除后移除
`database.ayatori.ddupan.top/instance-protection`,不删除 PostgreSQL、账号或凭据。
引用查询与删除不是跨对象事务;后续供应仍必须拒绝已删除/删除中的 Instance。
验收使用真实 PostgreSQL + API server:原生管理账号实际建库、owner 授权、trusted 扩展
安装/回读,拒绝非 trusted 扩展;权限撤回/恢复、只读会话、superuser 拒绝和中途轮换。
实际 manager 在生成的资源 RBAC 和 namespaced Secret Role 下验证缺失 Secret 后出现、
轮换、删除、跨 namespace 拒绝和 watch。API 测试另覆盖写入版本冲突、幂等、新 reconciler
恢复与引用删除保护。完整 DBaaS 仍需供应/导入、OpenBao/ESO、Retain/Delete 集成验收。
## 应用凭据存储切片
`adapter/openbao` 使用官方 Go SDK `api/v2 v2.7.0` 的 KV v2 API,只有创建和读取,
不维护 registry、不覆盖已有密码。动态路径由固定前缀与 Database UID 组成;所有访问都校验
配置前缀,已有导入位置也不能绕过 controller 的凭据权限范围。
创建使用 CAS=0,随后回读七键和版本 1;已有值或软删除历史报冲突。关闭 SDK 自动重试,
写入响应丢失、回读失败或内容变化均返回不确定结果,上层不得生成第二份密码或自动认领。
`Read` 只适用于调用方已确认关联的路径,读取成功本身不是管理权证据。错误不传播 SDK
响应体;内存凭据的普通格式化及 JSON 输出均脱敏,明确的 `SecretData` 才返回明文七键。
依据官方 [KV v2 CAS 合同](https://github.com/openbao/openbao/blob/main/internal/builtin/logical/kv/path_data.go)
与 [Go SDK](https://github.com/openbao/openbao/tree/main/api)。`make test-database-integration`
现包含独立 OpenBao dev 容器,固定摘要、随机回环端口、无持久卷,不接受外部地址。
真实后端覆盖创建/回读、并发唯一创建、重建适配器读取、软删除冲突、固定前缀 token
拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。
这一切片尚未接入 manager:Kubernetes auth/token 生命周期、Database 状态中的稳定位置和
已确认步骤、供应 service/controller、PostgreSQL 创建以及 ESO 交付仍未完成。
测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。
## 设计入口
- [系统规格](specification.md):规范性行为与验收标准;
+154 -142
View File
@@ -2,33 +2,89 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| API group | `database.ayatori.ddupan.top` |
| version | `v1alpha1` |
| 最后更新 | 2026-09-10 |
| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 |
| API group/version | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-25 |
本文把已批准的系统规格映射为 CRD 字段合同。API types、生成 CRD、sample 和测试必须与本文
一致。Ayatori 尚未注册这些 API,本页是后续实现的规范来源。
以 [系统规格](specification.md) 与
[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-<Tenant UID>`,首次创建即包含资源侧绑定与 finalizer,不带 Tenant ownerReference。
已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。
同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。
Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察或
绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。
读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。
绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照
映射、finalizer 和状态呈现。呈现前若资源版本已变化,返回冲突供下一轮重读,不覆盖其他修改。
双向记录完成后 Tenant 为 Bound,Ready=False/BindingComplete,明确尚未供应或交付。
生成的 manager ClusterRole 授予资源读写,不包含 Secret 读取;Instance 观测的管理 Secret
权限由固定 namespace 的独立 Role 授予。Instance controller 已接入原生管理观察与引用删除
保护,启用方式及 Ready 边界见 [模块说明](README.md#instance-原生管理观测)。
当前有 Tenant/Database finalizer 保护,但**删除清理尚未实现**:Tenant 删除报告
Ready=False/DeletionPending 并保留绑定与 finalizer,Database 的保护也不会被自动移除。
还未实现删除流程开始后的回收策略固定、Retain 释放、Released 重新开放、外部清理、
扩展只追加、Secret 默认名称解析与凭据交付。在后续清理协议和真实后端验收完成前,
不能作为可用 DBaaS 部署,也不能通过强行移除 finalizer 把它视为已完成清理。
## 通用约定
- PostgreSQL identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`。
- 所有引用名称使用 Kubernetes DNS label/name 的相应校验。
- Tenant 的 `spec.instanceRef` 与 `metadata.name` 长度合计不超过 241 个字符,确保派生的
`<instanceRef>-<metadata.name>-postgresql` 不超过 Kubernetes DNS subdomain 的
253 字符限制。
- port、TLS mode、deletion policy 等固定默认值由 CRD defaulting 提供。database、
loginRole、Secret 名称等依赖其他字段的值是 controller 语义默认值:字段保持省略,
controller 计算 effective value 并通过 status/受管资源展示,不引入 mutating webhook。
- 需要读取旧值或跨字段的校验由 CEL 或 controller 完成。
- `status` 由 controller 独占写入,禁止出现密码、Token、管理用户名或完整连接串。
- 两个 Kind 都只承诺一个 `Ready` Condition;调用方不得依赖内部协调阶段。
- Instance 与 Database 是 cluster-scoped;Tenant 是 namespaced。
- Tenant 按名称引用 Database,不提供 Database namespace;Database 绑定记录包含
Tenant namespace/name/UID。字段见当前 API 切片;绑定采用下述资源侧先写顺序。
- 每类资源提供唯一的 Ready Condition、observedGeneration;phase 用于进度展示,
不能单独作为写权限或所有权证明。
- 引用必须区分定位名称与已绑定 UID;同名新对象不继承绑定。
- controller 管理 status;资源侧管理与回收权限不得随 Tenant editor 权限自动授予。
- 固定默认值使用 CRD defaulting,跨字段/不可变校验使用 CEL 或 controller;
并发更新使用 resourceVersion,不增加 mutating webhook 或跨系统事务。
- database/role identifier 继续匹配 `^[a-z][a-z0-9_]{0,62}$`。
- 动态申请的 database/loginRole 省略时继续以 Tenant metadata.name 为语义默认值;
显式导入资源使用实际目标,不从新 Tenant 名称重新推导。最终互斥字段须经 API 评审。
- 投射仍位于 Tenant namespace,ExternalSecret 默认命名沿用
`<instanceRef>-<metadata.name>-postgresql`;目标 Secret 可由申请指定,省略时同名。
有效 Instance 名称与 Tenant 名称合计不超过 241 字符;完整名称须满足 API 名称校验。
- 任何 spec/status/错误不得出现密码、Token 或完整秘密响应。
## PostgreSQLInstance
cluster-scoped,short name 为 `pginstance`。
### Spec
保留管理入口字段:
| JSON path | 类型 | 必填/默认 | 合同 |
| --- | --- | --- | --- |
@@ -41,150 +97,106 @@ cluster-scoped,short name 为 `pginstance`。
| `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 |
| `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 |
`adminCredentialRef` 不接受 namespace 或 Bao path。管理 Secret 固定在 controller
namespace,名称须合法,两个字段须存在且非空。管理员维护 ExternalSecret,由 ESO
同步;controller 只读管理 Secret,不创建或修改它。此为 2026-09-13 批准的修订,
现有 API types、生成 CRD 和 samples 尚未更新。
Instance endpoint、管理凭据引用可以修改。修改后 controller 重新验证。
2026-09-14 修订:v1alpha1 不实现 allowedExtensions;现有 API types、生成 CRD 和
samples 中的字段待后续移除,不作为一个可配置但被忽略的策略保留。
扩展请求按目标 PostgreSQL 实际可安装列表判断,可用列表由应用层查询。
管理 Secret 固定在 controller namespace,不接受 namespace 或 Bao path。
管理员维护其 ExternalSecret,controller 只读;有效用户名/密码变化时重验连接。
endpoint 变化使旧观察失效,不迁移旧服务器的数据,不自动接管旧 UID 的资源。
endpoint 由管理员负责,不校验变更前后是否同一物理服务器/registry,只重验新配置
的连接与管理能力。新 UID 按新 Instance 处理,不授权接管旧 UID 的 Tenant 资源。
Status 保留 observedGeneration、postgresqlVersion 和 conditions;
phase 为 Pending、Validating、Ready、Deleting,移除 InitializingRegistry。
Ready 要求连接、metadata 与所需管理权限,不要求 registry。
实例实际可用扩展来自查询,不提供 allowedExtensions 配置。
### Status
开始受管时保存 finalizer;删除时阻止新供应,并检查 Database(含 Released)及未绑定
Tenant 引用。无引用才解除,不级联删除资源;查询失败不能视为无引用。
| JSON path | 类型 | 含义 |
| --- | --- | --- |
| `status.observedGeneration` | int64 | 最近完成有结论协调的 generation |
| `status.phase` | enum | `Pending`、`Validating`、`InitializingRegistry`、`Ready`、`Deleting` |
| `status.postgresqlVersion` | string | 从 server 回读的版本,不用于客户端解析 |
| `status.conditions[]` | `metav1.Condition` | 至少包含唯一的 `Ready` |
## Database 资源(工作 Kind:PostgreSQLDatabase)
print columns:`Endpoint=.spec.endpoint.host`、`Phase`、`Ready`、`Age`。
v1alpha1 以一个 database、一个兼任 owner 的 login role 及其应用凭据作为 Database 的
生命周期边界;不提供多账号字段或独立 Role/Credential CRD。多账号需求留待后续 API 版本。
此决定不改变导入只读验证和显式管理授权的要求。
Instance `Ready=True` 要求管理凭据可读、TLS/认证成功、server metadata 可读、registry
可访问且权限预检成功。它不代表数据库已经备份或高可用。
Database 是平台管理的集群级资源,不归属于应用 namespace,也不需要资源专用 namespace。
普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放;
这些资源管理操作由平台管理员授权。controller 的绑定协调权限与用户申请权限分别配置。
管理凭据从 Kubernetes Secret 装配;已有有效凭据可访问 PostgreSQL 时,Bao/ESO
暂时不可用不单独撤销 Instance Ready。Tenant 凭据操作仍依赖 Bao。
以下是行为合同,具体 schema 见当前 API 切片与生成的 CRD;后端行为尚未实现:
| 内容 | 合同 |
| --- | --- |
| `instanceRef` | Database 自身必填;定位来源并记录绑定的 Instance UID,不依赖 Tenant 补齐 |
| 外部目标 | 实际 database 名称及已确认的管理范围;操作开始后不可隐式改目标 |
| 来源 | 区分动态供应与管理员显式导入;不能从同名存在推断导入授权 |
| 绑定 | 至多一个 Tenant,含 namespace/name/UID;Released 保留旧身份;无允许绑定名单 |
| 回收策略 | 资源侧 Retain(默认)或显式 Delete;普通 Tenant editor 不得扩大授权 |
| 观察与进度 | 实际目标、当前阶段、条件和安全诊断,不保存秘密 |
概念生命周期包含供应/验证、可绑定、已绑定、Released 和删除;最终 phase 枚举待协议评审。
Released 不自动变成可绑定。Database 不以 Tenant 为 GC owner;使用中的资源受删除保护。
导入的初始检查只读;存在不等于 Ready。未绑定且可用的资源允许 Tenant 显式申请,
不要求管理员逐 Tenant 授权;已占用或 Released 的资源不能直接绑定。
默认保留不隐含密码、owner、授权或删除的变更许可。
## PostgreSQLTenant
namespaced,short name 为 `pgtenant`。
保留用户申请与交付职责:
### Spec
- 动态申请描述 Instance、所需数据库/登录角色、扩展与目标 Secret;
或显式引用管理员已登记的 Database。后者从 Database 获取 Instance,不重复指定来源;
两条路径互斥,具体 schema 在 API 切片固定。
- 绑定前固定有效需求,绑定/开始供应后不能通过改引用或名称迁移资源。
- extensions 成功后只允许追加,不自动卸载。
- status 展示绑定 Database 身份、Ready、交付 Secret 引用及不含认证信息的 OpenBao URL。
- Tenant 删除释放申请,按照 Database 的回收策略处理,不独立持有最终删除授权。
| JSON path | 类型 | 必填/默认 | 合同 |
| --- | --- | --- | --- |
| `spec.instanceRef` | string | 必填 | cluster-scoped Instance 名称 |
| `spec.database` | string | `metadata.name` | 合法 PostgreSQL identifier |
| `spec.loginRole` | string | `metadata.name` | database owner 兼应用 login |
| `spec.extensions` | set[string] | 空集合 | 必须属于目标实例实际可安装的扩展列表;成功创建后只允许追加 |
| `spec.credential.secretName` | string | `<instance>-<name>-postgresql` | 合法的同 namespace ESO target Secret 名称 |
| `spec.deletionPolicy` | enum | `Retain` | `Retain` 或 `Delete` |
旧 Tenant `spec.deletionPolicy` 不再作为最终资源回收策略;旧字段表、供应 phase 枚举及
仅按 Tenant namespace/name 派生凭据路径的规则不再是实现合同。已有代码没有兼容负担,
不保留两套相互覆盖的策略字段。
Tenant 不声明 OpenBao mount 或 path。controller 使用部署级 mount/base path 和
`namespace/name` 推导稳定路径,并用 UID metadata 验证所有权。ExternalSecret 固定为
`<instanceRef>-<metadata.name>-postgresql`;目标 Secret 可以由用户指定,只需
满足 Kubernetes Secret 名称校验,不限制命名内容;省略时使用相同默认名。
## 已确认的绑定顺序
`instanceRef`、`database`、`loginRole` 和 `credential.secretName` 在首次成功创建外部
状态后不可变。
`extensions` 只允许集合不变或追加;移除返回 `ImmutableField`,不会执行
`DROP EXTENSION`。`deletionPolicy` 在对象进入删除前可以修改;删除开始后以 finalizer
首次观察到的值为准,避免清理过程中改变授权范围。
1. 动态申请按 Tenant UID 确定 Database 名称并创建记录;已有资源申请使用指定的 Database,
检查资源未绑定且可用,不增加反向授权名单。动态创建重试遇到同名记录时需核对身份与目标。
2. 使用 resourceVersion 并发控制,先在 Database 记录 Tenant namespace/name/UID。
已绑定其他 Tenant 时报告 Conflict,不抢占;版本冲突后重新读取、重新判断。
3. 在 Tenant status 记录 Database name/UID。若上一步成功、本步失败,后续 reconcile
核对身份后补齐,不回滚已经成功的资源侧绑定。
4. 双向记录一致才允许动态供应或凭据交付;实际数据库与凭据验证通过后才可 Ready。
### Status
此顺序借鉴 PV/PVC 的资源侧先写模式,行为依据见
[系统规格](specification.md#5-动态供应与排他绑定)。Retain 释放时保留旧绑定身份并进入
Released,不自动清空后重新分配。API 记录部分写入由 reconcile 重试;外部创建结果
无法确认时仍报告 Conflict,交给人工,不新增事务队列或 registry。
| JSON path | 类型 | 含义 |
| --- | --- | --- |
| `status.observedGeneration` | int64 | 最近完成有结论协调的 generation |
| `status.phase` | enum | controller 状态机的权威 checkpoint |
| `status.database` | string | 应用语义默认值后的实际 database 名称 |
| `status.loginRole` | string | 应用语义默认值后的实际 owner/login role 名称 |
| `status.databaseOID` | uint32 | 回读的 database OID,仅供诊断 |
| `status.credential.secretRef.name` | string | 同 namespace 目标 Secret 名称 |
| `status.credential.openBaoURL` | string | 完整 KV v2 API URL,不含认证信息 |
| `status.conditions[]` | `metav1.Condition` | 至少包含唯一的 `Ready` |
## 绑定协议剩余评审要求
Secret reference 不重复 namespace,因为它必定与 Tenant 同 namespace。OpenBao URL 格式
为 `<consumer-address>/v1/<mount>/data/<derived-path>`;不得包含 Token、用户名、密码或
query credential。
实现前必须明确:
Tenant phase 枚举为 `Pending`、`Planned`、`CredentialCreated`、`RoleCreated`、
`DatabaseCreated`、`ExternalSecretCreated`、`CredentialProjected`、`Ready`、`Deleting`。
它不包含 `Failed` 或 `Retained`;失败类型由 Condition Reason 表达。
1. 引用字段的最终格式,以及落实管理员资源管理、controller 协调与普通申请权限的 RBAC 规则。
2. 绑定记录的最终字段及校验规则,落实上述写入顺序与恢复行为。
3. Tenant UID 变化、对象删除、Released 旧引用与管理员重新授权的判断。
4. 同一物理数据库重复登记的冲突处理;列表查询不是原子认领。
5. 已有凭据关联字段、旧访问处置与投射清理顺序;动态凭据路径已确定按 Database UID 定位。
6. Tenant/Database finalizer 配合;回收策略默认 Retain,删除流程前可改,进入后固定。
print columns:`Instance`、`Database`、`Phase`、`Secret`、`Ready`、`Age`。完整 OpenBao URL 只在
YAML/JSON status 中输出。
资源管理者设置 Delete 即表示删除授权,不增加额外审批字段。导入显式关联已有凭据;
Released 不自动改密,管理员处理旧访问后才重新开放。Tenant 不得自选任意 OpenBao 路径。
两个 Kind 的 `status.phase` 都是 controller 状态机的权威 checkpoint。controller 用它
选择下一候选动作,但必须在动作前后核对外部事实,不能仅凭 phase 跳过幂等检查。status
丢失或领先于实际状态时必须保守重建/纠正。自动化就绪判断仍应读取 `Ready` Condition;
phase 用于进度展示、恢复和排障。
该协议使用 Kubernetes API 持久化,不为它新增 PostgreSQL registry。
未完成一致绑定不得供应或交付;外部创建结果不确定按 Conflict 人工处理。
## Condition
## Conditions
每种类型最多一个 Condition;更新必须保留正确的 `lastTransitionTime` 语义。
每类资源至少提供唯一的 Ready;同类型 Condition 不重复,维护 lastTransitionTime 与
observedGeneration。最低安全错误分类见 [系统规格](specification.md#10-conditions-与可观测性)。
| Reason | Kind | 可重试性 |
| --- | --- | --- |
| `Reconciling` | 两者 | 正常进行中 |
| `Ready` | 两者 | 已收敛 |
| `InvalidSpec` | 两者 | 修改 spec 前不会恢复 |
| `ImmutableField` | Tenant | 恢复原值或重新迁移 |
| `DependencyUnavailable` | 两者 | 自动重试 |
| `AuthenticationFailed` | Instance | 修复凭据/TLS 后重试 |
| `InsufficientPrivileges` | Instance | 修复管理 role 后重试 |
| `InstanceNotReady` | Tenant | Instance 恢复后重试 |
| `Conflict` | Tenant | 人工解除名称/所有权冲突 |
| `ProvisioningFailed` | Tenant | 按错误类别退避重试 |
| `CredentialProjectionFailed` | Tenant | ESO/Secret 恢复后重试 |
Conflict 必须说明目标、步骤、已确认与不确定部分及人工核实建议;不能建议清空 status、
伪造 Ready 或改密码绕过。可恢复依赖故障退避重试,冲突不忙循环。
具体 Reason 在 API 切片固定,Released 不得误报为可立即交付。
`Ready=True` 必须使用 Reason `Ready`。处理中为 `Unknown/Reconciling`;已知未满足合同为
`False`。Condition message 可以包含资源名和错误类别,禁止包含凭据值或完整 Secret。
## 实现验收
## 删除语义
- `Retain` 不需要等待外部依赖;删除 CR 后外部记录保留原 UID 并标记 unmanaged。
- `Delete` 添加 finalizer,严格按规格的所有权验证和清理顺序执行;失败保持 finalizer。
- Instance 开始受管时即添加并保存 finalizer;删除时停止新供应,存在 Tenant 引用
(包括正在删除的 Tenant)就保留 finalizer,无引用才移除。引用查询失败时继续等待。
不级联删除 Tenant 或外部资源;管理员可使用运维逃生流程。
- finalizer 不禁止创建 Tenant CR;并发创建者遇到删除中或不存在的 Instance 不得
开始供应。首版不增加跨对象锁或准入控制,不承诺跨对象原子删除。
## 示例
```yaml
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLInstance
metadata:
name: shared
spec:
endpoint:
host: postgresql.home.arpa
hostaddr: 192.0.2.10
port: 5432
database: postgres
sslMode: verify-full
adminCredentialRef:
name: shared-postgresql-admin
---
apiVersion: database.ayatori.ddupan.top/v1alpha1
kind: PostgreSQLTenant
metadata:
name: netbox
namespace: netbox
spec:
instanceRef: shared
database: netbox
loginRole: netbox
extensions: [pg_trgm]
credential:
secretName: shared-netbox-database-credentials
deletionPolicy: Retain
```
CRD defaulting、CEL、status subresource、resourceVersion、并发绑定、重启与依赖 watch
使用真实 API server 验证;ownerReference/namespace 删除的实际 GC 使用测试集群。
类型、CRD、sample 和 contract tests 必须一起对齐,不以 fake client 替代 API 语义。
+38 -79
View File
@@ -1,99 +1,58 @@
# 系统架构
# Database 系统架构
本文是已批准 [`specification.md`](specification.md) 的架构视图。规范定义外部行为,
本文解释组件边界;二者冲突时以规范为准。Ayatori Database 模块目前只有首批领域模型,尚未
注册 API 或接入运行链路。
本页解释 [系统规格](specification.md) 的组件边界;资源与申请分离的依据见
[ADR-0009](../decisions/0009-database-resource-and-claim.md)。设计已确认,运行链路尚未完成。
## 组件与数据流
## 资源与后端
```text
GitOps / kubectl / Terraform / Backstage
Kubernetes API
Instance ──引用── Database ──排他绑定── Tenant
|
v
Kubernetes API (CRD)
|
v
Ayatori Database controller
Ayatori Database controllers
| | |
v v v
PostgreSQL DBMS OpenBao KV ExternalSecret
catalog+registry |
v
Kubernetes Secret
PostgreSQL OpenBao ExternalSecret
catalog |
ESO → Secret
```
- Kubernetes `spec` 保存期望状态;`status.phase` 保存 controller 状态机 checkpoint,
其他 status 字段保存可重建的观察结果。整个 status 都必须能由外部事实保守恢复。
- PostgreSQL catalog 保存 database、role、grant 和 extension 的实际状态。
- 两个 CR 的 `status.phase` 是 controller 状态机的权威 checkpoint。
- PostgreSQL 管理 database 中的 controller registry 只负责所有权、安装身份和保留标记。
- OpenBao KV v2 是应用凭据的事实来源。
- External Secrets Operator(ESO)读取 OpenBao,并创建应用使用的 Kubernetes Secret。
Kubernetes 保存声明、绑定与操作进度;PostgreSQL 保存实际数据库状态;OpenBao 保存应用凭据。
不在 PostgreSQL 中再建立管理 registry。Instance 是管理入口而非 CSI 协议实现,adapter 是薄访问层。
controller 不运行 PostgreSQL/OpenBao,不管理 VM、存储、备份或 OpenBao PKI,也不直接
把明文凭据写入 Kubernetes API。
Instance 验证当前目标的管理能力,不供应 Tenant 数据库,不因 registry 缺失初始化任何 schema。
Database 用例负责独立资源的供应、显式导入、保留与回收;Tenant 用例负责申请、绑定与凭据交付。
这是用例职责,不要求为每一步新增一个 controller 或通用控制循环。
## 资源模型
## 协调与恢复
`PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的
DNS host、IP host address、端口、管理 database、TLS 模式和管理 Secret 引用。
实际可安装扩展由应用层查询后交给领域对象判定,v1alpha1 不实现管理员 allowlist。
通过 Kubernetes API 的 resourceVersion 保护并发更新,watch 推动依赖恢复;外部操作前保存
资源与意图,执行后观察并记录结果。数据库实际操作仍须处理后端竞态,列表检查不是唯一约束。
管理连接使用管理员维护的 ExternalSecret 经 ESO 同步到 controller namespace 的
Secret;Instance 只选择 Secret 名称与字段,controller 只读,不直接从 Bao 获取
管理凭据。Tenant 凭据的创建、读取与销毁仍由 controller 直接访问 Bao。
可靠确认的步骤允许幂等继续;外部创建与进度写入之间的失败若导致归属不确定,则停止写入并
报告 Conflict。不创建第二套所有权存储,不承诺跨系统事务或任意 status 丢失自动认领。
错误必须提供人工可用的步骤、资源与结果确定性信息,但不泄漏秘密。
`PostgreSQLTenant` 是 namespaced。一个 Tenant 对应一个 database、一个同时作为 owner
的 login role、一组只允许追加的 extension、一个由 controller 推导的 OpenBao KV
记录,以及同 namespace 的 ExternalSecret 和目标 Secret。
## 绑定、导入与回收
Tenant namespace 只提供 Kubernetes RBAC 和身份边界。database 与 role 名称在一个
Instance 内仍然全局唯一。
Database 独立于 Tenant 存在,不能以会导致级联删除的 ownerReference 连接两者。
Instance 删除检查 Database 引用,包括 Released 资源,不直接清理数据库。
## Reconcile 与所有权
动态供应和管理员导入使用同一种资源记录。导入验证初始只读;未知同名数据库仍为冲突。
Retain 保留资源及旧绑定身份;重新绑定必须经过人工数据和访问权限处置,不自动分配。
Delete 由资源侧明确授权,在 finalizer 保护下按管理范围清理并逐步回读。
系统采用最终一致性,不在 Kubernetes、PostgreSQL、OpenBao 和 ESO 之间假装存在分布式
事务。每个外部写入前在 CR status 记录阶段,执行幂等操作,回读验证,再推进阶段:
角色、凭据与投射的具体管理字段和清理顺序仍需 API 评审,不能用 PV 类比代替数据库权限设计。
```text
Planned -> CredentialCreated -> RoleCreated -> DatabaseCreated
-> ExternalSecretCreated -> CredentialProjected -> Ready
```
## 保持的访问边界
controller 每轮同时读取 CR、registry、PostgreSQL catalog、OpenBao metadata 和 ESO
投射状态。`status.phase` 是状态机 checkpoint,但不能替代外部回读;丢失或与事实冲突
时必须保守重建/纠正。`metadata.generation` 只表示 spec 修改;Condition 的
`observedGeneration` 表示该版本是否已经完成一次有结论的协调。
管理 Secret 固定在 controller namespace,管理员维护其 ExternalSecret;controller 只读。
有效值变化刷新管理连接,不直接访问 Bao 获取管理凭据,不自行实现连接池。
应用凭据写 OpenBao,由 ESO 投射;controller 不直接写明文 Secret。
TLS、DNS/IP SAN、七键凭据输出和最小权限合同继续适用。
所有权使用 Instance UID、Tenant UID 与 namespace/name 验证。database/role COMMENT
可以辅助排障,但不能代替 registry。未知资源只报告 `Conflict`,不得修改、接管或
删除。Retain 后用相同名称重建 CR 会获得新 UID,因此仍然冲突。
## 导航
## 创建与删除边界
创建时先校验全部输入和冲突,再生成一次密码并写入 OpenBao,随后创建 role、database、
extension 和 ExternalSecret。只有 ESO 已投射 Secret 且应用凭据实际登录成功,Tenant
才可 Ready。
`Retain` 是默认删除策略,只移除 Kubernetes 管理关系并保留外部资源。显式 `Delete`
使用 finalizer,在重新验证所有权后依次删除 ExternalSecret/Secret、连接、database、
role、OpenBao KV 历史和 registry。详细恢复与逃生步骤见
[`operations.md`](operations.md)。
## 网络与 TLS
Instance 同时公布 DNS `host` 和 IP `hostaddr`。PostgreSQL server 证书必须包含对应的
DNS SAN 和 IP SAN,消费者自行选择可达目标,并可使用 `verify-full` 验证。OpenBao PKI
持有 CA 私钥并签发服务端证书;controller 只挂载公开 CA bundle。
OpenBao 的 controller 内部地址和外部消费者地址可以不同。Tenant status 同时提供目标
Kubernetes Secret reference 和不含认证信息的 OpenBao KV v2 API URL。
## 文档入口
- API 字段与 Condition:[`api-reference.md`](api-reference.md)
- 安装、依赖和配置:[`deployment.md`](deployment.md)
- 本地与 CI 测试:[`development.md`](development.md)
- 安全模型与最小权限:[`security.md`](security.md)
- 现有数据库迁移:[`migration.md`](migration.md)
- 日常排障和删除逃生:[`operations.md`](operations.md)
- [API 合同与待细化字段](api-reference.md)
- [领域模型](domain-model.md)与[Instance 规格](domain-instance.md)
- [部署](deployment.md)、[安全](security.md)、[开发测试](development.md)
- [导入与迁移](migration.md)、[运维](operations.md)
+30 -21
View File
@@ -1,16 +1,16 @@
# 部署与配置
> 本页迁入作为 Database 模块的目标部署合同。Ayatori manager flags、manifests 与发布装配尚未
> 实现;原设计行为保持有效,但当前仓库不能直接按本页完成部署。
> 本页区分已实现的 Instance 观测配置与尚未接入的供应/交付目标合同。
> 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| 环境 | homelab Kubernetes + 外部 PostgreSQL/OpenBao |
| 最后更新 | 2026-09-10 |
| 最后更新 | 2026-09-25 |
本文定义 v1alpha1 的运行依赖、启动顺序和部署级配置。当前 manifests 尚未实现这些
配置,示例是后续实现合同,不可直接用于现有脚手架。
本文定义 v1alpha1 的运行依赖、启动顺序和部署级配置。Instance 观测已接入 manager;
OpenBao、ESO 与完整供应装配仍是后续实现合同。
## 依赖与顺序
@@ -30,8 +30,13 @@
## Controller 配置合同
controller 使用以下 CLI flags。必填项缺失、路径无效或 duration 不为正数时,进程必须
在启动 manager 前失败;不得等到 reconcile 时才逐个资源报告配置错误。
当前 manager 支持 `--database-secret-namespace`(默认 `POD_NAMESPACE`,为空则停用
Instance 观测)与 `--database-root-cert`(公开 PostgreSQL CA PEM 路径)。Deployment
通过 downward API 获取 namespace,Secret 权限由该 namespace 的 Role 授予。
以下是尚待实现的供应/交付配置合同,不表示当前 manager 接受这些 CLI flags。
必填项缺失、路径无效或 duration 不为正数时,进程必须在启动 manager 前失败;
不得等到 reconcile 时才逐个资源报告配置错误。
| CLI flag | 必填/默认 | 说明 |
| --- | --- | --- |
@@ -43,7 +48,7 @@ controller 使用以下 CLI flags。必填项缺失、路径无效或 duration
| `--openbao-service-account-token-path` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | Kubernetes auth 使用的投射 token 文件 |
| `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 |
| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 |
| `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 |
| `--database-root-cert` | 已实现 | 只读 PEM trust bundle,不含私钥;沿用 Instance 连接配置 |
| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 |
address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/`
@@ -51,9 +56,11 @@ address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或
`/` 开头,不含空段、`.` 或 `..`;base path 还不得编码 KV v2 的 `data`/`metadata`
API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用于明确的开发 fixture。
Tenant 路径固定推导为 `<base-path>/<namespace>/<metadata.name>`。namespace/name 都已通过
Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer
address 拼为 `<address>/v1/<mount>/data/<base-path>/<namespace>/<metadata.name>`。
Tenant 不能选择任意凭据路径。凭据必须能随 Database 保留并安全交付给被授权的新 Tenant;
原 `<base-path>/<namespace>/<metadata.name>` 定位规则不再直接作为新 API 合同。
动态供应位置使用 `<base-path>/<Database UID>`;导入使用 Database 的显式 credentialRef,
不要求搬迁已有凭据。供应流程须先记录原 mount/path,不能在配置变化后重新推导位置。
consumer URL 仍使用无认证信息的 KV v2 API URL。
base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空段、`.`、`..`、
`data`/`metadata` API 层。ExternalSecret 固定命名为
@@ -61,8 +68,8 @@ base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空
满足 Kubernetes Secret 名称校验,不限制命名内容,默认与 ExternalSecret 同名。
配置变化不得隐式迁移既有凭据。修改 KV mount/base path 或 consumer address 前必须
停止 controller、评估现有 Tenant,并走明确迁移;实现应把 mount/base path 视为安装
身份的一部分并在 registry 留存,以便检测错误配置。
停止 controller、评估现有 Database 与绑定,并走明确迁移。资源记录应能定位原凭据,
不能根据新部署参数静默切换;不再使用 registry 保存安装身份。
## PostgreSQL 管理 role
@@ -73,13 +80,13 @@ base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空
- 创建 database 并指定 owner;
- 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT;
- 连接租户 database 并创建实例实际支持、租户申请的 extension;
- 创建和维护 controller 专属 registry schema/table;
- `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。
部分 PostgreSQL 操作天然要求较高权限,尤其终止其他 session 和安装某些 extension。
应优先使用 PostgreSQL 预定义角色、受控 SECURITY DEFINER 管理函数或限定数据库的
授权;任何不得不使用 superuser 的 extension 都必须按实例单独记录,不得扩大默认
controller 权限。最终可执行 SQL grant 将随 PostgreSQL adapter 集成测试固化。
第一版使用原生非 superuser 的 CREATEDB/CREATEROLE 方案,不引入 SECURITY DEFINER
管理接口。对自行创建的 owner 显式建立 SET membership,再以 owner 管理 ACL 与扩展;
已有对象仍须逐资源核实授权,不能凭基础属性接管。需要 superuser 的扩展不能扩大 controller
权限。真实权限矩阵见 [Instance 原生管理观测](README.md#instance-原生管理观测)。
## OpenBao 与 ESO
@@ -90,13 +97,15 @@ data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不
只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得
读取 PostgreSQL 管理凭据。controller 不创建或修改管理 ExternalSecret/Secret。
`ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。
controller 创建的 ExternalSecret 与 Tenant 同 namespace,并设置 ownerReference;目标
controller 创建的 ExternalSecret 与 Tenant 同 namespace;其 ownerReference 和 Retain 时的
保留/清理须与凭据交付协议一起确定,不能把投射关系等同于 Database 的 GC 关系。目标
Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、
`sslmode`。
## Kubernetes RBAC
- controller 可读/写 Instance、Tenant 的 status/finalizer 和 Event。
- controller 按用例读/写 Instance、Database、Tenant 及其 status/finalizer 和 Event。
- Database 不设置随 Tenant 级联删除的 ownerReference;导入、预留、回收和重新绑定授权限管理员。
- controller 可在 Tenant namespace 创建、读取、更新、删除 ExternalSecret,并只读检查
对应 Secret 是否完成投射。
- namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller
@@ -108,9 +117,9 @@ Secret 包含固定七键:`username`、`password`、`database`、`host`、`hos
## 升级与回滚
v1alpha1 尚不承诺跨版本转换。升级前备份 CR、PostgreSQL registry 和 OpenBao metadata,
v1alpha1 尚不承诺跨版本转换。升级前备份 CR/绑定、PostgreSQL 数据与 OpenBao,
先在隔离 Kind 环境运行 E2E。禁止在同一组 CR 上同时运行两个 controller 版本。若新版本
在执行任何破坏性迁移前失败,可回滚镜像;涉及 API/storage 或 registry schema 迁移时,
在执行任何破坏性迁移前失败,可回滚镜像;涉及 API/storage 或凭据定位迁移时,
必须先写独立升级规格和回滚步骤。
## 上线验证
+51 -15
View File
@@ -3,7 +3,37 @@
> 本页迁入作为 Database 模块的测试分层与 fixture 合同。旧项目的 Make target、devcontainer
> 和脚手架版本尚未适配 Ayatori;实现时应复用 Ayatori 现有工具链,并保持这里定义的测试边界。
## Ayatori 已接入的凭据切片测试
## 当前设计验收(2026-09-24)
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 将资源生命周期从 Tenant 中分离。
新增验收矩阵见 [系统规格](specification.md#11-验收)。registry 实现、专属测试与迁移依赖已撤除,不继续 schema 审计或自动所有权恢复切片。
| 层次 | 本次设计要求 |
| --- | --- |
| 纯领域 | Instance 无 registry 就绪判定、排他绑定、Released 不自动复用、管理范围与冲突规则 |
| envtest | 三资源 schema/status、RBAC、resourceVersion 并发、绑定单边更新/重启、依赖 watch、finalizer |
| 真实 PostgreSQL/OpenBao | 同名不修改、显式导入只读验证、创建不确定报冲突、可靠步骤幂等、Delete 故障重试 |
| 测试集群 | Tenant/namespace 删除不 GC Database、ESO 交付/释放、人工重新绑定前的旧访问处置 |
需故障注入外部成功而 API 写入失败、后端响应丢失、双 Tenant 竞争、同名新 UID、依赖稍后出现、
Instance 删除与 Released 引用。冲突必须给出可操作而不泄密的诊断;不要求自动认领不确定结果。
envtest 不运行 GC 或 ESO;这些行为必须由测试集群验证。
三资源 API 类型与 CRD 已实现,`make test` 包含真实 API server 验证:作用域、
静态默认值、非法声明、status 写入隔离、Condition 唯一性、resourceVersion 冲突与绑定记录回读。
另有绑定 controller 的真实 API server 测试:动态记录幂等、资源侧写入后故障注入、
新 reconciler 回读补齐、双 Tenant 竞争、Released/旧 UID 拒绝、目标固定、陈旧观察、
删除期间 finalizer 保留。实际 manager 在生成的 RBAC 角色下通过 watch/cache 处理依赖
稍后出现,绑定角色不具备 Secret 读取权限。没有 PostgreSQL/OpenBao 写入;后端 Ready
在测试中由 fixture 提供,不能把它当成完整 Instance/Database 观察验证。
Retain 释放和 Delete 清理仍未实现,当前删除会保持 DeletionPending 与 finalizer。
真实后端供应、凭据交付与上述完整删除矩阵仍待后续切片验收。
绑定规则另有不依赖 Kubernetes 的单元测试;service 测试只验证操作顺序、失败停止与最终
身份回读,不模拟 API server。原真实 API controller 测试覆盖完整分层调用,另验证 service
返回后发生并发修改时,资源呈现拒绝过期结果,重试完成绑定且保留其他字段。
## Ayatori 已接入的凭据与 metadata 切片测试
本节命令已在 Ayatori 接入;以下历史 Compose/Kind 操作仍属于迁入的目标合同。
@@ -28,8 +58,16 @@ runner 后端修复上线,不能从本地测试通过推断远端已经可用
fixture 启动失败会保留退出错误与 stderr,并遮蔽测试密码,
以区分缺少命令、daemon 不可达、权限和镜像拉取失败。
这些测试尚不包含 Instance CRD/controller、Secret watch、status/finalizer 事件链、registry、
权限探测矩阵、ESO 或 Tenant 供应。版本查询成功不意味着 Instance Ready。
metadata 测试验证版本与可用扩展的只读查询,包括未安装扩展、大小写保持、search_path 遮蔽、
低权限账号读取、catalog 访问被撤回后的失败与恢复,以及凭据中途变化时同时丢弃版本和扩展。
权限撤回只修改每个场景自建 PostgreSQL 容器的 ACL;不连接现有服务。
可用列表不等于安装权限,这些检查不替代后续的完整管理权限矩阵或 Instance Ready 验收。
Instance 领域测试不再提供 registry 状态,初次验证与 Ready 重验分别覆盖所有管理检查项的
未观察、不可用、认证失败、权限不足及未知值,并验证依赖恢复;完整管理观察可直接 Ready。
PostgreSQL adapter 测试不包含 controller、Secret watch、status/finalizer 事件链、权限探测矩阵、ESO 或 Tenant 供应。
CRD 基础语义由前述 API 测试覆盖;版本查询成功不意味着 Instance Ready。
本项目同时依赖 Kubernetes API、PostgreSQL、OpenBao 和 ESO。日常开发不连接 homelab
中的真实服务:Kubernetes 使用 envtest 或一次性 Kind,另外两个依赖使用一次性
@@ -95,8 +133,8 @@ make lint
`make test` 会下载与 `go.mod` 中 Kubernetes minor 版本匹配的 envtest 二进制,
启动临时 API server/etcd,测试结束后自动关闭。
规格实现后,快速测试必须覆盖默认值/校验、Condition `observedGeneration`、两个 CR 的
status 状态机、不可变字段、extension 只追加、registry 所有权和外部错误分类。envtest 只断言 controller 创建了正确
规格实现后,快速测试必须覆盖默认值/校验、Condition `observedGeneration`、三资源的
状态与绑定、不可变字段、extension 只追加、冲突和外部错误分类。envtest 只断言 controller 创建了正确
的 ExternalSecret;它不能证明 ESO 已生成 Secret。
### 2. 启动 PostgreSQL/OpenBao adapter 依赖
@@ -146,8 +184,8 @@ make test-integration
```
该 target 会启动一次性 Compose 依赖,并通过 `POSTGRES_TEST_DSN` 把测试指向开发
PostgreSQL。registry 测试会删除并重建固定的测试 schema,因此禁止将该变量指向真实
homelab database。测试后运行 `make dev-down` 清理依赖。
PostgreSQL。这是旧环境设计,不是当前 Ayatori 入口;当前 fixture 不接受外部 DSN,
使用本页前部的 `make test-database-integration`,禁止把测试指向真实 homelab database。
Gitea Actions 的 job 本身运行在 Docker container 中,不能通过 `127.0.0.1` 访问
Docker host 上发布的 Compose 端口。CI 会暂时将 job container 加入 Compose 网络,
@@ -196,24 +234,22 @@ E2E fixture 必须把测试 PostgreSQL、OpenBao 和 ESO 部署进 Kind,并等
-> 等待 Instance Ready
-> 创建 Tenant
-> 等待 Tenant Ready
-> 验证 registry、PostgreSQL catalog、OpenBao KV、ExternalSecret 和 Secret
-> 验证 Database 绑定、PostgreSQL catalog、OpenBao KV、ExternalSecret 和 Secret
-> 分别使用 DNS host 与 IP hostaddr 登录
-> 删除 Tenant 并分别验证 Retain 与 Delete(含故障点重试)
-> 删除 Kind
```
当前 controller 已实现第一条 Instance Ready 纵向链路:E2E fixture 在 Kind 内启动
PostgreSQL/OpenBao,配置 Kubernetes auth,验证管理凭据读取、PostgreSQL 登录、registry
migration 和 Instance Ready。Tenant provisioning、ESO、TLS DNS/IP SAN 与删除路径仍需
后续纵向切片覆盖,不能从 Instance Ready 推断这些合同已经通过。
以上 Compose/Kind 流程来自旧项目的环境设计,不是 Ayatori 已实现的运行状态。
Ayatori 尚未完成 Instance/Database/Tenant controller 链路;当前可执行的切片命令以本页
前部为准,不能从旧脚手架或 adapter 测试推断完整生命周期已经通过。
## 测试数据与泄漏检查
- 只使用显眼的固定 canary 测试密码,测试后扫描日志、Event、Condition、metrics 和
CR dump,出现 canary 即失败。
- 每个最终一致性阶段都注入一次中断,重启后验证密码不变且阶段只向前推进。
- 清空、落后或伪造超前的 `status.phase` 后验证它能从外部事实保守恢复/纠正,且不会
跳过任何回读。
- 每个写入阶段注入中断:已确认步骤继续且密码不变;结果不确定则停止并明确报告 Conflict。
- 缺少绑定/进度记录时不得凭同名外部对象恢复所有权;过期 phase 不得绕过实际观察。
- 为未知同名 database、role、Bao record 和伪造 COMMENT 分别构造 Conflict。
- Delete 在每个外部删除步骤失败后重试,确认未误删非当前 UID 资源。
- 迁移测试按 [`migration.md`](migration.md) 完整执行,不以单纯 `pg_restore` 成功代替
+66 -229
View File
@@ -1,253 +1,90 @@
# Instance 领域对象规格
状态:Draft,含已确认决策。日期:2026-09-13。
日期:2026-09-25。资源模型修订依据
[ADR-0009](../decisions/0009-database-resource-and-claim.md),行为以
[系统规格](specification.md) 为准。本页替代原 registry 准备与恢复合同;领域依赖已撤除,Instance 应用/controller 观测链路已接入。
上层合并边界见 [ADR-0008](../decisions/0008-merge-postgresql-tenant-operator.md)。本文只展开 Instance,不包含 Tenant 的供应
实现,也不新增 CRD 字段。设计签名用于评审职责与行为,不是待复制的 Go 接口代码。
## 职责
## 1. 对象职责与生命周期
Instance 是登记的 PostgreSQL 管理入口,只接收观察、判断规则,不直接或通过回调执行 IO。
应用层读 Secret、调用 adapter、关联目标与观察,再将结果交给领域判定。
领域不持有客户端、连接池、context 或完整 Database/Tenant 集合。
Instance 表示一次登记的 PostgreSQL 管理对象,是聚合根。它负责字段与策略校验、
根据观察结果判断能力是否满足要求、保护状态转换规则;不登录数据库,不读取 Bao,
不查询权限或初始化 registry。
每轮由 CR 重建;连接可由应用层复用,但旧连接、旧 Ready 不是本轮能力证据。
身份与 endpoint 以管理员声明为准,变更使观察失效,不验证物理服务器连续性,
不迁移旧数据,不自动授权旧 UID 资源的操作。
本草案选择:**领域对象只接收数据并做业务决策,不直接或通过端口、回调访问外部。**
应用层调用适配器获取事实、执行被允许的操作,并将观察结果交回对象。Instance 不接收
context、客户端或 IO 接口。领域行为不是公共 SetReady:调用方提供事实,不能指定结论。
## 字段与观察
每轮从 CR 重建一个 Instance;对象不跨 reconcile 缓存,也不是线程共享单例。
管理连接可由装配层跨轮次复用,但连接复用不代表上次能力验证仍然成立。
| 内容 | 合同 |
| --- | --- |
| identity | Instance UID/name;同名新 UID 是新对象 |
| revision | 当前 generation,不能与旧观察混用 |
| definition | endpoint 与管理 Secret 引用,不含明文 |
| checkpoint | Pending、Validating、Ready、Deleting |
| observedRevision/readiness/version | 映射 CR status,只表示进度或最近结果 |
| availableExtensions | 本轮实际可用集合;未观察与空集合不同 |
| evidence | 本轮目标、版本与管理能力检查结果,不持久化为永久授权 |
| deleting | 删除请求;禁止新的供应 |
身份与 endpoint 以管理员声明为准。改变 endpoint 不验证是否同一物理服务器或
registry,不增加安装身份连续性检查;只使旧观察失效,按新配置重验管理能力。
新 CR 是新 Instance,不自动获得旧 UID 资源的所有权,也不迁移或清理旧目标。
下文“观察绑定匹配”仅指结果属于本轮身份/配置,不是物理服务器身份认证协议。
CapabilityObservation 包含目标(UID、generation、endpoint、凭据引用)、server version、
连接/metadata/role/database/grant/extension 管理检查项。各项区分成功、失败和未观察,
不包含 registry 状态、凭据或驱动错误。缺项或目标不匹配不得 Ready。
## 2. 字段与值对象
实际可用扩展不等于安装权限,不做 allowlist,不改大小写;查询失败不当作不支持,
列表变化不自动卸载。安装必须由实际操作及回读验证。
resourceVersion 留在应用层处理 API 并发,不是领域版本或物理数据库身份。
所有可变状态封装在对象内部。构造后身份和本轮 definition 不可变;配置变更通过
下一轮装载新的 definition 处理,不提供任意 SetPhase/SetReady/SetEndpoint。
## 行为
| 字段 | 类型与内容 | 来源/持久化 | 修改规则 |
| --- | --- | --- | --- |
| identity | InstanceIdentity:UID、name | CR metadata | 本次对象身份内不可变;同名新 UID 是新对象 |
| revision | 正整数,期望配置版本 | metadata.generation | 本轮不可变;不是物理服务器版本 |
| definition.endpoint | Endpoint:host、hostaddr、port、managementDatabase、tlsMode | CR spec | 本轮不可变;新配置重验 |
| definition.adminCredential | CredentialReference:name、usernameKey、passwordKey | CR spec | 只引用 controller namespace 的管理 Secret,不存明文 |
| availableExtensions | 可选的实际可安装扩展集合 | 应用层从目标 PostgreSQL 查询;本轮观察,不新增 status 字段 | 未观察与已观察的空集合不同;目标变化后旧结果失效 |
| checkpoint | Pending/Validating/InitializingRegistry/Ready/Deleting | CR status.phase | 只能由领域动作变更,应用层负责持久化 |
| observedRevision | 最近完成有结论协调的版本 | CR status.observedGeneration | 成功或已知失败时更新,单纯记录意图不更新 |
| readiness | Unknown/Ready/NotReady,加安全失败类别和操作说明 | 由 status Ready Condition 重建,结果再映射回 Condition | 方法更新;不是第二套持久化状态 |
| reportedVersion | 可选服务器版本字符串 | status.postgresqlVersion;验证后从服务器更新 | 仅供展示,不能证明连接成功 |
| deleting | 是否已请求删除 | metadata.deletionTimestamp 映射 | 本轮不可变;优先于其他动作 |
| evidence | 可选 CapabilityEvidence | 本轮外部回读;不新增 status 字段 | 重建时始终为空,不能从 Ready Condition 伪造 |
- Reconstitute 校验 definition,重建 checkpoint,丢弃旧 evidence。
- BeginValidation 清空能力证据并进入 Validating。
- AssessManagement/AssessReadiness 根据本轮完整观察判断 Ready 或安全失败;
方法的具体合并方式在实现重构时决定,不保留无意义的中间初始化阶段。
- CheckExtensions 判定请求集合,不能单独授权供应。
- RequireProvisioningReady 检查本轮能力与删除状态;不授予 Database 所有权。
- BeginDeletion 禁止新供应,不执行外部删除。
- Snapshot 返回值副本,不序列化 evidence 或秘密。
Endpoint 的构造约束沿用 API:非空 host、合法 IP、1–65535 端口、合法 PostgreSQL
identifier、显式 TLS mode,禁止隐式降级。CredentialReference 包含合法 Secret 名称
及非空字段名,不包含 namespace 或 Bao path;namespace 由应用层固定为 controller
自身 namespace。这里校验领域值,不在对象里校验整个 controller 部署配置。
撤销 PlanRegistryPreparation、AssessRegistryResult、RegistryPreparationResult、
RegistryState 与 InitializingRegistry 的设计需求。不能用始终返回 Usable 的兼容层绕过旧逻辑。
CapabilityEvidence 包含本轮目标绑定(Instance UID、revision、endpoint、凭据引用)、
server version、管理能力检查结果、registry 观察结果。registry 结果区分
Absent/NeedsMigration/Usable;连接失败不能当作 Absent。它不包含密码、token 或 DSN。
## 应用与连接边界
管理能力要求来自规格中的 role/database/grant/extension 操作,不等价于“能执行
SHOW server_version”。具体权限探测矩阵需在 PostgreSQL 适配器规格中定义,不能
让一个没有定义检查内容的布尔值承担验收。
管理凭据来自 controller namespace 的 Secret;管理员维护 ExternalSecret,ESO 同步。
Instance 不直接访问 Bao。有效用户名/密码变化时应用层释放旧连接、用新值装配并重验;
metadata/无关字段变化不重建。采集途中有效值变化必须丢弃观察,不因 generation 没变而复用。
controller 不修改 PostgreSQL 密码、管理 Secret 或 Bao 管理凭据。
不属于 Instance 的字段:Tenant 清单、客户端、连接池、token TTL、CA 文件句柄、
Kubernetes resourceVersion。resourceVersion 留在应用层作为乐观并发保存的前提。
InspectManagement 是观察能力,不包含 schema 初始化/迁移。只读 metadata 查询仍不足以
证明管理权限;真实权限矩阵由 adapter 定义和测试。连接复用由 pgxpool 提供,不自建池。
已可用管理凭据下,Bao/ESO 当前故障不单独撤销 Instance Ready。
### 扩展支持判定(2026-09-14 已确认方向)
v1alpha1 按目标 PostgreSQL 实际可安装的扩展列表判断请求,不实现管理员 allowlist。
allowlist 仅保留为后续可选策略,不接受一个看似生效、实际被忽略的策略字段;现有
CRD 的 allowedExtensions 应在对应 API 改动中移除,本次只修订文档。
应用层查询实际可用扩展并提供与本轮目标绑定的观察;Instance 只做集合判断,不
访问数据库。不沿用之前提议的字符正则,不自动改大小写或名称;SQL 适配器仍须
安全引用 identifier。可用列表不是已安装列表,也不保证权限或其他安装前提满足。
未观察/查询失败不得当作空集合或不支持;不得用旧目标的列表授权新目标的操作。
非空请求须属于已观察的可用集合,返回不支持的名称;空请求无需扩展支持判定,
但不绕过 Instance 的其他就绪要求。安装后仍需回读,不能以集合匹配代替安装验证。
列表变化不触发自动卸载;已有扩展的漂移处理留到 Tenant 用例细化。
## 3. 设计签名
## 状态与删除
```text
Reconstitute(identity, revision, definition, checkpointSnapshot, deleting)
-> Instance | InvalidDefinition
Instance.BeginValidation() -> Outcome
Instance.AssessManagement(observation: CapabilityObservation) -> Outcome
Instance.PlanRegistryPreparation(observation: CapabilityObservation)
-> AlreadyUsable | PreparationAllowed | PreparationDenied
Instance.AssessRegistryResult(result: RegistryPreparationResult) -> Outcome
Instance.AssessReadiness(observation: CapabilityObservation) -> Outcome
Instance.CheckExtensions(requested: ExtensionSet)
-> Accepted | ExtensionsUnsupported | ExtensionSupportUnobserved
Instance.RequireProvisioningReady() -> Accepted | InstanceNotReady
Instance.BeginDeletion() -> Outcome
Instance.Snapshot() -> InstanceSnapshot
Pending → Validating → Ready
Ready → Validating(配置/凭据变更或能力失效)
任意阶段 → Deleting
```
Outcome 是正常推进、已知失败或方法前提不成立,不包含重试秒数、Kubernetes patch
或原始驱动错误。InstanceSnapshot 只包含 checkpoint、observedRevision、readiness、
reportedVersion,不能序列化 evidence。快照与集合访问返回值副本。
应用层保存意图、获取观察、领域判定、按 resourceVersion 保存结果;
保存冲突重新装载,不能覆盖较新配置。Instance 观察本身不创建外部资源,
初始 status 缺失可重新探测,不由此推出 Database 所有权可自动重建。
CapabilityObservation 是不可变的事实输入:目标绑定、服务器版本、管理能力检查项和
registry 观察结果;各检查项区分成功、失败、未观察,未观察不视为成功。失败只含安全
类别,不含驱动异常或凭据。对象校验目标绑定与当前身份/配置一致,拒绝不匹配输入,
不改变状态;完整性不足不能产生 Ready。观察结果由应用层收集,对象不能自行证明
这些事实的真实性或实时性;采集来源、同轮次关联和并发检查由应用层保证。
受管前先保存 finalizer。删除期间查询 Database 引用(包括 Released/删除中)及未绑定
Tenant;存在引用或查询失败都等待,无引用才解除。不得级联删除数据库或凭据。
finalizer 不阻止并发申请 CR 创建;新请求见 Instance 删除中/不存在时不得供应。
不引入跨对象锁,不声称列表与删除之间存在原子事务。
RegistryPreparationResult 为操作失败(目标绑定、安全失败类别)或操作后的完整回读
观察。单独的“迁移调用成功”不是就绪证据。CapabilityEvidence 是对象接受并判定满足
要求的观察值,不是调用方传入的 Ready 布尔值。
## 验收与实现差距
### 构造与恢复
领域单测覆盖缺项、旧配置、错误目标、扩展空集合与未观察、重验与删除禁用。
真实 API server 验证 Secret、resourceVersion、watch、finalizer;真实 PostgreSQL 验证
权限、TLS、凭据更新和查询失败,不以领域布尔值或 server_version 查询代替管理权限验收。
Reconstitute 校验期望 definition;无效输入不构造一个可参与用例决策的 Instance。
入口把 InvalidDefinition 映射成 InvalidSpec,不必为了报告坏 CR 而制造非法领域对象。
checkpoint 缺失或未知时保守使用 Pending;reportedVersion 和 Ready 都只是旧观察,
evidence 为空。若 observedRevision 与 revision 不一致,旧 Ready 不得通过供应检查。
### 方法合同
| 方法 | 前置条件/输入 | 行为与状态变化 | 失败语义 |
| --- | --- | --- | --- |
| BeginValidation | 未删除;初次登记、配置变更或需重建 checkpoint | 转 Validating,readiness=Unknown,清空 evidence;不做外部 IO,不推进 observedRevision | deleting 时不启动验证 |
| AssessManagement | 未删除;Validating;目标匹配的观察 | 判定管理访问、metadata、权限是否满足;registry 可用或可安全准备时转 InitializingRegistry,仍为 Unknown;不执行探测 | 失败保持 Validating,NotReady,observedRevision=当前版本 |
| PlanRegistryPreparation | 未删除;InitializingRegistry;本轮前置观察 | 根据管理能力及 registry 现状决定无需写入、允许准备或禁止准备;返回决策,不执行迁移、不标 Ready | 访问失败、不兼容或证据不足时禁止写入,NotReady;保持阶段,更新 observedRevision |
| AssessRegistryResult | 未删除;InitializingRegistry;准备结果或无需写入时的完整回读 | 按全部就绪条件判断回读结果;全满足才 Ready,并更新 observedRevision/version/evidence | 操作失败或回读不满足时保持 InitializingRegistry、NotReady;不得提前 Ready |
| AssessReadiness | 未删除;Ready;本轮观察 | 配置版本不一致时仅 BeginValidation;否则根据全部观察判断是否仍满足就绪条件 | 访问失败转 Validating/NotReady;registry 缺失或需迁移时转 InitializingRegistry,保存后下一轮修复 |
| CheckExtensions | 请求集合;本轮实际可用扩展观察 | 判断请求是否为实际可用集合的子集,返回不支持的名称;无 IO、无状态修改 | ExtensionsUnsupported 或 ExtensionSupportUnobserved;不卸载已存在扩展 |
| RequireProvisioningReady | 供 Tenant 用例使用 | 要求未删除、Ready、observedRevision 匹配,并有本次调用链的新鲜完整 evidence | 不满足即 InstanceNotReady;持久化 Ready 本身不构成授权 |
| BeginDeletion | deleting=true | 转 Deleting,清除供应能力,Unknown;不执行任何数据库或凭据删除 | 引用检查/finalizer 处理失败不得恢复成可供应 |
| Snapshot | 任意合法对象状态 | 返回可安全持久化的结果值 | 不触发 IO,也不改变状态 |
领域方法只检查对象状态,不知道 checkpoint 是否已落盘。“已持久化 checkpoint”是
应用用例执行外部写入的前提。内存字段变成 InitializingRegistry 不代表已保存成功;不能
在同一轮无条件接着执行迁移。通过用例测试验证此约束,而不是伪造一个内存事务。
AssessManagement 成功只是中间步骤,observedRevision 不前移;完成就绪判定或
明确失败才产生相应有结论结果。旧版本字符串可供诊断,但失败会清空 evidence。
Instance 不在本轮暴露 CreateDatabase/DeleteDatabase:Tenant 的供应/销毁授权来自
Tenant 和 OwnershipClaim,不是从 Instance.Ready 推导。数据库执行能力如何承接
已授权动作,留到 Tenant 对象规格,不在这里设计第二个万能 service。
## 4. 应用层与外部访问边界
```text
应用层依赖的适配器能力(不传入 Instance):
InspectManagement(context, target) -> ManagementObservation | AccessFailure
InspectRegistry(context, target) -> RegistryObservation | AccessFailure
EnsureRegistry(context, target) -> Completed | AccessFailure
```
应用层在 IO 前绑定目标并关联结果,领域对象在接受观察时检查身份和配置匹配;旧
endpoint 的成功结果不得用于新 endpoint。Inspect 是只读;EnsureRegistry 是幂等初始化/迁移,
不能顺带建立 Tenant 数据库或接管未知 schema。Completed 不足以推进 Ready,必须回读。
适配器由装配层绑定管理连接;Secret 读取与连接池释放留在该边界之后,Instance
管理连接不涉及 Bao token。适配器不得把基础设施异常转换成 Ready。失败区分依赖不可用、
认证失败、权限不足和 registry 不兼容;不兼容属于不可安全继续,不自动覆写。
registry 不兼容的具体 Condition 映射须在接口规格中确定,不能统一误报权限不足。
管理连接由应用层从 controller namespace 的 Secret 装配;管理员维护 ExternalSecret,
ESO 负责同步。Instance 路径不直接访问 Bao,也不以 Bao/ESO 当前可用性作为就绪条件。
首次装配缺少有效 Secret 时失败;已有凭据可正常访问 PG 时继续按 PG 能力判定。
检测到所引用 Secret 的有效用户名或密码变化时,应用/基础设施层使用新值重建连接池
并重新采集管理能力观察;metadata 或无关字段变化不重建。不要求 Instance generation
变化,也不能复用旧连接的成功观察来证明新凭据有效。Secret 变化监听、连接释放和
刷新均不进入领域对象;应用层保证旧连接观察不混入刷新后的调用链。
controller 不修改 PostgreSQL 密码、不回写 Secret 或 Bao 管理凭据。
## 5. 状态转换与初始化走查
```text
Pending --BeginValidation/保存--> Validating
Validating --AssessManagement(观察)/保存--> InitializingRegistry
InitializingRegistry --AssessRegistryResult(回读结果)/保存--> Ready
Ready --配置变化或访问失败/保存--> Validating
Ready --registry 需修复/保存--> InitializingRegistry
任意阶段 --删除请求/保存--> Deleting
```
1. 入口读取 CR,装配 definition、checkpointSnapshot;客户端不注入领域对象。
2. 应用层按 checkpoint 协调用例;首次调用 BeginValidation,没有 IO。
3. 保存 Validating。若保存失败,结束本轮,不执行 registry 写入。
4. 下一轮应用层调用适配器探测实例,将观察交给 AssessManagement;领域判定通过后
保存 InitializingRegistry,保存失败则停止,不进行迁移。
5. 再下一轮应用层采集前置观察,调用 PlanRegistryPreparation。仅在意图已持久化且
领域允许时调用 EnsureRegistry;AlreadyUsable 则跳过写入,PreparationDenied 则
保存失败结果并停止。允许的操作完成后回读,交给 AssessRegistryResult 决定能否
Ready;操作失败也用安全结果交回,不在应用层直接修改 phase。
6. 入口用原 resourceVersion 前提保存快照;并发变更导致冲突时重新装载,不覆盖新状态。
7. 后续 Ready 检查先由应用层探测,再调用 AssessReadiness;Tenant 用例同样获取当前事实,不能
仅凭另一个 CR 的 Ready Condition 永久缓存授权。实际资源写入仍须处理并发变化。
阶段调度和外部操作顺序在应用层;“观察是否满足业务要求、是否允许准备 registry、
哪些结果算完成、失败退到哪里”在 Instance 方法内。controller 不重复这些规则,
也不直接把 phase 设置成 Ready。领域允许操作并不锁住外部世界,适配器仍须保障幂等
和并发安全;禁止把旧观察当成永久授权。
## 6. 不变量与恢复验收
- UID 不随名称复用;不同 UID 的 evidence/结果不可互用。
- 未完成当前配置的能力回读,不能新产生 Ready,也不能通过供应检查。
- checkpoint 可以落后或被伪造;每次初始化/供应前都核对事实。status 清空只需重新
验证和幂等准备,不删除 registry,更不能重新生成 Tenant 密码。
- 迁移成功而 status 保存失败:重试回读已存在 registry,安全完成,不重复破坏性写入。
- registry 在 Ready 后消失:下一次回读撤销 Ready,保存修复意图后才能重新准备。
- 外部 IO 超时:产生安全失败结果;保存 status 使用仍有效的外层上下文,不能复用
已超时的 IO 上下文而丢失失败状态。
- 已请求删除的 Instance 不允许新供应;BeginDeletion 不删除 PostgreSQL、Tenant 或
Bao。应用层在开始受管时添加并保存 finalizer,而非出现 Tenant 后再添加。
删除时查询所有引用它的 Tenant(含删除中的对象);有引用或查询失败就保留
finalizer,确认无引用才移除。引用查询、finalizer 写入和本地连接释放均不属于
领域 IO,Instance 只根据删除请求禁用供应能力。
- 首版不为 Instance 删除增加跨对象锁或准入控制。并发创建的 Tenant CR 不被
finalizer 拦截,但遇到删除中/不存在的 Instance 不得开始供应;不承诺取消
已在途的外部操作,也不声称引用查询与移除 finalizer 是跨对象原子事务。
- CheckExtensions 失败不能授权扩展安装;可用列表变化不会自行卸载已有扩展。
- Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。
- 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入
得到相同决策。缺少检查项、目标不匹配和旧配置结果不得产生 Ready。
上述每条都对应领域或用例测试;真实权限检查、迁移与并发保障由适配器集成测试
验证。本文为设计文档,未执行或宣称通过这些测试。
## 7. 本轮待评审与后续阻塞项
本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。
管理 Secret 来源、Bao 故障不单独撤销 Instance Ready,以及管理用户名/密码变化时
重建连接池,以及管理员声明的 Instance 身份/endpoint 和简化 finalizer 删除规则
均已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。
批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。
## 8. 领域实现与验证边界(2026-09-21)
`internal/database/domain/instance` 按上述方法合同实现 Ready 纯判定。输入分别表达连接、
metadata、role、database、grant、extension 管理能力,以及 registry 的未观察、缺失、需迁移、
可用、不兼容和不可访问状态。检查零值或未知值按证据不足处理;操作失败只使用封闭的安全
类别,不接收驱动错误。`Snapshot.Failure` 是 Condition 映射的领域输入,不新增 CRD/status 字段。
管理能力分别指目标连接可用、服务器 metadata 可读,以及执行规格 §7 所要求的角色、数据库、
授权和扩展管理操作的能力;不是仅凭版本查询或扩展可用列表判定权限。具体 SQL 权限探测矩阵、
最小权限角色和扩展权限例外仍须在 PostgreSQL adapter 切片定义并用真实后端验证。
registry 不兼容独立保留为领域失败类别,不将其误报为权限不足;公开 Condition Reason 的映射
留待 API/application 切片按原合同评审。
领域测试验证完整回读、缺少检查项、状态重建、重复判定、目标不匹配、配置变化、依赖失败、
registry 丢失/不兼容、操作结果不确定和删除限制。只有本轮完整能力判定通过后,Instance
前置条件检查才通过;这不授予 Tenant 所有权,也不替代实际写入前的并发校验。
本切片不新增 controller、adapter、Secret 读取或外部生命周期操作。checkpoint 保存失败、
resourceVersion 冲突、watch 与 finalizer 事件链需由后续 application/envtest 验证;SQL 探测、
registry 初始化/迁移、超时后的真实状态回读和并发幂等由 PostgreSQL 集成测试验证;Secret
变化后的连接刷新由 Kubernetes API 加真实 PostgreSQL 的集成测试验证。纯领域测试不能证明
Database 已可运行或这些集成合同已完成。
Instance 领域代码、adapter 与测试的 registry 依赖已撤除。AssessManagement 根据完整观察
直接完成验证;AssessReadiness 失败进入 Validating,依赖恢复后重新验证。领域测试覆盖各检查项
在这两个入口的失败与恢复。原生权限检查、Instance controller、metadata-only Secret watch
和引用删除保护已接入,验证矩阵见 [模块说明](README.md#instance-原生管理观测);
Database/Tenant 的供应、交付和回收仍未完成。
+73 -128
View File
@@ -1,149 +1,94 @@
# 领域模型设计草案
# Database 领域模型
状态:Draft,含已确认决策。日期:2026-09-13。
状态:资源模型已确认,字段与绑定协议待细化。日期:2026-09-25。
行为以 [系统规格](specification.md) 为准;决策依据见
[ADR-0009](../decisions/0009-database-resource-and-claim.md)。
本文定义领域职责、身份与一致性边界,并用对象规格细化字段和方法合同;方法使用
设计签名,不固定 Go 目录、SDK 或框架,也不批准实现。外部行为以
[系统规格](specification.md) 为准;下列未决问题不能由实现自行决定。
PR #6 的代码和已有 registry 表结构是可评估的实现素材,不反向决定领域模型。
## 统一语言与关系
## 1. 领域与统一语言
| 术语 | 含义 |
| --- | --- |
| Instance | 平台登记的 PostgreSQL 资源来源与管理入口 |
| Database | 独立存在的数据库资源,保存目标、管理范围、绑定与回收策略 |
| Tenant | 用户对数据库的申请与使用合同 |
| Binding | Database 与 Tenant 的排他关联,不是独立 Claim 或 registry |
| LoginRole | 当前单数据库场景中兼任 owner 的登录角色 |
| CredentialLocation | 与资源生命周期一致的凭据定位,不是密码 |
| CredentialProjection | 面向当前使用者的凭据投射要求与观察 |
本系统的领域是“在共享 PostgreSQL 上供应并管理应用租户”,不是数据库服务器运维。
v1alpha1 先采用一个限界上下文,不把 PostgreSQL、Bao、Kubernetes 各自当成业务上下文。
Instance 一对多 Database;每个 Database 同时零或一个 Tenant,Tenant 最多一个 Database。
Instance 不持有全部资源的内存集合。三者以引用关联,操作一个资源无需加载整个实例集合。
| 术语 | 含义 | 不是什么 |
| --- | --- | --- |
| Instance | 平台登记的外部 PostgreSQL 管理对象及其供应策略 | 连接池、VM 或 controller 单例 |
| Tenant | 一个应用的数据库使用合同及受管资源生命周期 | PostgreSQL database 的别名 |
| Database | 租户数据库的名称、owner、扩展等期望描述与实际观察 | 包含 Bao 登录与连接关闭的操作接口 |
| LoginRole | 同时作为 database owner 和应用登录身份的角色 | 额外的 NOLOGIN owner |
| OwnershipClaim | 某个 Tenant 身份对一组资源名称与凭据位置的所有权声明 | 工作流阶段或仅凭名称推断的归属 |
| CredentialLocation | 固定推导的凭据位置及所有权关联 | 密码本身或用户可任意选择的 KV path |
| CredentialProjection | 把既定凭据交付到目标 Secret 的要求与观察结果 | controller 直接写入明文 Secret |
Instance 与 Database 是集群级资源,Tenant 位于 namespace。Tenant 按名称引用 Database,
Database 记录所绑定 Tenant 的 namespace/name/UID。Database 不属于应用 namespace;
平台管理员管理资源及回收策略,普通申请者不能自行将 Released 资源重新开放。
UID 表示一次 Kubernetes 对象身份;namespace/name 用于定位,不足以证明归属。
database OID 是诊断观察值,不充当本系统的租户身份。
Database 的 instanceRef 表达资源归属,手工登记时也必须提供,不从 Tenant 反推。
Tenant 动态申请才选择 Instance;引用已有 Database 时使用资源声明的 Instance。
释放使用绑定不改变 Database 的实例归属,修改引用不能实现外部数据库迁移。
## 2. 候选聚合边界
Database 不再是 Tenant 内的无独立生命周期描述。原 OwnershipClaim 不再作为独立领域能力:
排他绑定是资源自身的不变量,Kubernetes 保存记录,不另建 PostgreSQL 所有权存储。
### Instance:实例能力与供应策略
## 职责
Instance 是候选聚合根,持有自身身份、endpoint、管理凭据引用、实际可用扩展观察,
以及用于判断当前能力的观察结果。它不持有所有 Tenant 对象的集合。
Instance 只接收观察并判断当前连接、metadata、管理权限和扩展支持,不访问 IO、不初始化
registry。管理凭据来源和连接刷新由应用层协调,连接池由 pgxpool 实现。见
[Instance 规格](domain-instance.md)。
其行为包括:
Database 保护目标和管理范围、排他绑定、导入验证与 Retain/Delete 规则。资源首次外部操作前
必须已有持久记录;完成记录与外部存在性分别检查。数据库名称不是归属证明。
- 判断租户申请的 extension 是否在本实例实际可安装列表中;v1alpha1 暂不实现 allowlist。
- 根据管理连接、服务器信息、registry 和权限检查结果判断是否具备供应能力。
- 判断配置变化使哪些能力观察过期,禁止以旧 generation 的 Ready 证明新配置可用。
- 在 registry 初始化完成并回读验证后,接受新的就绪结果。
Tenant 表达申请与交付要求;可显式申请未绑定且可用的资源,不增加反向授权名单。
绑定后检查数据库满足要求、
应用凭据可登录且 ESO 投射完成,才可 Ready。Tenant 删除意味着释放使用关系。
“探测实例”“准备管理 registry”是应用用例协调的外部操作,不是 Instance 的 IO 方法。
领域对象只接收观察值,负责前提、规则和状态决策;应用层调用适配器获取事实与执行
获准操作。领域对象不持有或调用外部访问端口、客户端或回调。具体选择见
[Instance 字段与行为](domain-instance.md),仍处于待评审状态。
第一版 Database 的生命周期边界包含一个 database、一个兼任 owner 的 LoginRole 及其
应用凭据。LoginRole 与 CredentialLocation 随 Database 保留,不能因为 Tenant 消失就
失去定位或未经授权被删除;导入时的管理授权与具体字段仍需评审。不新增 Role、Credential
或 Claim CRD,也不预留多账号集合;若出现一库多账号的实际需求,再通过后续 API 版本演进。
### Tenant:供应合同与资源生命周期
## 生命周期与恢复
Tenant 是另一个候选聚合根,通过身份引用 Instance,而不是 Instance 的聚合成员。
操作一个 Tenant 不应要求装载、锁定或保存整个实例的租户集合。
- 动态创建与显式导入最终形成同一种 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 任意丢失后自动恢复所有权”的附加要求。
Tenant 持有有效的 database/role 名称、请求的扩展、凭据交付目标、删除策略,以及
已建立的资源绑定。它负责:
## 分层
- 检查绑定后的不可变字段、extension 只追加规则。
- 判断外部部分状态属于本 Tenant、尚不存在,还是与未知资源冲突。
- 决定是否允许继续供应、何时达到 Ready、是否允许释放受管资源。
- 按 Retain/Delete 合同限制行为,禁止把保留资源自动认领给同名新 UID。
| 层 | 责任 |
| --- | --- |
| 领域 | 值、身份、允许动作、不变量、完成与冲突判定;不做 IO |
| 应用 | 装载记录与事实、协调 API 更新和 adapter、回读、交回领域判定 |
| controller | watch/调度、调用用例、请求资源呈现与安排重试;不判断绑定资格 |
| adapter | Kubernetes 资源映射与呈现(含 conditions/status/finalizer)、后端访问与安全错误分类 |
| 装配 | 客户端与成熟连接池的生命周期,不是领域状态 |
Database、LoginRole 和 CredentialProjection 暂不设独立聚合根或独立 CRUD 用例。
它们可作为 Tenant 内的资源描述与观察值;有规则才增加行为,不为了“充血”添加方法。
真实 PostgreSQL database/role 的存在不意味着内存中必须各有一个有身份的实体。
不引入通用 Repository CRUD、跨系统 Unit of Work、事务队列或第二套 phase 存储。
resourceVersion 解决 API 对象并发更新,不宣称 PostgreSQL 与 Kubernetes 原子提交。
聚合边界是业务规则的保护边界,不表示 Tenant 对应的 PostgreSQL、Bao、ESO 资源
能够一次事务提交。跨系统供应必须允许部分完成。
绑定实现中,`domain/binding` 承载请求默认值、资源身份匹配、实例就绪与排他绑定规则;
`application/BindingService` 协调固定申请、资源侧写入和回读确认,返回待呈现结果。
两层均不依赖 Kubernetes API 类型。`adapter/kubernetes/BindingResources` 将 CR 转换为事实
快照,并负责保留其他字段、检查快照版本、写入 finalizer 和呈现 Conditions/status。
controller 仅连接事件、service 与呈现层,不把资源写入细节和领域判断塞进 Reconcile。
这里的接口只列出绑定用例所需操作,不扩展成通用 CRUD、Repository 或事务框架。
### OwnershipClaim:跨租户唯一性与持久证据
## API 切片前需明确
名称唯一性不可能只靠某个 Tenant 的内存检查保证。需要一项领域能力,在持久化边界
原子认领资源;已有 registry 是其适配器候选,仍需结合 catalog 和 Bao metadata 检查。
- 引用与绑定字段的最终格式及校验、管理员与 controller 的权限落实。
- 资源侧回收策略与 Tenant/Database finalizer 配合。
- 导入时角色/凭据的管理范围及关联字段、旧使用者撤权及投射清理。
- 绑定/导入同一实际目标的重复声明如何拒绝,且不引入 registry。
- 管理员确认冲突、解除旧绑定的具体可审计操作入口。
Claim 与 Tenant 关联,但不随 Tenant CR 消失:Retain 后证据必须继续存在。因此不能
把它仅视为 CR 的附属 status。是否作为独立的小聚合,先以“可独立持久化、保留并保护
归属不变量的声明”建模;不因此引入新的 CRD。
- 同一身份、同一绑定的重复认领可以成功;不同 UID 或不同绑定不能覆盖。
- Claim 预留名称不等于证明同名外部资源由本 controller 创建。
- 实际写入仍须核对所有权,不能把先查后建当成并发安全保证。
- 当前 registry 的数据库事务不能覆盖 Bao;跨实例的凭据路径竞争也不能靠单个
registry 的唯一约束解决。写入前提与条件创建协议需单独设计和验收。
## 3. 领域、用例与适配器的分工
| 层 | 承担的职责 | 禁止承揽的职责 |
| --- | --- | --- |
| 领域对象/策略 | 身份、有效合同、归属判断、允许的动作、完成条件 | 外部 IO(包括通过接口间接调用)、解析 CLI、生成 Kubernetes Condition |
| 应用用例 | 装载模型与事实、持久化意图、调用能力、回读、提交结果 | 另写一套绕过领域规则的判断流程 |
| controller 入口 | CR 映射、调度、watch、重试、status/finalizer 写入 | 在 reconcile 中重新定义业务规则 |
| 基础设施适配器 | PostgreSQL、registry、Bao、ESO 的实际读写与并发保障 | 自行决定接管、改密码或扩大删除范围 |
| 启动装配 | 校验部署配置,创建共享客户端、连接管理器及用例依赖 | 把连接生命周期当成 Instance 的业务状态 |
领域可使用独立的身份、endpoint、identifier、extension 集合等值对象,不依赖 CRD
类型、pgx pool 或 Bao SDK。Kubernetes 对象的存取与 registry 的存取不是一个通用
`Save(Tenant)` 可以原子完成的事情;不虚构跨系统 Unit of Work。
暂不引入事件总线、事件溯源、通用聚合框架或全套 Repository CRUD。领域建模的依据
是业务规则,而不是接口和目录数量。
## 4. 状态与恢复
CR `status.phase` 仍是已批准的工作流 checkpoint,不在内存对象或 registry 再建一套
权威 phase。领域对象可以由 CR 的期望状态、checkpoint 和外部观察重新构造。
phase 只决定候选步骤,外部证据决定该步骤是否允许执行、是否已经完成。应用层在
写操作前保存意图,调用幂等操作后回读,再保存下一 checkpoint。status 写入失败时,
下次从外部事实识别完成结果;不能重发密码,也不能相信伪造的 Ready。
业务失败区分 InvalidSpec、ImmutableField、Conflict 等;依赖故障由适配器转换成
安全的能力失败,应用层决定重试并映射 Condition。凭据不进入模型序列化、status、
事件或错误明细;只能在实际需要它的执行边界短暂传递。
## 5. 用例走查与验收方向
| 场景 | 领域判定 | 应用与适配器执行/恢复 |
| --- | --- | --- |
| 登记 Instance | 当前配置的能力要求是否满足 | 读取管理凭据,验证连接与权限,准备并回读 registry;完成后才 Ready |
| 供应 Tenant | Instance 策略、绑定与归属允许供应 | 保存意图,认领资源,先写并回读 Bao 凭据,再创建 role/database,登录验证和 ESO 投射 |
| Bao 写入后进程中断 | 同一身份的部分状态可继续 | 回读原凭据继续,不生成第二份密码 |
| 两个 Tenant 竞争名称 | 只有匹配所有权的一方可继续 | 持久化认领和条件写入裁决竞争,失败方 Conflict,不覆盖资源 |
| Delete 中断 | 已消失资源可视为完成;剩余资源仍须归属正确 | 按规格顺序继续删除,全部回读不存在后才清 registry 和 finalizer |
| Retain 后同名 CR 重建 | 新 UID 不等于原所有者 | Conflict,不恢复管理、不改密码 |
领域测试验证规则与决策;adapter 测试验证锁、条件写入、SQL 与协议行为;controller
测试验证 checkpoint 持久化和重启恢复;E2E 验证最终合同。不能只验证一串 mock 调用
就声称实现了最终一致性。
## 6. 决策记录与待细化边界
1. **Instance 身份与物理目标(已确认)**:以管理员声明为准,endpoint 变更不验证
物理服务器/registry 连续性,不增加安装身份绑定检查;旧观察失效,重验新配置
的连接与管理能力。新 CR 视为新 Instance,不自动接管旧 UID 资源或迁移数据。
2. **Retain 完成条件**:外部依赖不可用不能永久阻止 CR 删除,但 registry 又需标记
unmanaged。需定义 CR 消失后的补偿/清扫入口及所需身份依据,不能承诺同时原子
完成两者,也不能在没有回读时声称已写入保留标记。
3. **管理凭据来源与 Ready(已确认)**:Instance 引用 controller namespace 内的
管理 Secret 名称和字段;管理员维护 ExternalSecret,ESO 同步。controller 不直接
从 Bao 读取管理凭据。已有凭据仍可访问 PG 时,Bao/ESO 故障不撤销 Instance Ready;
首次装配无有效 Secret 则失败。Secret 的有效用户名/密码变化时重建管理连接池并
重验,不因无关字段变化重建;controller 不修改 PG 密码,不回写 Secret 或 Bao。
4. **绑定时机**:系统规格写“首次成功后不可变”,API 文档写“首次创建外部状态后
不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失
后恢复;否则供应中途改名称可能产生无人管理的资源。
5. **Instance 删除(已确认)**:开始受管即添加 finalizer;删除期间停止新供应,
有 Tenant 引用就等待,无引用才解除,不级联删除外部资源。首版采用 finalizer
与引用检查,不引入跨对象锁/准入控制;不保证并发创建与删除的原子性。
Tenant 的 Retain 等待决行为留到 Tenant 设计,不属于本轮 Instance 设计范围。
相关用例在决策批准前不进入实现,不同时实现整套模型。
这些细节不阻止已确认的三资源设计,但必须先于对应 API 与生命周期实现获得评审。
+32 -10
View File
@@ -1,13 +1,34 @@
# 现有数据库迁移 Runbook
# 现有数据库导入与迁移 Runbook
| 项目 | 内容 |
| --- | --- |
| 状态 | Review;尚未在临时 PostgreSQL 演练 |
| 适用范围 | 任意既有数据库迁移为新建 v1alpha1 Tenant |
| 最后更新 | 2026-09-10 |
| 适用范围 | 管理员显式导入,或通过 dump/restore 迁移到新资源 |
| 最后更新 | 2026-09-24 |
v1alpha1 不接管现有 database、role 或 OpenBao record。本流程通过逻辑 dump/restore 把
数据迁移到 controller 创建的新资源,保留旧资源作为限时回滚点。
当前设计支持管理员显式登记已有 Database;未知同名资源仍不得自动认领。
导入不要求移动数据,不隐含改密码、owner、授权或删除权限。API schema 与绑定协调已实现,
但导入观察和凭据交付尚未实现,以下导入步骤
是验收要求而非可直接执行的命令。
## 显式导入与保留资源复用
1. 核对 Instance、数据库、owner、角色权限、扩展、使用者与备份,确定允许管理的范围。
2. 由管理员声明 Database,指定已有目标,回收策略默认 Retain;导入验证初始只读。
3. 安全关联现有应用凭据;具体 API 待定,不把密码写入 CR,不因验证失败重置密码。
4. Tenant 显式引用未绑定且可用的 Database;controller 验证要求并建立排他绑定,无额外名单审批。
5. 验证实际登录与 ESO 交付;不满足时停止,不以修改原数据库作为默认修复。
Released 资源复用前另需核实旧使用者的访问权限、数据交接与投射处置。保留旧绑定身份直到
人工处理完成,不仅靠清空 claimRef 或修改 UID 授予新使用权。
导入失败时原数据库应保持不变;撤回登记不得触发 Delete。绑定后的回退按 Retain 释放,
检查新投射与访问授权的影响,不能承诺撤回 CR 自动恢复此前所有外部访问状态。
## 可选的 dump/restore 路径
不适合直接导入、需要改变 owner/权限模型或移动数据时,可使用下述逻辑迁移流程。
它不是纳管现有数据库的唯一路径;保留旧资源作为限时回滚点。
以下命令是顺序模板,不可原样复制到真实环境。先把尖括号变量解析成明确值,确认当前
连接目标,再逐条执行。dump 可能包含敏感业务数据,必须放在加密临时存储且不得提交 Git。
@@ -67,8 +88,8 @@ identifier 必须由管理员工具安全引用,不能把未经校验的值直
应用 `PostgreSQLTenant`,使用未被占用的 database/loginRole,等待 Ready。确认:
- registry 记录 UID 正确;
- OpenBao metadata 属于该 Tenant;
- Database/Instance 身份及 Tenant 排他绑定正确;
- OpenBao 凭据位置与 Database 管理范围及当前交付授权一致;
- ExternalSecret Ready 且目标 Secret 已投射;
- 新凭据可以通过 DNS host 和 IP hostaddr 分别登录空 database。
@@ -96,7 +117,7 @@ extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预
### 7. 收尾
回滚窗口结束后,按独立变更删除旧 database/role/旧凭据;它们不属于 controller,禁止
通过 Tenant `Delete` 清理。安全删除 dump 和临时凭据材料,并记录验证结果。
通过新 Database 的 `Delete` 清理。安全删除 dump 和临时凭据材料,并记录验证结果。
## 回滚
@@ -105,9 +126,9 @@ extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预
1. 立即停止新目标写入。
2. 评估切换后是否产生新数据;若有,先决定反向迁移或接受丢弃,不能盲目切回。
3. 将应用连接切回 retained database/role;若必须恢复原名称,先确保新受管目标已用
`Delete` 完整清理或改用不同名称,再安全地反向执行 rename。
资源侧 `Delete` 完整清理或改用不同名称,再安全地反向执行 rename。
4. 恢复旧凭据(MD5 环境可能需要重设),验证旧服务。
5. 保留失败 Tenant 供排障;选择 Retain 或 Delete 前明确其外部资源后果。
5. 保留失败 Tenant 供排障;修改 Database 的 Retain/Delete 策略前明确其外部资源后果。
若已经删除旧资源,则只能使用已验证备份恢复,不再属于本 runbook 的快速回滚。
@@ -115,6 +136,7 @@ extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预
发布首个可用版本前,必须在临时 PostgreSQL/OpenBao/Kind 环境执行本文并记录:
- 显式导入的管理权限、Released 重新开放前的旧访问处置和失败不修改原资源;
- 使用的 PostgreSQL major version 和命令版本;
- dump/restore 返回码和对象差异;
- DNS/IP TLS 登录结果;
+61 -54
View File
@@ -1,76 +1,83 @@
# 运维与故障处理
| 项目 | 内容 |
| --- | --- |
| 状态 | Review;命令待实现后演练 |
| 最后更新 | 2026-09-10 |
状态:设计合同,操作入口待 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;后续必须补齐清理与验收。
## 日常检查
先看 API 合同,而不是从日志猜状态:
Instance 观察已实现:先确认 manager 配置了 `--database-secret-namespace` 或 `POD_NAMESPACE`,
再检查 Ready Reason、observedGeneration 与管理 Secret 名称/字段映射,切勿导出其 data。
`InsufficientPrivileges` 表示当前原生方案要求的非 superuser、CREATEDB/CREATEROLE 不满足;
`CredentialsChanged` 会丢弃中途轮换的结果并重验;`InstanceInUse` 消息定位阻塞删除的资源。
Secret 事件立即入队,30 秒重查覆盖 PostgreSQL 权限等没有 Kubernetes 事件的外部变化。
Instance 删除不要求 PostgreSQL 可达,但必须可读取所有 Database/Tenant 引用。
```sh
kubectl get postgresqlinstances
kubectl get postgresqltenants -A
kubectl get postgresqltenant -n <namespace> <name> -o yaml
kubectl describe postgresqltenant -n <namespace> <name>
```
先看 Instance、Database、Tenant 的 Ready Condition、绑定 UID、阶段与 observedGeneration,
再核对 PostgreSQL catalog、OpenBao metadata、ExternalSecret 与 Secret 投射状态。
具体 kubectl 资源名、finalizer 名称与人工确认字段在 API 实现后补齐,不提供猜测的 patch 命令。
不得把 Secret data、密码或带 Token 的请求粘贴到 issue/日志。
随后检查 controller 日志、ExternalSecret/Secret、OpenBao metadata、registry 和
PostgreSQL catalog。排障时不得把 Secret data 或带 Token 的请求粘贴到 issue/日志。
`status.phase` 是 controller 状态机 checkpoint,也用于定位当前步骤;`Ready`
Condition/Reason 用于判断对外结果。phase 不能替代外部事实,清空或不一致时应由
controller 自动重建/纠正。
## 故障分类
## 常见 Reason
| Reason | 首要检查 |
| Reason/状态 | 首要检查 |
| --- | --- |
| `InvalidSpec` / `ImmutableField` | API 字段、identifier、不可变/只追加约束 |
| `DependencyUnavailable` | 网络、DNS、服务状态和超时 |
| `AuthenticationFailed` | 管理凭据、CA、DNS/IP SAN、OpenBao auth |
| `InsufficientPrivileges` | PostgreSQL grants、OpenBao policy、Kubernetes RBAC |
| `InstanceNotReady` | 先恢复所引用 Instance |
| `Conflict` | registry UID、同名 DB/role、OpenBao metadata;禁止直接覆盖 |
| `CredentialProjectionFailed` | ClusterSecretStore、ExternalSecret Condition、目标 Secret |
| `ProvisioningFailed` | `status.phase` 及对应外部资源的回读结果 |
| InvalidSpec / ImmutableField | 请求、名称、不可变目标与只追加扩展约束 |
| DependencyUnavailable | 网络、DNS、服务状态和超时;恢复后退避重试 |
| AuthenticationFailed | 管理 Secret、TLS、OpenBao auth |
| InsufficientPrivileges | PostgreSQL/OpenBao 权限与 Kubernetes RBAC |
| InstanceNotReady | 当前目标的管理能力,不检查 registry |
| Conflict | 绑定 UID、未知同名资源、失败步骤与外部结果确定性 |
| CredentialProjectionFailed | 授权的凭据位置、Store、ESO 与目标 Secret |
| Released | 资源已保留,不代表可直接交给另一个 Tenant |
修复依赖后让正常 reconcile 自动重试。不要通过删除/重建 CR 规避 Conflict;新 UID 只会
使已有保留资源继续冲突。
## 创建不确定或同名冲突
## Retain 后的资源
1. 保留 CR、绑定与安全诊断,不清空 status、不反复删除重建申请。
2. 核对确切 Instance/database/role 和凭据位置;区分已确认完成与结果不确定的操作。
3. 使用只读检查确认资源内容、使用者和权限,不通过重设密码来“验证归属”。
4. 管理员决定清理确定的残留后重试,或显式导入保留资源;涉及删除需另有明确授权。
5. 记录处理依据,再按 API 的受控入口恢复协调。
Retain 删除完成后,database、role、OpenBao record 和 registry 所有权记录仍存在但标记
unmanaged。v1alpha1 不支持重新关联。需要恢复管理时,使用 [`migration.md`](migration.md)
把数据迁移到一个全新受管名称;不要手工把 registry UID 改成新 CR UID。
普通依赖故障可以自动继续,未知归属不得因后端恢复就自动认领。
controller 重启保留 Kubernetes 中的记录,不需要恢复第二套 registry。
## Retain 与重新绑定
Tenant 删除后 Database 及实际资源保留,进入 Released,保存旧绑定身份。
不要删除 Database 对象来“释放名称”,也不要只修改 UID 或 Ready 强行交付。
管理员先确认数据是否允许交给新使用者、旧角色是否共享、旧账号访问如何撤销或保留、
新使用者如何获得凭据,以及原 ExternalSecret/Secret 的处置。删除 Secret 不会撤销已持有密码
的 PostgreSQL 访问。完成这些处置后,才通过显式授权重新绑定;不自动回到可分配状态。
具体凭据关联与解除绑定字段尚待 API 评审,当前不能宣称已有可执行恢复命令。
## Delete 卡住
1. 暂停应用写入并记录 Tenant UID、Instance UID、database、role 和 Bao path。
2. 从 registry 和 OpenBao metadata 独立确认所有权。
3. 检查删除阶段,修复 PostgreSQL/OpenBao/ESO 依赖,让 controller 继续。
4. 若依赖永久丢失,列出每个可能残留的 database、role、KV metadata 和 Secret。
5. 只有确认接受这些残留后,才人工移除 finalizer。
核对 Database/Instance/绑定身份、资源侧 Delete 授权及实际管理范围,修复相关依赖,
让 controller 从已确认的步骤继续。不要删除共享角色或未纳管凭据,不使用扩大范围的 CASCADE。
最终 finalizer 名称由 API 实现固定后补入命令。人工移除 finalizer不会执行剩余清理,
也不会把外部资源变成可由新 CR 接管的资源。
依赖永久丢失时列出每个可能残留的数据库、角色、凭据与投射。只有管理员接受残留与后续处置
责任后才人工移除确切对象的 finalizer。该操作不会完成清理,也不会授权新申请使用残留资源。
## 备份与恢复
- PostgreSQL VM/磁盘备份必须与数据库一致性策略配套;仅复制在线磁盘不自动等于有效
PostgreSQL 备份。
- PostgreSQL 备份必须包含管理 database 中的 controller registry。
- OpenBao 使用独立的受支持备份/快照流程,且恢复点应与 PostgreSQL 尽量接近。
- Kubernetes 侧备份 CR、controller 配置、ClusterSecretStore 和公开 CA bundle,不备份
明文 Secret 作为凭据事实来源。
- 定期在隔离环境执行恢复演练,验证 registry、KV metadata、应用登录及 Retain/Delete。
分别备份 PostgreSQL 数据、Kubernetes 资源与绑定记录、OpenBao 数据及必要配置。
不再要求备份专用 registry。只复制在线磁盘不等于有效数据库备份;秘密备份必须加密并限制访问。
恢复后先停止 controller,核对 PostgreSQL/OpenBao 时间点与 UID 映射,再启动单副本
controller 观察;出现一侧存在、一侧缺失时不得手工生成新密码或改 registry,应先按
Conflict 处理并决定恢复哪一侧。
灾难恢复先暂停 controller,核对三者恢复点、UID、外部目标与凭据的一致性,再恢复协调。
不一致时按 Conflict 人工处理,不承诺仅凭外部同名数据库重建丢失绑定。
在隔离环境演练登录、导入、Retain、重新绑定与 Delete 后才能标记验证通过。
## 升级与紧急停止
## 紧急停止
有疑似越权删除或凭据泄漏时,先把 controller Deployment scale 到 0,保留 CR、registry
和日志证据,再撤销 OpenBao token/role 并限制 PostgreSQL 管理 role。恢复前在隔离环境
复现并确认不会扩大破坏。一般依赖故障无需 scale down,最终一致性会自动重试。
疑似越权删除或秘密泄漏时暂停 controller,保留 CR 与脱敏证据,限制相关管理身份权限,
在隔离环境复现并确认修复后恢复。一般依赖失败不需要停机。
+20 -13
View File
@@ -3,13 +3,13 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | Review |
| 最后更新 | 2026-09-10 |
| 最后更新 | 2026-09-25 |
## 保护目标
- 应用密码只存在于 OpenBao、ESO 投射的目标 Secret 和需要使用它的进程内存中。
- controller 只能修改其 registry 能证明归属当前 Tenant UID 的资源。
- namespace 租户不能越权管理 Instance、其他 namespace 或 controller 配置。
- controller 只能在 Database 已确认的管理范围和当前绑定/操作授权内修改资源;未知同名对象报冲突。
- namespace 租户不能越权管理 Instance、Database 导入/回收、其他 namespace 或 controller 配置。
- PostgreSQL 和 OpenBao 的网络身份使用受信 CA 验证,不因 DNS 不可用而降级 TLS。
## 信任边界
@@ -18,15 +18,15 @@ Kubernetes 管理员、OpenBao 管理员和 PostgreSQL 管理员是平台信任
目标 Secret 或对应 OpenBao path 的主体等同于持有数据库账号。database owner 可以
改变自己 database 内的对象,因此 COMMENT 不能作为 controller 所有权依据。
VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 OpenBao 中的密码;完整
灾难恢复必须同时保护 PostgreSQL 与 OpenBao,并控制两份备份的访问权限。
数据库备份包含业务数据;Kubernetes 保存资源与绑定记录;OpenBao 保存应用凭据。
完整灾难恢复必须分别保护三者并核对恢复点,不依靠数据库内 registry 重建绑定。
## 凭据处理
- controller 使用 Kubernetes auth 获取短期 OpenBao token,不配置长期静态 token。
- 管理凭据只从 Instance 引用的 controller namespace Secret 读取,不复制到
CR/status/Event/metric/trace;管理员维护 ExternalSecret,由 ESO 同步该 Secret。
- 租户密码使用密码学安全随机源生成一次;中断恢复必须复用 OpenBao 现值。
- 动态供应密码使用密码学安全随机源;已有可靠关联时复用 OpenBao 现值,结果不确定时停止并报冲突。
- controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。
- 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、
DSN、Authorization header、密码或完整 OpenBao URL path 作为 metric label。
@@ -47,17 +47,22 @@ ESO 身份只读管理路径,租户 ESO 身份只读 tenant base path,二者
使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance
不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。
PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 函数承载创建或
删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向
controller role 授予 EXECUTE。controller 不调用 shell 或 `psql` 拼接用户输入。
2026-09-25 维护者确认第一版使用原生非 superuser 管理 role,具有 CREATEDB/CREATEROLE,
不引入 SECURITY DEFINER 接口。Instance 检查拒绝 superuser;具体已有资源的 owner 和
membership 仍需逐资源验证,不能把基础能力用于接管他人资源。扩展按实际权限安装,
不因可用列表包含某个扩展就默认能安装它。controller 不调用 shell 或 `psql` 拼接用户输入。
当前检查与真实权限矩阵见 [Instance 原生管理观测](README.md#instance-原生管理观测)。
Kubernetes RBAC 应把 cluster-scoped Instance 管理限制给平台管理员。Tenant editor
Kubernetes RBAC 应把 Instance 管理、Database 导入、Released 重新开放和回收限制给平台管理员。
有权创建 Tenant 的申请者可显式申请未绑定且可用的 Database,不增加资源侧允许绑定名单
或逐 Tenant 审批。Released 必须先由管理员处理旧访问并重新开放。Tenant editor
不自动获得 Secret read;是否读取目标 Secret 由 namespace 内独立 RBAC 决定。
## 删除保护
Delete 是明确的数据销毁授权,但仍必须在每一步校验 Instance UID、Tenant UID、名称和
OpenBao metadata。禁止对未知对象使用 `CASCADE`。删除 finalizer 卡住时只能按
资源侧 Delete 是明确的数据销毁授权,但仍必须在每一步校验 Instance/Database UID、
绑定、实际目标及角色/凭据管理范围;CR 中记录了意图不等于外部对象由本系统创建。
禁止对未知对象使用 `CASCADE`。删除 finalizer 卡住时只能按
[`operations.md`](operations.md) 核实外部状态后人工移除;该操作可能遗留资源。
## 发布前安全验收
@@ -68,4 +73,6 @@ OpenBao metadata。禁止对未知对象使用 `CASCADE`。删除 finalizer 卡
- 应用 login 不能创建 role/database,也不能连接其他租户 database。
- 日志、Event、Condition、metrics、CR 导出和测试 artifact 不含 canary password/token。
- 伪造 COMMENT、同名 database/role 或错误 UID metadata 均不能绕过 Conflict。
- Delete 只销毁 registry 可证明归属当前 Tenant 的资源。
- Delete 只销毁 Database 已确认管理范围内且获得删除授权的资源。
- 导入检查不改密码/owner,Released 不自动授权新使用者;旧访问处理后才能重新交付。
- 导入默认 Retain;角色/凭据的管理与删除范围未明确时不得扩大操作范围。
+173 -499
View File
@@ -1,558 +1,232 @@
# PostgreSQL Tenant Operator 系统规格说明书
# Ayatori Database 系统规格
| 项目 | 内容 |
| --- | --- |
| 状态 | Approved |
| 状态 | 资源模型与生命周期已批准;字段协议待 API 评审 |
| 目标 API | `database.ayatori.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-13 |
| 批准日期 | 2026-09-10 |
| 规范范围 | 首次注册外部 PostgreSQL 实例并创建一个应用租户 |
| 最后更新 | 2026-09-25 |
| 决策 | [ADR-0009](../decisions/0009-database-resource-and-claim.md) |
本文档定义系统对用户和外部依赖呈现的行为,是 API、测试和实现共同遵守的合同。
实现若需要改变本文合同,必须先修改规格并重新获得批准。
本文是当前行为合同,替代旧的 Tenant 同时承担申请与资源生命周期、PostgreSQL registry
持久所有权、任意 status 丢失自动恢复的设计。批准设计不表示实现已完成。
未决字段不能由实现自行补成新产品约定。
文中的“必须”“禁止”“应当”“可以”分别对应强制要求、强制限制、推荐行为和可选
行为。
## 1. 范围
## 1. 背景
在已存在的 PostgreSQL 实例上供应独立数据库、一个兼任 owner 的 login role、申请的扩展及
应用凭据;支持管理员显式导入已有数据库。Kubernetes API 管理声明与绑定,OpenBao 保存
应用凭据,ESO 向应用 namespace 投射 Secret。
homelab 中的大部分应用共享一个运行在独立 VM 上的 PostgreSQL DBMS。应用需要各自
独立的 database、作为 owner 的 login role 和密码,但不需要独立 PostgreSQL 实例。
目前这些资源依靠人工 SQL 和人工 Secret 管理,难以重复、审计和检测漂移。
不运行 PostgreSQL、VM、存储、备份或 OpenBao;不提供跨实例数据迁移、自动密码轮换、
多角色权限产品或跨系统事务。备份与数据恢复仍由管理员负责。
本系统使用 Kubernetes CRD 作为声明式 API,持续协调外部 PostgreSQL 与 OpenBao:
## 2. 资源与职责
```text
PostgreSQLInstance / PostgreSQLTenant
|
v
Ayatori Database controller
| |
v v
PostgreSQL catalog OpenBao KV v2
Instance
└─ Database × N 独立持久资源
└─ Tenant × 0..1 排他绑定的用户申请
```
## 2. 目标
v1alpha1 必须实现以下目标:
1. 注册一个已经存在的外部 PostgreSQL 实例并报告连接状态。
2. 为一个应用租户创建独立 database 和一个同时作为 database owner 的 login role。
3. 根据实例实际可安装扩展列表检查并安装租户申请的 PostgreSQL extension。
4. 首次生成高强度长期密码,并只把凭据明文写入 OpenBao KV v2。
5. 为 Kubernetes 应用创建 ExternalSecret,由 ESO 将凭据投射到同 namespace Secret。
6. 同时输出 OpenBao API URL,使 Kubernetes 外的应用可以直接读取凭据。
7. 同时输出 PostgreSQL DNS hostname 和 IP address,不假定所有消费者都能使用集群内
DNS。
8. 持续检测并修正由本系统管理的非破坏性漂移。
9. 通过 Kubernetes Condition 报告进度、成功和可操作的失败原因。
10. 重复 reconcile、controller 重启及外部依赖暂时失败不得重复创建或破坏资源。
11. 删除 Tenant CR 时默认保留外部资源;显式选择 `Delete` 时提供完整清理路径。
## 3. 非目标
v1alpha1 不负责:
- 创建、升级、备份或高可用运行 PostgreSQL DBMS/VM;
- 创建或运维 OpenBao;
- 直接写入包含凭据明文的 Kubernetes Secret;Secret 必须由 ESO 投射;
- 动态凭据、定时或自动密码轮换;
- Web UI、独立 REST API 或 Backstage 插件;
- 跨实例迁移 database;
- schema/table 级别租户、多 login role 或跨租户 grant;
- 删除不属于本系统管理的 database、role、extension 或 OpenBao Secret;
- 接管不是由本系统创建的外部资源;
- 提供生产环境 SLA。
## 4. 参与者与事实来源
| 对象 | 事实来源 | 说明 |
| 资源 | 职责 | 不承担的职责 |
| --- | --- | --- |
| 期望状态 | Kubernetes CR `spec` | 用户声明的合同 |
| 最近观察结果与当前阶段 | Kubernetes CR `status` | 可以丢失并重建,不是外部事实来源 |
| database/role/grant/extension | PostgreSQL catalog | 每轮 reconcile 必须重新读取 |
| 受管资源所有权与保留标记 | PostgreSQL controller registry | 与受管 DBMS 一起备份和恢复 |
| controller 工作流阶段 | Kubernetes CR `status.phase` | 状态机 checkpoint;可由外部事实保守重建 |
| 应用凭据 | OpenBao KV v2 | Kubernetes API 中不得出现明文 |
| Kubernetes 凭据投射 | External Secrets Operator | ExternalSecret 由本 controller 管理 |
| PostgreSQL 管理凭据 | controller namespace 的 Kubernetes Secret | 管理员维护 ExternalSecret,由 ESO 同步;Instance 只引用 Secret |
| Instance | 登记实例、管理连接、能力与供应前置条件 | 持有租户集合、保存所有权表 |
| Database | 描述外部数据库、管理范围、绑定与回收策略 | 充当第二套 registry 或通用资源框架 |
| Tenant | 声明需求或显式选择资源,申请使用并交付凭据 | 删除时隐式销毁独立资源记录 |
平台管理员管理 `PostgreSQLInstance`、controller 部署配置、OpenBao policy 和
PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管理
`PostgreSQLTenant`。
Instance 与 Database 为 cluster-scoped,Tenant 为 namespaced。Database 的工作名称是
PostgreSQLDatabase;字段拼写与导入授权细节待 API 评审。
Database 由平台管理员管理,不属于应用 namespace,不引入资源专用 namespace。
Tenant 按名称引用 Database;Database 绑定记录包含 Tenant 的 namespace/name/UID。
普通申请者通过 Tenant 申请使用,不能自行修改 Database 回收策略或将 Released 资源重新开放。
## 5. 资源模型
2026-09-25 确认:第一版以一个 database、一个兼任 owner 的 login role 及其应用凭据
作为 Database 的生命周期边界,Tenant 负责申请与交付,不单独拥有账号或凭据生命周期。
不预留多账号字段,不新增独立 Role、Credential 或 Claim CRD。一库多账号若出现实际需求,
通过后续 API 版本演进处理,不纳入 v1alpha1。此边界不扩大导入资源的管理授权。
### 5.1 PostgreSQLInstance
Database 自身必须声明 `instanceRef`,手工登记时同时指定实际数据库名;无需先存在 Tenant,
即可通过 Instance 验证目标。动态申请由 Tenant 选择 Instance,供应时把该引用写入 Database;
选择已有 Database 的 Tenant 从资源获取 Instance,不重复指定另一份来源。资源与实例的归属
独立于使用绑定,Tenant 删除后仍保留;修改引用不是数据库迁移。
`PostgreSQLInstance` 是 cluster-scoped 资源,表示一个已经存在、可由 controller
管理的 PostgreSQL server。
参考 [Kubernetes PV/PVC](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) 的
资源/申请分离与绑定生命周期,不复制存储调度和 CSI 协议。资源与申请的关系不是 GC 所有关系。
它必须声明:
## 3. 身份与事实来源
- PostgreSQL host、port 和管理连接使用的 database;
- PostgreSQL host address,供无法解析 DNS 的消费者使用;
- TLS mode;
- controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名。
- Kubernetes spec 保存声明;受保护的资源绑定记录与 status 保存身份关联、操作进度和观察。
它们通过 API 持久化,不因 controller 重启而消失。
- PostgreSQL catalog 是 database、role、grant、extension 实际状态的来源。
- OpenBao 是应用凭据的事实来源;ESO 状态和目标 Secret 存在性说明投射结果。
- Instance、Database、Tenant 以 UID 区分对象身份;namespace/name 用于定位,
同名新 UID 不继承旧绑定。数据库 OID 仅供诊断,不是永久身份或删除授权。
- 不新增 PostgreSQL 所有权表、安装身份表或 Retain 墓碑;COMMENT 也不能授权认领。
- 记录操作意图不等于外部操作成功,phase 不等于外部所有权;执行前后仍须观察实际状态。
可安装的 extension 集合由应用层从目标 PostgreSQL 查询,不由管理员在 Instance
中声明。v1alpha1 不实现 allowlist;该概念保留为后续可选策略。实际可用不代表安装
权限及前置条件已满足,安装仍需执行并回读;查询失败不得被解释为扩展不支持。
## 4. Instance 合同
实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全
建立管理连接、读取 server metadata、访问 controller registry 并使用所需管理能力。
Instance 声明 host、hostaddr、port(默认 5432)、管理 database(默认 postgres)、
TLS mode(默认 verify-full)及 controller namespace 的管理 Secret 名称和字段映射。
禁止隐式 TLS 降级。凭据引用不接受自选 namespace 或 OpenBao path。
Instance 身份和 endpoint 以管理员声明为准。修改 endpoint 不验证是否仍是原物理
服务器或原 registry,不增加服务器/安装身份绑定检查;但旧配置观察失效,必须按
新配置重新检查连接和管理能力。新 CR 按新 Instance 处理,不自动接管旧 UID 的租户
资源。controller 不迁移旧服务器上的数据,也不清理旧目标,影响由管理员负责评估。
管理员维护 ExternalSecret,由 ESO 同步管理 Secret;controller 只读,不修改管理密码,
不直接从 Bao 取管理凭据。有效用户名或密码变化时释放旧连接并重验;仅 metadata 或无关
字段变化不重建。中途凭据变化必须丢弃旧观察。已有有效管理凭据时,Bao/ESO 故障本身
不撤销 Instance Ready;首次缺少有效 Secret 时不能 Ready。
Instance 开始受管时即添加 finalizer,成功保存后才参与供应,不等发现 Tenant 后
再补加。删除期间停止新供应;仍有引用它的 Tenant(包括正在删除的 Tenant)时保留
finalizer,无引用后才移除。不级联删除 Tenant 或任何外部数据库、角色、凭据。
引用检查失败不得当作无引用。首版不引入跨对象锁或准入控制:finalizer 不禁止同时
创建 Tenant CR,新 Tenant 遇到正在删除或已不存在的 Instance 时不得开始供应。
这不保证列表检查、CR 创建和在途外部操作之间的原子性;不是严格的跨对象事务。
Ready 要求当前目标的连接、服务器 metadata 和所需管理能力检查通过,不要求创建、
迁移或读取 registry,也不证明备份或高可用。阶段简化为 Pending → Validating → Ready,
删除进入 Deleting。实际扩展可用列表不等于安装权限;查询失败不等于不支持。
### 5.2 PostgreSQLTenant
endpoint 变更由管理员负责评估,不验证物理服务器连续性,不迁移或清理旧目标;
旧观察失效。新 UID 不接管旧资源。Instance 开始受管前保存 finalizer;删除时停止新供应,
只要有引用它的 Database(包括 Released/删除中)或尚未绑定的 Tenant 就等待。
查询失败不视为无引用;无引用才解除 finalizer,不级联删除任何业务资源。
引用检查不是跨对象事务,正在删除或不存在的 Instance 不允许开始新的供应/绑定。
`PostgreSQLTenant` 是 namespaced 资源。v1alpha1 中,一个 Tenant 精确对应:
## 5. 动态供应与排他绑定
- 一个 `PostgreSQLInstance`;
- 一个 database;
- 一个同时作为 database owner、供应用使用的 `LOGIN` role;
- 零个或多个 extension;
- 一个 OpenBao KV v2 凭据位置;
- 一个同 namespace ExternalSecret 及其目标 Kubernetes Secret。
1. 校验 Tenant 请求、Instance 能力、名称和扩展要求。
绑定 controller 在触及资源侧绑定前将 Tenant 进度记为 Binding,固定申请目标,
避免两次绑定写入之间修改引用占用第二个资源;该进度不是已绑定的声明。
2. 在首次外部写入前持久化独立 Database 记录、确定目标与管理范围。
动态创建的 Database 名称由 Tenant UID 确定;重试复用同一记录,不重复创建。
3. 先在 Database 写入 Tenant namespace/name/UID,再在 Tenant status 写入 Database
name/UID;双向记录一致后才允许供应或交付。
4. 按已确认步骤建立凭据、role、database、授权和扩展,逐步回读。
5. 验证应用登录与 ESO 投射后,Tenant 才可 Ready。
Tenant 的 namespace 用于 Kubernetes RBAC 和身份识别,不代表 PostgreSQL schema。
同一 Instance 中的 database 和 role 名称全局唯一。
绑定前固定有效目标;绑定或开始外部供应后不得通过修改名称或引用实施隐式迁移。
每个 Database 最多一个使用者,每个 Tenant 最多一个 Database。
绑定 API 写入采用 resourceVersion 并发控制;双向记录不原子,单边完成不得授予使用权限。
采用 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 测试验证。
## 6. 标识与默认值
不同 Database 记录请求同一外部名称仍可能竞争,不能仅靠 Kubernetes 中的列表检查保证
PostgreSQL 名称唯一。后端创建时的重名失败报告 Conflict,失败方不得接管胜方资源。
不为此新增跨系统锁或 registry。管理员也不得把同一物理数据库登记成多个可绑定资源。
以下是 v1alpha1 的标识合同:
## 6. 显式导入
| 字段 | 默认值 | 约束 |
| --- | --- | --- |
| Instance port | `5432` | 1–65535 |
| Instance host address | 无 | 必须是合法 IPv4 或 IPv6 address |
| 管理 database | `postgres` | 合法 PostgreSQL identifier |
| TLS mode | `verify-full` | 禁止隐式降级 |
| Tenant database | `metadata.name` | 同一 Instance 全局唯一 |
| login role | `metadata.name` | 同一 Instance 全局唯一 |
| deletion policy | `Retain` | `Retain` 或 `Delete` |
管理员创建资源声明,明确 Instance、已有数据库和允许管理的范围,构成导入授权。
初始检查只读验证存在性、owner、角色权限和扩展等是否匹配;不匹配报告清楚的差异,
不得通过重置密码、改变 owner 或撤销现有访问来“完成导入”。
Tenant 的 `spec.instanceRef` 与 `metadata.name` 长度合计不得超过 241 个字符,确保
派生的 ExternalSecret/Secret 默认名称
`<instanceRef>-<metadata.name>-postgresql` 不超过 Kubernetes 253 字符限制。
未显式导入的同名数据库一律 Conflict。导入资源默认 Retain,不隐含 Delete 授权。
有权创建 Tenant 的申请者可以显式引用已登记、未绑定且可用的 Database;不增加资源侧
允许绑定名单或逐 Tenant 的管理员审批。绑定仍检查目标、可用状态与排他关系。
Released 不在可申请范围,必须由管理员处理旧访问并重新开放。导入时显式关联已有凭据,
不通过隐式改密生成替代凭据;具体关联字段在 API 中定义。
固定默认值由 CRD defaulting 写入。依赖 `metadata.name` 或 `instanceRef` 的 database、
login role、ExternalSecret/Secret 名称属于 controller 语义默认值:省略字段不会被 admission
回写,controller 必须始终计算同一个 effective value,并通过 status 的 database、
loginRole、credential reference 以及实际资源展示。
v1alpha1 不为此引入 mutating webhook。
## 7. Retain、重新绑定与 Delete
database 和 role 名称必须作为 PostgreSQL identifier 参数安全引用,禁止通过字符串
拼接执行。名称校验必须拒绝空字符串、NUL 和超过 PostgreSQL identifier 长度限制的
值,并统一限制为小写字母、数字和下划线。
回收策略属于 Database,默认 Retain;Tenant 删除是释放申请,不是独立资源的 GC 授权。
有资源管理权限的主体可在进入删除流程前修改 Retain/Delete;进入删除流程后策略固定。
显式设置 Delete 就是删除授权,不增加第二次审批或确认字段。
Database 不得设置会让它随 Tenant 消失的 ownerReference。
Tenant 首次成功后,`instanceRef`、database、login role 和凭据位置必须
不可变。修改这些字段不是 rename 或 migration,API 必须拒绝或报告明确的
`ImmutableField`。
### Retain
## 7. PostgreSQL 权限合同
- 保留 Database 对象、外部数据以及与资源关联的角色和凭据,不自动删除或重置。
- Tenant 释放后 Database 进入 Released,保留旧绑定身份用于诊断和防止自动复用。
Released 不是 Available,不再向原申请交付新状态,也不自动分配给同名新 Tenant。
- 保留策略不要求 PostgreSQL/Bao 在线才能完成申请释放,但必须先将释放关系安全记录到
Kubernetes;API 写入失败时不能宣称释放完成。仍有在途操作时不得跳过必要协调。
- 管理员检查数据、旧账号访问与凭据后,显式授权重新绑定。保留数据的复用可以不清空数据,
但必须由管理员确认新使用者应获得这些数据及旧使用者的权限处置。
- 删除旧投射 Secret 或解除绑定不等于撤销 PostgreSQL 访问;Retain 不承诺自动撤权。
ExternalSecret/Secret 的保留与清理细节需随凭据交付协议明确。
建议的 v1alpha1 权限模型如下:
### Delete
1. database 必须由 login role 拥有。
2. login role 必须是 `LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`。
3. 必须撤销 `PUBLIC` 对租户 database 的连接权限,再显式允许 login role 连接。
4. controller 不得修改其他 database 或无关 role 的权限。
5. controller 只保证请求的 extension 存在;移除 extension 不得自动执行
`DROP EXTENSION`。
必须由有权限的主体在资源侧明确授权,并核对 Database 身份、绑定、实际对象和管理范围。
在相关 finalizer 保护下清理投射、阻止新登录、处理已有连接、删除 database,再按已确认的
独占管理范围清理 role 与凭据;共享或未纳管的对象不得删除,禁止扩大 CASCADE 范围。
这意味着应用可以在自己的 database 内执行 schema migration,但不能创建其他
database、role 或访问其他租户。v1alpha1 不创建只有形式意义、却未隔离运行时权限的
额外 `NOLOGIN` owner。若以后应用能分别使用 migration 和 runtime 凭据,再通过新的
权限 profile 引入 owner/migrator/runtime 角色模型。
每步回读,失败保持进度与 finalizer;确认已删除的对象可幂等跳过,未知同名对象不能继续删除。
数据库被使用时,直接删除 Database 不得绕过绑定保护。资源已释放后才按策略处理。
具体 Tenant/Database finalizer 配合与投射清理顺序须经 API 设计及故障注入验收。
## 8. OpenBao 凭据合同
## 8. 幂等、失败与人工处理
### 8.1 Controller 自身认证
普通依赖故障退避重试。已持久确认且仍与观察一致的步骤可以幂等继续;controller 重启
不重新生成密码,不重复创建已确认资源。
controller 必须使用 Kubernetes auth 登录 OpenBao。controller 使用的 OpenBao API
address、提供给消费者的 OpenBao API address、auth mount、auth role 和 KV v2 mount
属于部署配置,不属于任何 CR。两个 API address 可以相同;若 controller 使用集群内
地址而外部消费者不能解析,则必须单独配置 consumer address。生产部署的 KV mount
默认为 `kv`;开发环境可以配置为 OpenBao dev server 默认的 `secret`。长期 OpenBao
Token 禁止写入 Deployment、CR 或镜像。
外部创建成功但记录尚未保存,或超时导致结果不确定时,若不能可靠确认归属,报告
Conflict 并停止相关写入;不得仅凭名称相同、曾记录意图或字段相似自动认领。
失败恢复不承诺全部自动完成,也不实现队列模拟事务。
### 8.2 管理凭据
status 缺失不假定发生于正常重启。Instance 可重新探测能力;Database/Tenant 缺少绑定或
操作确认时不能从外部同名对象推导所有权。按冲突/灾难恢复处理,不自动重建所有权表。
`PostgreSQLInstance` 只引用 controller 自身 namespace 中 Kubernetes Secret 的名称及
用户名、密码字段名,不允许指定 namespace 或 Bao path。endpoint 仍由 Instance 声明。
平台管理员维护 ExternalSecret,将 OpenBao 管理凭据同步到该 Secret;controller 只读
Secret,不创建或修改管理 Secret、其 ExternalSecret 或上游管理凭据。
人工处理必须能看到:请求与资源身份、目标 Instance/database/role、失败步骤、已确认完成
与结果不确定的操作、冲突原因、下一步核实建议。保留现场,不自动删除疑似残留或改密。
管理员核实后可清理确定的残留再重试,或走显式导入;不能通过伪造 Ready/清空 status 强行继续。
Instance 管理连接不直接访问 Bao,不负责管理密码轮换。已装配的凭据仍可访问
PostgreSQL 且满足 registry/权限要求时,Bao 或 ESO 暂时不可用不使 Instance NotReady。
首次装配无法取得有效 Secret 时不能 Ready。检测到所引用 Secret 的有效用户名或密码
变化时,controller 使用新值重建管理连接池并重新检查管理能力;仅 metadata 或无关
字段变化不触发重建。刷新不依赖 Instance generation 变化,新连接验证失败按实际
故障报告,不能用旧连接的成功结果证明新凭据可用。
controller 不修改 PostgreSQL 密码、不回写 Secret,也不修改 Bao 管理凭据;数据库侧
凭据变更由管理员负责。这是跟随已提供凭据的连接刷新,不是自动密码轮换。
Tenant 凭据管理仍直接依赖 Bao。
## 9. 权限、凭据与扩展
### 8.3 租户凭据
2026-09-25 确认第一版管理账号使用原生非 superuser + CREATEDB/CREATEROLE 方案,
不引入 SECURITY DEFINER 接口;权限检查与限制见 [安全合同](security.md#最小权限)。
Tenant 不声明凭据 path。controller 根据部署级 KV mount、base path 和 Tenant 的
namespace/name 推导唯一的 mount-relative path。base path 来自 controller 启动参数
`--openbao-tenant-base-path`,默认 `postgresql-tenants`。最终路径为
`<base-path>/<namespace>/<metadata.name>`。推导结果禁止以 `/` 开头,禁止包含空路径段、
`.`、`..`,也禁止把 KV v2 HTTP API 的 `data` 或 `metadata` 层编码进路径。
动态供应继续使用一个兼任 database owner 的 LOGIN role;应用角色不得具备 superuser、
CREATEDB、CREATEROLE 或 replication 权限。撤销 PUBLIC CONNECT,再授予目标角色;
不修改无关数据库和角色。identifier 匹配 `^[a-z][a-z0-9_]{0,62}$`,SQL 安全引用。
新 Tenant 的凭据建立顺序必须可从任意中断点恢复:
请求扩展按实例实际可用集合判断,成功后只追加,不自动 DROP EXTENSION。
可用列表查询失败按依赖错误处理;实际安装仍检查权限与结果。
1. 验证 Instance、名称、extension 和目标 OpenBao 路径;
2. 确认目标 database、role 和 OpenBao 记录不存在,或能够验证为同一 Tenant
已创建的部分状态;
3. 生成密码;
4. 先创建带 controller 所有权 metadata 的 OpenBao KV v2 记录;
5. 从 OpenBao 重新读取凭据;
6. 使用该凭据创建作为 database owner 的 login role 和其他 PostgreSQL 资源;
7. 用 login role 实际连接目标 database;
8. 全部验证成功后将 Tenant 标记 Ready。
应用密码使用安全随机源,只写 OpenBao;已有可信凭据可复用,不因失败生成第二份密码。
新建时先安全保存并回读凭据,再创建角色;凭据写入本身结果不确定也适用人工冲突规则。
Kubernetes 应用由 ESO 投射同 namespace Secret,controller 不直接写明文 Secret。
若第 4 步成功、后续 PostgreSQL 操作失败,下一轮必须读取同一份 OpenBao 凭据继续,
不得生成第二个密码。若 PostgreSQL 先存在而 OpenBao 记录不存在,controller 必须报告
Conflict,不得擅自重置已有 role 密码。
凭据仍输出 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、改部署参数或重新绑定就隐式搬迁凭据或改密。
controller 必须在 PostgreSQL 管理 database 的专用 registry schema 中持久保存可验证的
Instance UID、Tenant UID 与 namespace/name 关联,不能只依赖会丢失的 CR status 判断
资源所有权。registry 必须可回读且不得改变数据库授权语义;database 或 role COMMENT
不能作为权威所有权记录。
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
见 [安全模型](security.md)。这些安全约束继续适用。
默认写入字段固定为:
## 10. Conditions 与可观测性
```text
username
password
database
host
hostaddr
port
sslmode
```
三类资源均提供唯一的 Ready Condition 及 observedGeneration,phase 只辅助表示阶段。
Ready=True 必须有当前目标的实际验证;Database 已绑定不等于 Tenant 凭据交付已经完成。
这些字段是 controller 的规范化输出合同。controller 不生成包含密码的 URI、JDBC URL
或应用专用键名。应用通过 ExternalSecret template、Helm values 或自身配置把原子字段
映射为 `DATABASE_URL`、独立环境变量或配置文件;因此 URI escaping 和应用特有格式也
由消费方负责。`host` 是 DNS 名称,`hostaddr` 是可直接连接的 IP;消费者自行选择其
支持且可达的连接目标。PostgreSQL server 证书必须同时包含与 `host` 匹配的 DNS SAN
和与 `hostaddr` 匹配的 IP SAN,使两种目标都能在 `verify-full` 下独立完成身份验证。
至少区分 Reconciling、InvalidSpec、ImmutableField、DependencyUnavailable、
AuthenticationFailed、InsufficientPrivileges、InstanceNotReady、Conflict、
ProvisioningFailed、CredentialProjectionFailed。Released 应明确显示未可供重新绑定,
具体 Condition Reason 由 API 细化,不假定仅靠 phase 判断授权。
### 8.4 凭据输出与 ExternalSecret
使用结构化日志、Events 和低基数失败分类 metrics;Condition 是面向使用者的主要诊断入口。
禁止在任何 CR、Event、日志、metric、trace 或测试输出中出现密码、Token、完整秘密响应。
controller 必须根据部署级 base path 推导 Tenant 的 KV path,Tenant 不能选择 mount 或
任意远端路径。ExternalSecret 固定命名为
`<instanceRef>-<metadata.name>-postgresql`。Tenant 可以通过
`spec.credential.secretName` 指定目标 Kubernetes Secret 名称;省略时使用同一默认名。
自定义名称只需是合法 Kubernetes Secret 名称,不限制命名内容;两者均与 Tenant 位于
同一 namespace。
## 11. 验收
controller 必须创建同 namespace ExternalSecret,从固定的 ClusterSecretStore 读取七个
原子字段。ExternalSecret 及目标 Secret 的名称通过 Tenant status 暴露。controller
不得直接读取 OpenBao 密码后写入 Kubernetes Secret。
Tenant status 还必须提供完整、可由外部消费者使用的 OpenBao KV v2 API URL。URL 可以
包含 consumer API address、mount 和 secret path,但不得包含 Token、密码或其他认证
信息。默认 `kubectl get` 表格显示目标 Secret 名称;完整 OpenBao URL 通过
`kubectl get postgresqltenant <name> -o yaml` 获取,避免表格列过长。
OpenBao metadata 必须能够标识 Tenant UID、namespace/name 和 Instance,使 controller
区分自己的残留记录与外部记录。任何凭据值都不得进入日志、Event、Condition、metric
label、trace、CR spec/status 或测试快照。
## 9. Reconcile 行为
系统采用最终一致性模型。Kubernetes、PostgreSQL、OpenBao 和 ESO 可以短暂处于不同
阶段;controller 不尝试实现跨系统事务,而是以 Kubernetes CR `status.phase` 作为
工作流 checkpoint,通过幂等外部操作和每轮回读验证最终收敛。
两个 CR 的状态机权威记录都在 `status.phase`。controller 根据 phase 选择下一项候选
动作,但 phase 不能替代外部状态检查:执行前后仍须回读 PostgreSQL catalog、registry、
OpenBao 和 Kubernetes/ESO。外部写入成功但 status 更新失败时,下一轮必须识别已完成
事实并推进 phase,不得重复生成密码或报告虚假冲突。
status 丢失时,controller 必须从 registry 的所有权记录和各外部系统实际状态保守重建
phase。若 status 被伪造或领先于实际状态,controller 必须纠正到安全阶段并补齐资源,
不能跳过验证。registry 不保存或驱动协调 phase。
Instance phase 按当前 generation 表示连接与初始化进度:
```text
Pending -> Validating -> InitializingRegistry -> Ready
(any phase) --------------------------------> Deleting
```
spec generation 改变后可以从 `Ready` 回到 `Validating`。Tenant phase 如下:
```text
Pending -> Planned -> CredentialCreated -> RoleCreated -> DatabaseCreated
-> ExternalSecretCreated -> CredentialProjected -> Ready -> Deleting
```
失败不增加 `Failed` phase;phase 保留在无法推进的步骤,由 `Ready=False` 的 Reason 和
message 表达 `Conflict`、认证失败或依赖不可用。Retain 删除完成后 CR 已不存在,因此
没有持久的 `Retained` phase。
每轮 Tenant reconcile 必须按以下逻辑执行:
```text
读取 Tenant
-> 读取 Instance
-> 校验不可变字段与输入
-> 检查 Instance Ready
-> 读取 OpenBao 与 PostgreSQL 实际状态
-> 检测冲突或部分完成状态
-> 执行非破坏性补齐
-> 使用应用凭据验证登录
-> 创建并验证 ExternalSecret/Secret 投射
-> 回读实际状态
-> 更新 status
```
要求:
- 所有步骤必须幂等;
- 每个外部写入前必须先在 CR status 持久化足够的操作意图,写入后必须回读并推进
`status.phase`;
- 暂时性网络、锁和依赖错误必须重试;
- 输入错误、资源冲突和禁止操作不得忙循环重试,只在 generation 或依赖状态变化后
重试;
- 未知外部资源不得被修改、接管或删除;
- 用户从 `spec.extensions` 移除 extension 时不得执行卸载,必须报告该字段在 v1alpha1
中只允许追加;
- controller 重启不得影响已经签发的应用密码;
- `status` 丢失后必须可以从 registry、PostgreSQL、OpenBao 和 Kubernetes/ESO 重建。
## 10. Condition 合同
两个资源都必须提供唯一的 `Ready` Condition。可以增加辅助 Condition,但调用方只需
依赖 `Ready`。
| 状态 | 含义 |
| 场景 | 必须验证的结果 |
| --- | --- |
| `Ready=Unknown` | 正在首次观察或 reconcile,尚无结论 |
| `Ready=False` | 当前 generation 未达到合同要求 |
| `Ready=True` | 当前 generation 已回读验证成功 |
| Instance 登记与重验 | 无 registry 依赖;真实凭据/TLS/管理权限检查 |
| 动态供应与重复 reconcile | 独立资源记录、排他绑定、密码不变、实际登录与投射成功 |
| 显式导入 | 无数据/密码/owner 隐式修改;错误目标、已占用或 Released 资源的申请被拒绝 |
| 同名未知资源 | Conflict,原数据库/角色/凭据不变 |
| 并发申请与单边绑定 | 最多一个使用者;失败方不能开始危险外部操作 |
| controller 重启 | 已确认步骤正常继续;不确定创建报告人工可诊断冲突 |
| Retain 与 namespace/Tenant 删除 | Database 不被 GC,外部数据保留,Released 不自动复用 |
| 人工重新绑定 | 旧 UID 不继承使用权;确认数据及凭据/旧访问处置后才能交付 |
| Delete 每步中断 | finalizer 保留,可重试,不误删未知/共享/未纳管对象 |
| 依赖稍后出现/权限恢复 | 安全重试,过期观察不授权写入 |
| status/备份恢复不一致 | 不凭同名推导归属,明确人工处理范围 |
| 泄漏与权限 | canary 不出现在输出;namespace 用户不能导入/回收他人资源 |
Condition 必须带正确的 `observedGeneration`。资源自身的
`status.observedGeneration` 只在当前 generation 完成一次有结论的 reconcile 后更新。
最低 Reason 集合:
| Reason | 适用资源 | 含义 |
| --- | --- | --- |
| `Reconciling` | 两者 | 尚在处理 |
| `Ready` | 两者 | 当前 generation 已验证 |
| `InvalidSpec` | 两者 | 输入不满足规格 |
| `DependencyUnavailable` | 两者 | PostgreSQL 或 OpenBao 暂时不可用 |
| `AuthenticationFailed` | Instance | 管理凭据或 TLS 验证失败 |
| `InsufficientPrivileges` | Instance | 管理 role 缺少必要权限 |
| `InstanceNotReady` | Tenant | 引用的 Instance 未 Ready |
| `Conflict` | Tenant | 目标名称或 OpenBao 路径已被其他主体占用 |
| `ProvisioningFailed` | Tenant | 可重试的创建/验证失败 |
| `CredentialProjectionFailed` | Tenant | ESO 或目标 Secret 未达到期望状态 |
Condition message 必须适合人类排障,但禁止包含连接串密码、Token 或完整 Secret 数据。
## 11. 删除与保留
### 11.1 Retain
`Retain` 是默认策略:
- 删除 Tenant CR 不得删除 database、role、extension 或 OpenBao 记录;
- controller 不得因外部依赖不可用而永久阻止 Retain CR 删除;
- 保留资源必须继续携带原 Tenant UID 和 namespace/name 的所有权记录,但在 CR 删除后
明确处于 unmanaged 状态;
- 重新创建同名 Tenant 会产生新的 UID,必须因已有资源不属于新 UID 而报告 Conflict;
- v1alpha1 不提供重新关联、import 或 adoption;恢复管理必须使用第 12 节的迁移流程,
或等待后续版本定义显式纳管协议。
### 11.2 Delete
用户在创建 Tenant 时显式设置 `deletionPolicy: Delete`,表示删除 CR 时授权永久清理
该 Tenant 的外部资源。controller 必须使用 finalizer,并按以下顺序处理:
1. 再次验证 database、role 和 OpenBao 记录都属于当前 Tenant UID;
2. 删除 ExternalSecret,并确认目标 Kubernetes Secret 已删除;
3. 禁止该 login role 建立新连接;
4. 终止该 database 的现有连接;
5. 删除 database,database 内 extension 随之删除;
6. 删除 login role;
7. 删除 OpenBao KV 记录及其可恢复版本;
8. 回读确认外部资源均不存在;
9. 删除 controller registry 记录;
10. 移除 finalizer,允许 Kubernetes 删除 CR。
任一步失败都必须保持 finalizer 并从安全检查开始重试。controller 禁止使用
`CASCADE` 删除无法证明属于该 Tenant 的依赖对象。若 Instance 或 OpenBao 永久丢失,
管理员可以在核实外部状态后手工移除 finalizer;该逃生操作必须在运维 runbook 中明确
标记为可能遗留资源。
v1alpha1 不自动检查备份,也不承诺恢复被 `Delete` 删除的数据。显式选择 Delete 的
用户承担数据销毁语义;默认 Retain 用于避免普通误删。
## 12. 现有环境迁移
v1alpha1 不接管现有 database 或 role,但必须提供可重复、可回滚的迁移 runbook。对每
个现有应用租户,推荐的停机迁移顺序是:
1. 盘点 database、role、owner、grant 和 extension,并完成可恢复备份;
2. 创建逻辑备份,必须使用可映射到新 owner 的格式,避免恢复旧 role ownership;
3. 停止应用写入并确认没有活动写事务;
4. 完成最终逻辑备份;
5. 将旧 database 和 role 重命名为带迁移时间戳的保留名称,释放最终名称;
6. 创建 `PostgreSQLTenant`,由 controller 创建最终 database、role 和 OpenBao 凭据;
7. 等待 Tenant Ready;
8. 以新 owner 恢复逻辑备份,并验证 row count、schema、extension 和应用权限;
9. 让 ESO 投射新凭据,重启或重新部署应用;
10. 验证应用读写后结束维护窗口;
11. 保留旧 database、role 和备份直到回滚窗口结束,再由管理员手工清理。
回滚时停止新应用写入、恢复原名称或连接配置,并重新使用旧凭据。迁移工具不得把旧
密码、管理凭据或 dump 文件提交到 Git。真实命令、锁定方式和各现有应用验证项见
[`migration.md`](migration.md),并必须在实现首个可用版本前通过临时 PostgreSQL 实例
演练。
## 13. 安全要求
1. 所有 PostgreSQL 与 OpenBao 网络访问必须支持超时和 context cancellation。
2. homelab 部署必须通过 Deployment 挂载的共享 CA bundle 验证 TLS server identity;
该 bundle 的信任根来自 OpenBao PKI,但不得包含 CA 私钥。Instance 默认使用
`verify-full`,其 host 必须与服务器证书名称匹配。开发环境可以显式使用 `disable`
明文连接。
3. PostgreSQL 管理 role 应使用满足本规格的最小权限,不应使用 PostgreSQL
superuser;若 extension 安装需要额外权限,必须单独记录例外。
4. controller 的 OpenBao policy 仅覆盖受管租户 KV 操作,不授予管理凭据路径权限。
管理凭据的 ESO 同步身份与应用凭据的 ESO 读取身份隔离。controller 只在自身
namespace 获得管理 Secret 读取权限,不因此扩大跨 namespace Secret data 访问范围。
5. namespace 用户不得修改 cluster-scoped Instance。
6. 所有 identifier、extension name 和引用字段必须在发起外部调用前校验。
7. controller 不得通过 shell 或 `psql` 子进程执行用户输入。
8. 错误包装、结构化日志和 tracing 必须经过 Secret 泄露测试。
详细威胁模型和部署 policy 见 [`security.md`](security.md)。
## 14. 可观测性要求
v1alpha1 至少必须提供:
- Kubernetes Events:开始 provisioning、成功及需要人工处理的失败;
- 结构化日志:resource namespace/name、Instance、generation、阶段和错误类别;
- controller-runtime 默认 reconcile metrics;
- 不包含 database、role、OpenBao path 等无界用户输入的低基数失败分类 metric。
日志和 metrics 的存在不能代替 Condition;Condition 是 API 使用者判断状态的主要方式。
## 15. 验收标准
实现 v1alpha1 第一条完整纵向切片前,测试必须覆盖:
1. 有效 Instance 可以建立 TLS 管理连接并变为 Ready。
2. PostgreSQL 管理能力不可用时 Instance Ready=False,恢复后自动变为 Ready;已有
管理凭据可正常使用时,Bao/ESO 故障不单独影响 Instance Ready。首次装配缺少有效
管理 Secret 时不能 Ready;Tenant 的 Bao 操作失败按其自身依赖故障报告。
3. 有效 Tenant 创建 database、作为 owner 的 login、grant、extension 和 OpenBao
记录。
4. 应用凭据可以实际连接且不能创建其他 database/role。
5. 相同 generation 重复 reconcile 不改变密码、不重复创建资源。
6. controller 在每个外部写入步骤后中断,重启后都能继续并得到相同最终状态。
7. 预先存在且不属于当前 Tenant UID 的 database、role 或 OpenBao path 导致
Conflict,且不修改已有资源。
8. 目标实例实际不支持的 extension 在供应外部写入前被拒绝;扩展列表查询失败时
按依赖故障处理,不报告为不支持。安装结果仍须回读验证。
9. status 被清空后可以从两个外部事实来源重建。
10. 删除 Retain Tenant 后外部资源仍存在且不再受管;重新创建同名 Tenant 报告
Conflict。
11. 日志、Event、Condition、metric 和 CR 中不存在生成的密码或管理凭据。
12. 两个 namespace 对同一 Instance 申请相同名称时,只有第一个成功,第二个报告
Conflict。
13. 删除 Delete Tenant 时,任一步骤失败都可重试,且最终删除 database、login role、
OpenBao KV 历史和 finalizer。
14. 使用迁移 runbook 可以把一个现有 database 转移到新建的受管 database,并在回滚
窗口内恢复旧服务。
15. Tenant 只有在 ExternalSecret Ready、目标 Secret 存在且应用凭据实际可登录后才
Ready。
16. Tenant status 同时提供 Kubernetes Secret reference 和不含认证信息的 OpenBao API
URL。
17. DNS 不可用时,使用输出的 `hostaddr` 可以连接 PostgreSQL;server 证书同时覆盖
`host` 的 DNS SAN 和 `hostaddr` 的 IP SAN,两种连接目标均可通过 `verify-full`。
18. 两个 CR 的 `status.phase` 都能反映当前协调步骤;清空 status 后可以从外部事实重建,
且伪造或过期 phase 不会使 controller 跳过验证或外部操作。
单元测试验证纯决策逻辑,adapter 集成测试使用 Docker PostgreSQL/OpenBao,controller
集成测试使用 envtest,完整网络路径使用 Kind E2E。
## 16. 已确认决策
- v1alpha1 使用一个同时作为 database owner 的 login role,不创建额外 NOLOGIN owner。
- v1alpha1 不接管任意现有资源,但必须提供并演练 dump/restore 迁移路径。
- v1alpha1 同时实现默认 `Retain` 和显式 `Delete`;Delete 必须有 finalizer、所有权验证
和完整清理路径。
- OpenBao KV v2 mount 和 base path 是 controller 部署配置,mount 默认 `kv`,base path
由 `--openbao-tenant-base-path` 配置并默认 `postgresql-tenants`;Tenant 不能选择 mount
或任意远端 path,controller 根据 namespace/name 推导记录路径。
- 租户 KV 记录固定写入 `username/password/database/host/hostaddr/port/sslmode` 七个
原子字段;
controller 不生成连接 URI,应用负责映射和拼装自身配置。
- PostgreSQL TLS 使用 controller Deployment 挂载的共享 CA bundle。OpenBao PKI 是
CA 权威并继续签发、续期 PostgreSQL server 证书;controller 只消费公开 trust
bundle,不接触 CA 私钥。bundle 可以由 ConfigMap 或现有证书同步机制投射,不允许
Tenant 或 Instance 选择其他 CA;开发环境可以显式使用 `sslMode: disable`。
- 每个 PostgreSQLInstance 在其管理 database 中维护 controller 专用 registry schema。
registry 是受管资源所有权、安装身份和 Retain 后 unmanaged 标记的权威记录;两个
CR 的 `status.phase` 是 controller 状态机的权威 checkpoint,Instance status 不聚合
Tenant 清单。
- PostgreSQL database 和 role identifier 必须匹配 `^[a-z][a-z0-9_]{0,62}$`,不支持
需要双引号的大小写或特殊字符名称。
- External Secrets Operator 是 v1alpha1 的运行依赖。controller 管理同 namespace
ExternalSecret,但不直接写明文 Secret;Tenant status 同时输出目标 Secret reference
和供非 Kubernetes 消费者使用的 OpenBao API URL。
- PostgreSQLInstance 同时声明 DNS `host` 和 IP `hostaddr`;PostgreSQL server 证书必须
同时包含对应 DNS SAN 和 IP SAN,消费者自行选择连接目标。
## 17. 批准状态
2026-09-14 确认 extension 判定修订:v1alpha1 使用实例实际可安装列表,不实现管理员
allowlist;后续可按需引入策略。现有 allowedExtensions 字段尚待 API 实现移除。
2026-09-13 已确认管理连接修订:Instance 引用 controller namespace 内的管理 Secret,
管理员维护 ExternalSecret,由 ESO 同步;controller 不再从 Bao 直接读取管理凭据。
此项是已批准行为,现有 API types 与实现尚待后续修改。
具体设计决策和本文整体已于 2026-09-10 获得批准,可以进入 API reference、测试和
实现阶段。同日确认状态机修订:两个 CR 的 `status.phase` 是 controller 工作流的权威
checkpoint;PostgreSQL registry 只承担所有权、安装身份和保留状态。
## 18. 与当前脚手架的已知差异
当前 API skeleton 至少需要以下调整:
- 删除 Tenant 自选 OpenBao path 的能力,改由部署级 mount、base path 和 Tenant
identity 推导,并修正当前包含 `kv/` 前缀的示例;
- 增加 controller 部署级 OpenBao KV mount 和 TLS 配置;
- 增加部署级 OpenBao consumer address、ClusterSecretStore 和 KV base path 配置;
- 删除独立 `ownerRole` 字段,使 login role 成为 database owner;
- 为 Instance 增加 `hostaddr`,为 Tenant 增加目标 Secret 配置及 Secret/Bao URL 输出
status;
- 按已确认的 identifier 合同收紧校验;
- 增加 PostgreSQL controller registry,记录基于 UID 的所有权、安装身份和保留状态;
- 修正凭据 type 中遗留的 rotation 注释;
- 使 Condition、不可变字段和 extension 追加语义具备 API 校验或明确的 reconcile
结果。
这些是规格批准后的实现工作,不属于本规格本身。
纯规则用单元测试;schema/CEL/status/watch/resourceVersion/绑定事件链用 envtest;
真实 PostgreSQL/OpenBao 验证后端行为;GC、ESO 与完整交付用具备相应控制器的测试集群。
envtest 不运行 GC/ESO,不能据此宣称这两类验收完成。详细测试与实现差距见
[开发文档](development.md)。导入不是数据迁移,dump/restore 仍是可选路径,见
[迁移文档](migration.md)。
@@ -3,6 +3,10 @@
- 状态:Accepted
- 日期:2026-09-20
2026-09-24 修订:[ADR-0009](0009-database-resource-and-claim.md) 已明确替代本文对 ownership
registry、任意 status 丢失自动恢复及原 Retain 合同的沿用要求。Database 合并归属、来源保护、
无旧部署兼容负担及其他仍适用的安全边界继续有效;以下保留当时迁移决策的历史背景。
## 背景
独立仓库 `postgresql-tenant-operator` 已经为 homelab 共享 PostgreSQL 设计了
@@ -0,0 +1,65 @@
# ADR-0009:分离 Database 资源与 Tenant 申请
- 状态:Accepted(资源模型与生命周期);API 字段细节待评审
- 日期:2026-09-24
- 依据:维护者本轮设计讨论;本决定部分替代 [ADR-0008](0008-merge-postgresql-tenant-operator.md)
对 ownership registry、自动恢复与 Retain 的沿用要求,不改变 Database 合并归属。
## 问题
原模型让 Tenant 同时表示用户申请和外部资源生命周期,又用 PostgreSQL registry 在 Tenant
删除后保留所有权。它把简单的一对多关系扩展成额外持久化协议,并为“外部创建成功但 status
未写入”承诺自动认领恢复。这里并没有自动恢复所有不确定结果的产品要求;清楚报告冲突、
保留现场并允许人工处理是可接受的合同。
Kubernetes status 存储在 API/etcd 中,不是 controller 重启即丢失的内存。外部操作与 API
写入之间确实存在失败窗口,但不因此引入第二套所有权数据库或模拟跨系统事务。
## 参考与取舍
参考 Kubernetes 官方 [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/):
资源独立于申请存在,绑定排他;Retain 释放后需要人工处理;已有资源可以静态登记。
同时参考 [owner references](https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/)
与 [finalizers](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/) 的生命周期边界。
采用这些模式,不直接使用 PV/PVC 类型,不实现 CSI 协议,不引入 StorageClass、容量匹配、
调度器、通用 Claim、事件总线或额外 registry。Instance 是资源来源与管理入口,PostgreSQL
adapter 承担类似驱动的访问职责;Instance 本身不是 CSI 驱动。
## 决策
- Instance 对应多个独立 Database;每个 Database 同时最多绑定一个 Tenant,Tenant 最多绑定
一个 Database。Tenant 是用户申请,不再直接承担持久资源的全部生命周期。
- Database 是新增 Kubernetes 资源;本文采用 PostgreSQLDatabase 作为工作名称,具体字段、
scope 与短名称在 API 评审中确定,不从类比自动推导。
- 动态供应先建立资源记录;已有数据库只能由管理员显式登记导入。仅发现同名数据库不是授权。
- Database 自带 instanceRef,手工登记不依赖 Tenant。动态申请由 Tenant 选择 Instance;
引用已有 Database 的 Tenant 从资源获取 Instance,不重复声明另一份来源。
- Retain 默认保留 Database 与外部资源;Tenant 删除后资源进入 Released,保留旧绑定身份,
不自动重新分配。管理员处理数据、账号权限与凭据后,才可授权重新绑定。
- 回收策略在 Database 一侧。Delete 必须具备明确管理范围、删除授权、finalizer 和回读;
导入不隐含授权改密码、改 owner、撤权或删除。
- Kubernetes CR 保存资源身份、绑定与操作进度,PostgreSQL catalog 保存实际数据库状态;
不再维护 PostgreSQL ownership registry,不把 registry 初始化或回读作为 Instance Ready 条件。
- 普通依赖失败继续 reconcile;同名未知资源、创建结果无法确认时报告 Conflict,停止对相关
资源的危险操作,提供人工诊断。已有可靠记录支持的幂等步骤可以继续,不将每次重启都变成冲突。
- status 不承诺在任意删除后自动重建所有权。灾难恢复按备份与人工核实处理。
- Database 不受 Tenant 的级联 GC 控制;Instance 删除也不能级联删除 Database 或业务数据。
## 保留的合同与未决项
管理 Secret 来源、凭据变化后刷新连接、TLS、实际扩展观察、OpenBao 应用凭据与 ESO 投射、
最小权限和分层集成验证继续适用。单 database、单 login owner 的首版使用场景不变。
Database 对 role/凭据的具体管理边界、持久凭据定位与重新授权方式、资源 scope、绑定字段、
预留及并发绑定的 API 更新协议需在 API/实现切片前细化。旧的 Tenant namespace/name 固定
凭据路径不能未经评估直接用于跨 Tenant 重新绑定;不为填满字段表而默认授权搬迁或改密。
## 实施边界
本次只修订设计。现有 registry adapter、Instance 中的 registry 判定及相关测试是待撤换的旧实现,
不是新合同的前提;未提交的 registry inspection 不继续接入。后续按新规格撤除这些依赖,
再实现 Database 资源、绑定、导入与回收的纵向切片,不保留未部署实现的兼容层。
详见 [系统规格](../database/specification.md)、[领域模型](../database/domain-model.md)、
[API 设计状态](../database/api-reference.md)和[测试合同](../database/development.md)。
+18 -4
View File
@@ -4,14 +4,17 @@ go 1.27.1
require (
github.com/jackc/pgx/v5 v5.11.0
github.com/openbao/openbao/api/v2 v2.7.0
k8s.io/api v0.37.0
k8s.io/apimachinery v0.37.0
k8s.io/client-go v0.37.0
sigs.k8s.io/controller-runtime v0.25.0
sigs.k8s.io/yaml v1.6.0
)
require (
cel.dev/expr v0.25.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
@@ -23,6 +26,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
@@ -40,15 +44,25 @@ require (
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/cel-go v0.29.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -57,6 +71,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -72,14 +87,14 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
@@ -99,5 +114,4 @@ require (
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+51 -21
View File
@@ -1,7 +1,7 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -23,12 +23,16 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -72,6 +76,10 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k=
@@ -89,6 +97,25 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -103,12 +130,14 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -121,6 +150,8 @@ github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/openbao/openbao/api/v2 v2.7.0 h1:3CD1l3tr39nQraCgFGAWA5vYvPFzZoZrt3NL7DMQKAc=
github.com/openbao/openbao/api/v2 v2.7.0/go.mod h1:uXbMoyH2pjSvNyTepinUvLde8pOJB82EuhUCfOKnKbo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -134,9 +165,9 @@ github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLA
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -147,8 +178,8 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -179,14 +210,15 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -195,12 +227,12 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
@@ -214,8 +246,6 @@ google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
@@ -0,0 +1,61 @@
package kubernetes
import (
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
"k8s.io/apimachinery/pkg/types"
)
func bindingTenant(object *databasev1alpha1.PostgreSQLTenant) *application.BindingTenant {
tenant := &application.BindingTenant{
Revision: object.ResourceVersion, Generation: object.Generation,
}
tenant.Tenant = binding.Tenant{
Identity: binding.TenantIdentity{Namespace: object.Namespace, Name: object.Name, UID: string(object.UID)},
Phase: object.Status.Phase, Deleting: !object.DeletionTimestamp.IsZero(),
}
if request := object.Spec.Provision; request != nil {
tenant.Request.Provision = &binding.ProvisionRequest{
Instance: string(request.InstanceRef.Name), Database: string(request.Database), LoginRole: string(request.LoginRole),
}
}
if object.Spec.DatabaseRef != nil {
tenant.Request.ExistingDatabase = string(object.Spec.DatabaseRef.Name)
}
if ref := object.Status.DatabaseRef; ref != nil {
tenant.Database = &binding.Identity{Name: string(ref.Name), UID: string(ref.UID)}
}
return tenant
}
func bindingDatabase(object *databasev1alpha1.PostgreSQLDatabase) *application.BindingDatabase {
database := &application.BindingDatabase{
Revision: object.ResourceVersion,
}
database.Database = binding.Database{
Identity: binding.Identity{Name: object.Name, UID: string(object.UID)},
Instance: string(object.Spec.InstanceRef.Name), InstanceUID: string(object.Status.InstanceUID),
Name: string(object.Spec.Database), LoginRole: string(object.Spec.LoginRole), Source: object.Spec.Source,
Phase: object.Status.Phase, Deleting: !object.DeletionTimestamp.IsZero(),
Ready: currentReady(object.Generation, object.Status.Conditions),
}
if ref := object.Spec.TenantRef; ref != nil {
database.Tenant = &binding.TenantIdentity{Namespace: ref.Namespace, Name: string(ref.Name), UID: string(ref.UID)}
}
return database
}
func tenantReference(tenant binding.TenantIdentity) *databasev1alpha1.TenantReference {
return &databasev1alpha1.TenantReference{
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: types.UID(tenant.UID),
}
}
// BindingTargetName 供 informer 索引使用;不把无效请求丢出事件映射。
func BindingTargetName(tenant *databasev1alpha1.PostgreSQLTenant) string {
if tenant.Spec.DatabaseRef != nil {
return string(tenant.Spec.DatabaseRef.Name)
}
return binding.DynamicDatabaseName(string(tenant.UID))
}
@@ -0,0 +1,191 @@
package kubernetes
import (
"context"
"fmt"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
const (
TenantFinalizer = "database.ayatori.ddupan.top/tenant-protection"
DatabaseFinalizer = "database.ayatori.ddupan.top/database-protection"
)
// BindingResources 读取领域所需事实,并把用例结果呈现为 CR、finalizer 与 Conditions。
// 重新读取后校验快照版本,保留不属于本用例的字段;不决定绑定资格或恢复顺序。
type BindingResources struct {
Client client.Client
Reader client.Reader
}
var _ application.BindingResources = (*BindingResources)(nil)
func (r *BindingResources) Tenant(ctx context.Context, namespace, name string) (*application.BindingTenant, error) {
object := &databasev1alpha1.PostgreSQLTenant{}
if err := r.Reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return bindingTenant(object), nil
}
func (r *BindingResources) Database(ctx context.Context, name string) (*application.BindingDatabase, error) {
object := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return bindingDatabase(object), nil
}
func (r *BindingResources) Instance(ctx context.Context, name string) (*binding.Instance, error) {
object := &databasev1alpha1.PostgreSQLInstance{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return &binding.Instance{
Identity: binding.Identity{Name: object.Name, UID: string(object.UID)},
Deleting: !object.DeletionTimestamp.IsZero(), Ready: currentReady(object.Generation, object.Status.Conditions),
}, nil
}
func (r *BindingResources) BeginBinding(ctx context.Context, tenant *application.BindingTenant,
checkpoint *application.BindingStatus) (*application.BindingTenant, error) {
object, err := r.tenantAtVersion(ctx, tenant)
if err != nil {
return nil, err
}
if controllerutil.AddFinalizer(object, TenantFinalizer) {
if err := r.Client.Update(ctx, object); err != nil {
return nil, err
}
}
if checkpoint != nil {
if err := r.presentStatus(ctx, object, *checkpoint); err != nil {
return nil, err
}
}
return bindingTenant(object), nil
}
func (r *BindingResources) CreateDatabase(ctx context.Context, target binding.Target,
tenant binding.TenantIdentity) (*application.BindingDatabase, error) {
object := &databasev1alpha1.PostgreSQLDatabase{}
object.Name = target.Name
object.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(target.Provision.Instance)},
Database: databasev1alpha1.PostgreSQLIdentifier(target.Provision.Database),
LoginRole: databasev1alpha1.PostgreSQLIdentifier(target.Provision.LoginRole),
Source: "Provision", ReclaimPolicy: databasev1alpha1.ReclaimRetain, TenantRef: tenantReference(tenant),
}
controllerutil.AddFinalizer(object, DatabaseFinalizer)
if err := r.Client.Create(ctx, object); err != nil {
return nil, err
}
return bindingDatabase(object), nil
}
func (r *BindingResources) RecordInstance(ctx context.Context, database *application.BindingDatabase,
instanceUID string) (*application.BindingDatabase, error) {
object, err := r.databaseAtVersion(ctx, database)
if err != nil {
return nil, err
}
object.Status.InstanceUID = types.UID(instanceUID)
if err := r.Client.Status().Update(ctx, object); err != nil {
return nil, err
}
return bindingDatabase(object), nil
}
func (r *BindingResources) BindDatabase(ctx context.Context, database *application.BindingDatabase,
tenant binding.TenantIdentity) (*application.BindingDatabase, error) {
object, err := r.databaseAtVersion(ctx, database)
if err != nil {
return nil, err
}
wanted := tenantReference(tenant)
changed := controllerutil.AddFinalizer(object, DatabaseFinalizer)
if object.Spec.TenantRef == nil || *object.Spec.TenantRef != *wanted {
object.Spec.TenantRef = wanted
changed = true
}
if changed {
if err := r.Client.Update(ctx, object); err != nil {
return nil, err
}
}
return bindingDatabase(object), nil
}
func (r *BindingResources) Present(ctx context.Context, result application.BindingResult) error {
if result.Tenant == nil {
return nil
}
object, err := r.tenantAtVersion(ctx, result.Tenant)
if err != nil {
return client.IgnoreNotFound(err)
}
return r.presentStatus(ctx, object, result.Status)
}
func (r *BindingResources) presentStatus(ctx context.Context, object *databasev1alpha1.PostgreSQLTenant,
status application.BindingStatus) error {
previous := object.Status.DeepCopy()
object.Status.Phase = status.Phase
object.Status.ObservedGeneration = object.Generation
if status.Database != nil {
object.Status.DatabaseRef = &databasev1alpha1.BoundDatabaseReference{
Name: databasev1alpha1.ObjectName(status.Database.Name), UID: types.UID(status.Database.UID),
}
}
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
Type: "Ready", Status: metav1.ConditionFalse, Reason: status.Reason, Message: status.Message,
ObservedGeneration: object.Generation,
})
if equality.Semantic.DeepEqual(*previous, object.Status) {
return nil
}
return r.Client.Status().Update(ctx, object)
}
func (r *BindingResources) tenantAtVersion(ctx context.Context, tenant *application.BindingTenant) (*databasev1alpha1.PostgreSQLTenant, error) {
object := &databasev1alpha1.PostgreSQLTenant{}
key := types.NamespacedName{Namespace: tenant.Identity.Namespace, Name: tenant.Identity.Name}
if err := r.Reader.Get(ctx, key, object); err != nil {
return nil, err
}
if string(object.UID) != tenant.Identity.UID || object.ResourceVersion != tenant.Revision {
return nil, bindingVersionConflict("postgresqltenants", object.Name)
}
return object, nil
}
func (r *BindingResources) databaseAtVersion(ctx context.Context, database *application.BindingDatabase) (*databasev1alpha1.PostgreSQLDatabase, error) {
object := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Reader.Get(ctx, types.NamespacedName{Name: database.Identity.Name}, object); err != nil {
return nil, err
}
if string(object.UID) != database.Identity.UID || object.ResourceVersion != database.Revision {
return nil, bindingVersionConflict("postgresqldatabases", object.Name)
}
return object, nil
}
func bindingVersionConflict(resource, name string) error {
return apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource(resource).GroupResource(), name,
fmt.Errorf("绑定快照已过期,请重新读取后判断"))
}
func currentReady(generation int64, conditions []metav1.Condition) bool {
condition := meta.FindStatusCondition(conditions, "Ready")
return condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == generation
}
@@ -0,0 +1,45 @@
package kubernetes
import (
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func instanceRecord(object *databasev1alpha1.PostgreSQLInstance) (*application.InstanceRecord, error) {
identity, err := instance.NewIdentity(string(object.UID), object.Name)
if err != nil {
return nil, err
}
revision, err := instance.NewRevision(object.Generation)
if err != nil {
return nil, err
}
spec := object.Spec
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: spec.Endpoint.Host, HostAddr: spec.Endpoint.HostAddr,
Port: int(spec.Endpoint.Port), ManagementDatabase: string(spec.Endpoint.Database),
TLSMode: instance.TLSMode(spec.Endpoint.SSLMode),
})
if err != nil {
return nil, err
}
credential, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: string(spec.AdminCredentialRef.Name),
UsernameKey: spec.AdminCredentialRef.UsernameKey, PasswordKey: spec.AdminCredentialRef.PasswordKey,
})
if err != nil {
return nil, err
}
definition, err := instance.NewDefinition(endpoint, credential)
if err != nil {
return nil, err
}
target, err := instance.NewObservationTarget(identity, revision, definition)
if err != nil {
return nil, err
}
return &application.InstanceRecord{
Target: target, Revision: object.ResourceVersion, Deleting: !object.DeletionTimestamp.IsZero(),
}, nil
}
@@ -0,0 +1,112 @@
package kubernetes
import (
"context"
"errors"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
const InstanceFinalizer = "database.ayatori.ddupan.top/instance-protection"
type InstanceResources struct {
Client client.Client
Reader client.Reader
}
func (r *InstanceResources) LoadInstance(ctx context.Context, name string) (*application.InstanceRecord, error) {
object := &databasev1alpha1.PostgreSQLInstance{}
if err := r.Reader.Get(ctx, client.ObjectKey{Name: name}, object); err != nil {
return nil, client.IgnoreNotFound(err)
}
return instanceRecord(object)
}
func (r *InstanceResources) ProtectInstance(ctx context.Context, record *application.InstanceRecord) (*application.InstanceRecord, error) {
object, err := r.instanceAtVersion(ctx, record)
if err != nil {
return nil, err
}
if controllerutil.AddFinalizer(object, InstanceFinalizer) {
if err := r.Client.Update(ctx, object); err != nil {
return nil, err
}
}
return instanceRecord(object)
}
func (r *InstanceResources) InstanceReferences(ctx context.Context, name string) (string, error) {
// 删除判断必须直读 API;包含 Released、删除中的 Database 和尚未绑定的申请。
// 不按旧 Instance UID 忽略引用,也不依赖 informer 索引的及时性。
databases := &databasev1alpha1.PostgreSQLDatabaseList{}
if err := r.Reader.List(ctx, databases); err != nil {
return "", err
}
for _, database := range databases.Items {
if string(database.Spec.InstanceRef.Name) == name {
return "Database/" + database.Name, nil
}
}
tenants := &databasev1alpha1.PostgreSQLTenantList{}
if err := r.Reader.List(ctx, tenants); err != nil {
return "", err
}
for _, tenant := range tenants.Items {
if tenant.Spec.Provision != nil && string(tenant.Spec.Provision.InstanceRef.Name) == name {
return "Tenant/" + tenant.Namespace + "/" + tenant.Name, nil
}
}
return "", nil
}
func (r *InstanceResources) PresentInstance(ctx context.Context, result application.InstanceResult) error {
if result.Record == nil {
return nil
}
object, err := r.instanceAtVersion(ctx, result.Record)
if err != nil {
return client.IgnoreNotFound(err)
}
previous := object.Status.DeepCopy()
object.Status.Phase = string(result.Snapshot.Phase)
object.Status.ObservedGeneration = object.Generation
object.Status.PostgreSQLVersion = result.Snapshot.ReportedVersion
ready := metav1.ConditionFalse
if result.Snapshot.Readiness == instance.Ready {
ready = metav1.ConditionTrue
}
meta.SetStatusCondition(&object.Status.Conditions, metav1.Condition{
Type: "Ready", Status: ready, ObservedGeneration: object.Generation,
Reason: result.Reason, Message: result.Message,
})
if !equality.Semantic.DeepEqual(*previous, object.Status) {
if err := r.Client.Status().Update(ctx, object); err != nil {
return err
}
}
if result.RemoveProtection && controllerutil.RemoveFinalizer(object, InstanceFinalizer) {
return r.Client.Update(ctx, object)
}
return nil
}
func (r *InstanceResources) instanceAtVersion(ctx context.Context, record *application.InstanceRecord) (*databasev1alpha1.PostgreSQLInstance, error) {
object := &databasev1alpha1.PostgreSQLInstance{}
name := record.Target.Identity().Name()
if err := r.Reader.Get(ctx, client.ObjectKey{Name: name}, object); err != nil {
return nil, err
}
if string(object.UID) != record.Target.Identity().UID() || object.ResourceVersion != record.Revision {
return nil, apierrors.NewConflict(databasev1alpha1.GroupVersion.WithResource("postgresqlinstances").GroupResource(),
name, errors.New("Instance 快照已过期,请重新观察"))
}
return object, nil
}
@@ -0,0 +1,140 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package openbao 通过官方 SDK 适配应用凭据,不保存资源归属或重建供应状态。
package openbao
import (
"context"
"errors"
"maps"
"net/http"
"regexp"
"slices"
"strings"
bao "github.com/openbao/openbao/api/v2"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
)
var (
ErrInvalidLocation = errors.New("credential location is outside the configured scope")
ErrUnavailable = errors.New("credential backend unavailable")
ErrNotFound = errors.New("application credential not found")
ErrConflict = errors.New("credential creation requires manual conflict resolution")
ErrUncertain = errors.New("credential creation outcome is uncertain; manual resolution required")
)
var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Credentials 使用独立的 SDK client;认证与短期 token 生命周期由部署装配负责。
// 本适配器既不自动认领已有值,也不提供覆盖、轮换或删除操作。
type Credentials struct {
kv *bao.KVv2
basePath string
}
// NewCredentials 不登录、不读取环境 token。调用方必须提供专用的已认证 client。
// 禁用 SDK 写入重试,防止第一次结果丢失后被 CAS 错误掩盖。
func NewCredentials(client *bao.Client, mount, basePath string) (*Credentials, error) {
if client == nil || !validPath(mount) || !validPath(basePath) {
return nil, ErrInvalidLocation
}
client.SetMaxRetries(0)
return &Credentials{kv: client.KVv2(mount), basePath: basePath}, nil
}
func validPath(value string) bool {
for segment := range strings.SplitSeq(value, "/") {
if !pathSegment.MatchString(segment) || segment == "data" || segment == "metadata" {
return false
}
}
return true
}
// ProvisionPath 只按 Database UID 定位;调用方须先持久化位置,再执行外部写入。
func (c *Credentials) ProvisionPath(databaseUID string) (string, error) {
if !pathSegment.MatchString(databaseUID) {
return "", ErrInvalidLocation
}
return c.basePath + "/" + databaseUID, nil
}
func (c *Credentials) accepts(path string) bool {
return validPath(path) && strings.HasPrefix(path, c.basePath+"/")
}
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) {
if !c.accepts(path) {
return application.ApplicationCredential{}, ErrInvalidLocation
}
secret, err := c.kv.Get(ctx, path)
if errors.Is(err, bao.ErrSecretNotFound) {
return application.ApplicationCredential{}, ErrNotFound
}
if err != nil {
return application.ApplicationCredential{}, ErrUnavailable
}
if secret == nil || secret.Data == nil {
return application.ApplicationCredential{}, ErrNotFound
}
return application.ParseApplicationCredential(secret.Data)
}
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
// 任何不确定写入都不返回凭据;上层必须停止供应并持久化冲突,不能重新生成密码。
func (c *Credentials) Create(ctx context.Context, path string, credential application.ApplicationCredential) error {
if !c.accepts(path) {
return ErrInvalidLocation
}
if err := credential.Validate(); err != nil {
return err
}
if ctx.Err() != nil {
return ErrUnavailable
}
data := credential.SecretData()
created, err := c.kv.Put(ctx, path, data, bao.WithCheckAndSet(0))
if err != nil {
// 明确的认证/权限拒绝没有发生写入,可以等待依赖恢复。
// SDK 的原始错误可能携带路径及响应体,不向外传播。
if response, ok := errors.AsType[*bao.ResponseError](err); ok {
switch response.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return ErrUnavailable
case http.StatusBadRequest:
if slices.Contains(response.Errors, "check-and-set parameter did not match the current version") {
return ErrConflict
}
}
}
return ErrUncertain
}
if created == nil || created.VersionMetadata == nil || created.VersionMetadata.Version != 1 {
return ErrUncertain
}
observed, err := c.kv.Get(ctx, path)
if err != nil || observed == nil || observed.VersionMetadata == nil || observed.VersionMetadata.Version != 1 {
return ErrUncertain
}
if !maps.Equal(data, observed.Data) {
return ErrUncertain
}
return nil
}
@@ -0,0 +1,227 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package openbao_test
import (
"context"
"errors"
"maps"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os/exec"
"regexp"
"strings"
"sync"
"testing"
"time"
bao "github.com/openbao/openbao/api/v2"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
)
// 只连接本测试创建的无持久卷 dev server,不接受生产地址或环境 token。
func baoFixture(t *testing.T) *bao.Client {
t.Helper()
const image = "openbao/openbao@sha256:5b2486ab0fb90bbc788cc345b0a08616dfb375873ee8be5df3a2fd4d378a67e0"
prepareBaoImage(t, image)
// 冷缓存拉取不占用容器启动和健康检查的一分钟预算。
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
output, err := exec.CommandContext(ctx, "docker", "run", "--pull=never", "--rm", "-d", "-p", "127.0.0.1::8200",
image, "server", "-dev", "-dev-root-token-id="+fixtureToken, "-dev-listen-address=0.0.0.0:8200").Output()
if err != nil {
t.Fatalf("cannot start isolated OpenBao fixture: %s", baoCommandError(ctx, err))
}
id := strings.TrimSpace(string(output))
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
t.Fatal("unexpected fixture container ID")
}
t.Cleanup(func() {
cleanup, stop := context.WithTimeout(context.Background(), 30*time.Second)
defer stop()
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
t.Error("OpenBao fixture cleanup failed")
}
})
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format",
`{{(index (index .NetworkSettings.Ports "8200/tcp") 0).HostPort}}`, id).Output()
if err != nil {
t.Fatalf("cannot inspect fixture port: %s", baoCommandError(ctx, err))
}
client := fixtureClient(t, "http://127.0.0.1:"+strings.TrimSpace(string(output)))
client.SetMaxRetries(0)
for {
if _, err := client.Sys().HealthWithContext(ctx); err == nil {
return client
}
select {
case <-ctx.Done():
t.Fatal("OpenBao fixture startup timed out")
case <-time.After(100 * time.Millisecond):
}
}
}
func prepareBaoImage(t *testing.T, image string) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
defer cancel()
if exec.CommandContext(ctx, "docker", "image", "inspect", image).Run() == nil {
return
}
t.Log("pulling isolated OpenBao fixture image (timeout: 5m)")
if _, err := exec.CommandContext(ctx, "docker", "pull", image).Output(); err != nil {
t.Fatalf("cannot pull OpenBao fixture image: %s", baoCommandError(ctx, err))
}
}
// 保留 Docker stderr 与超时原因,但不泄露测试 token/password 或完整命令参数。
func baoCommandError(ctx context.Context, err error) string {
detail := err.Error()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
detail += ": " + strings.TrimSpace(string(exitErr.Stderr))
}
if ctx.Err() != nil {
detail += ": " + ctx.Err().Error()
}
return strings.NewReplacer(fixtureToken, "[REDACTED]", fixturePassword, "[REDACTED]").Replace(detail)
}
func TestBaoCommandError(t *testing.T) {
err := &exec.ExitError{Stderr: []byte("registry unavailable " + fixtureToken + " " + fixturePassword)}
ctx, cancel := context.WithCancel(t.Context())
cancel()
detail := baoCommandError(ctx, err)
if !strings.Contains(detail, "registry unavailable") || !strings.Contains(detail, "context canceled") {
t.Fatal("Docker diagnostic or context failure was lost")
}
if strings.Contains(detail, fixtureToken) || strings.Contains(detail, fixturePassword) {
t.Fatal("Docker diagnostic exposed fixture credentials")
}
}
func TestCredentialConcurrentCreateWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
store := fixtureStore(t, root)
credential := fixtureCredential(t)
results := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Go(func() { results <- store.Create(t.Context(), credentialPath, credential) })
}
workers.Wait()
close(results)
succeeded, conflicted := 0, 0
for err := range results {
switch err {
case nil:
succeeded++
case openbao.ErrConflict:
conflicted++
default:
t.Fatal("unexpected concurrent create result")
}
}
if succeeded != 1 || conflicted != 1 {
t.Fatal("CAS must allow exactly one creator")
}
}
func TestCredentialLostWriteResponseWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
address, err := url.Parse(root.Address())
if err != nil {
t.Fatal("invalid fixture address")
}
proxy := httputil.NewSingleHostReverseProxy(address)
proxy.ModifyResponse = func(response *http.Response) error {
if response.Request.Method == http.MethodPut && response.StatusCode == http.StatusOK {
return errors.New("fixture drops successful write response")
}
return nil
}
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) {
w.WriteHeader(http.StatusBadGateway)
}
server := httptest.NewServer(proxy)
defer server.Close()
store := fixtureStore(t, fixtureClient(t, server.URL))
credential := fixtureCredential(t)
if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain {
t.Fatal("lost response must stop provisioning")
}
confirmed, err := root.KVv2("secret").Get(t.Context(), credentialPath)
if err != nil || !maps.Equal(confirmed.Data, credential.SecretData()) || confirmed.VersionMetadata.Version != 1 {
t.Fatal("fault injection did not preserve the original write")
}
if err := fixtureStore(t, root).Create(t.Context(), credentialPath, credential); err != openbao.ErrConflict {
t.Fatal("restart must not adopt an unconfirmed write")
}
}
func TestCredentialsWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
ctx := t.Context()
// root 仅用于 fixture 装配;实际读写使用固定前缀的短期 token。
policy := `path "secret/data/applications/*" { capabilities = ["create", "update", "read"] }`
if err := root.Sys().PutPolicyWithContext(ctx, "application-fixture", policy); err != nil {
t.Fatal("cannot configure fixture policy")
}
secret, err := root.Auth().Token().CreateWithContext(ctx, &bao.TokenCreateRequest{
Policies: []string{"application-fixture"}, NoDefaultPolicy: true, TTL: "5m",
})
if err != nil {
t.Fatal("cannot create scoped fixture token")
}
client := fixtureClient(t, root.Address())
client.SetToken(secret.Auth.ClientToken)
store := fixtureStore(t, client)
credential := fixtureCredential(t)
if err := store.Create(ctx, credentialPath, credential); err != nil {
t.Fatal(err)
}
// 重建适配器读取已确认路径;重复 Create 仍报冲突,不把读取当作认领。
restarted := fixtureStore(t, client)
observed, err := restarted.Read(ctx, credentialPath)
if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) {
t.Fatal("confirmed credential was not preserved across adapter restart")
}
if err := restarted.Create(ctx, credentialPath, credential); !errors.Is(err, openbao.ErrConflict) {
t.Fatal("existing credential must conflict even if contents match")
}
metadata, err := root.KVv2("secret").GetMetadata(ctx, credentialPath)
if err != nil || metadata.CurrentVersion != 1 {
t.Fatal("duplicate create changed credential version")
}
if _, err := client.KVv2("secret").Get(ctx, "management/instance"); err == nil {
t.Fatal("scoped token accessed management credentials")
}
if err := root.KVv2("secret").Delete(ctx, credentialPath); err != nil {
t.Fatal("cannot soft-delete fixture credential")
}
if _, err := store.Read(ctx, credentialPath); err != openbao.ErrNotFound {
t.Fatal("soft-deleted credential must not be usable")
}
if err := store.Create(ctx, credentialPath, credential); err != openbao.ErrConflict {
t.Fatal("soft-deleted credential must not be recreated")
}
}
@@ -0,0 +1,190 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package openbao_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
bao "github.com/openbao/openbao/api/v2"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
)
const (
credentialPath = "applications/database-uid"
fixturePassword = "AYATORI-TEST-ONLY-application-password"
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
kvDataKey = "data"
)
func fixtureCredential(t *testing.T) application.ApplicationCredential {
t.Helper()
credential, err := application.ParseApplicationCredential(map[string]any{
"username": "app_owner", "password": fixturePassword, "database": "app",
"host": "postgres.example", "hostaddr": "192.0.2.1", "port": "5432", "sslmode": "verify-full",
})
if err != nil {
t.Fatal(err)
}
return credential
}
func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
for _, scenario := range []string{"read failure", "changed version", "changed password", "missing metadata"} {
t.Run(scenario, func(t *testing.T) {
credential := fixtureCredential(t)
var writes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
writes.Add(1)
var request struct {
Options struct {
CAS *int `json:"cas"`
} `json:"options"`
}
if json.NewDecoder(r.Body).Decode(&request) != nil || request.Options.CAS == nil || *request.Options.CAS != 0 {
t.Error("create request must explicitly require CAS=0")
}
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{"version": 1}}); err != nil {
t.Error("cannot encode fixture write response")
}
return
}
if scenario == "read failure" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
data := credential.SecretData()
version := 1
if scenario == "changed version" {
version = 2
}
if scenario == "changed password" {
data["password"] = "modified"
}
response := map[string]any{kvDataKey: data}
if scenario != "missing metadata" {
response["metadata"] = map[string]any{"version": version}
}
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil {
t.Error("cannot encode fixture read response")
}
}))
defer server.Close()
store := fixtureStore(t, fixtureClient(t, server.URL))
if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain || writes.Load() != 1 {
t.Fatal("unconfirmed readback must stop after one write")
}
})
}
}
func fixtureClient(t *testing.T, address string) *bao.Client {
t.Helper()
config := bao.DefaultConfig()
config.Address = address
client, err := bao.NewClient(config)
if err != nil {
t.Fatal("cannot construct fixture client")
}
client.SetToken(fixtureToken)
return client
}
func fixtureStore(t *testing.T, client *bao.Client) *openbao.Credentials {
t.Helper()
store, err := openbao.NewCredentials(client, "secret", "applications")
if err != nil {
t.Fatal(err)
}
return store
}
func TestCredentialLocationScope(t *testing.T) {
client := fixtureClient(t, "http://127.0.0.1:1")
store := fixtureStore(t, client)
path, err := store.ProvisionPath("database-uid")
if err != nil || path != credentialPath {
t.Fatal("unexpected stable location")
}
for _, path := range []string{"", "/absolute", "applications", "applications-other/key", "applications/../management", "applications/%2e%2e/key", "applications//key", "applications/data/key"} {
if _, err := store.Read(t.Context(), path); !errors.Is(err, openbao.ErrInvalidLocation) {
t.Fatal("accepted invalid location")
}
if err := store.Create(t.Context(), path, fixtureCredential(t)); !errors.Is(err, openbao.ErrInvalidLocation) {
t.Fatal("accepted invalid create location")
}
}
for _, uid := range []string{"", "../key", "a/b", "a?b"} {
if _, err := store.ProvisionPath(uid); err == nil {
t.Fatal("accepted invalid UID")
}
}
for _, invalid := range []string{"", "data", "metadata", "../secret", "secret/", "secret?query"} {
if _, err := openbao.NewCredentials(client, invalid, "applications"); err == nil {
t.Fatal("accepted invalid mount")
}
if _, err := openbao.NewCredentials(client, "secret", invalid); err == nil {
t.Fatal("accepted invalid base path")
}
}
}
func TestCredentialWriteFailureDoesNotRetryOrLeak(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
http.Error(w, fixturePassword+fixtureToken, http.StatusInternalServerError)
}))
defer server.Close()
store := fixtureStore(t, fixtureClient(t, server.URL))
if err := store.Create(t.Context(), credentialPath, fixtureCredential(t)); err != openbao.ErrUncertain {
t.Fatal("write error must be a redacted uncertain outcome")
}
if requests.Load() != 1 {
t.Fatal("SDK retried an uncertain write")
}
if _, err := store.Read(t.Context(), credentialPath); err != openbao.ErrUnavailable {
t.Fatal("read error must be redacted")
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := store.Create(ctx, credentialPath, fixtureCredential(t)); err != openbao.ErrUnavailable || requests.Load() != 2 {
t.Fatal("canceled operation must not write")
}
}
func TestCredentialWriteDeniedBeforeExecution(t *testing.T) {
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, fixtureToken, status)
}))
store := fixtureStore(t, fixtureClient(t, server.URL))
err := store.Create(t.Context(), credentialPath, fixtureCredential(t))
server.Close()
if err != openbao.ErrUnavailable {
t.Fatalf("status %d: definite rejection should wait for dependency recovery, got %v", status, err)
}
}
}
@@ -47,14 +47,6 @@ func (d *database) Close() {
d.pool.Close()
}
func (d *database) Version(ctx context.Context) (string, error) {
var version string
if err := d.pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
return "", safeError(err, application.ErrObservation)
}
return version, nil
}
func (c Connector) Connect(ctx context.Context, endpoint instance.Endpoint, credentials application.Credentials) (application.Database, error) {
if err := endpoint.Validate(); err != nil {
return nil, err
@@ -131,13 +131,17 @@ func TestObservationDiscardsResultWhenCredentialsChange(t *testing.T) {
}
}
version, err := fixture.service.ObserveVersion(fixture.ctx, fixture.target)
observation, err := fixture.service.ObserveMetadata(fixture.ctx, fixture.target)
if !errors.Is(err, application.ErrCredentialsChanged) {
t.Fatal("in-flight rotation was not detected")
}
if version != "" {
if observation.Version() != "" {
t.Fatal("observation returned data obtained with stale credentials")
}
requested := instance.NewExtensionSet([]string{fixtureExtension})
if observation.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
t.Fatal("observation returned extension support obtained with stale credentials")
}
if fixture.backendIDs(t) != "" {
t.Fatal("stale connection was retained after rotation")
}
@@ -31,6 +31,7 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/envtest"
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
@@ -40,8 +41,13 @@ import (
)
const (
fixtureAddress = "127.0.0.1"
managementUsernameKey = "login"
managementPasswordKey = "credential"
unrelatedNamespace = "unrelated"
fixtureHost = "fixture.invalid"
fixtureUser = "postgres"
fixtureExtension = "plpgsql"
dockerExec = "exec"
fixtureImage = "postgres@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73"
fixturePassword = "AYATORI-TEST-ONLY-initial-password"
@@ -78,7 +84,7 @@ func postgresFixture(t *testing.T, ctx context.Context) (string, int) {
t.Fatal("invalid fixture port")
}
// 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", "127.0.0.1", "-U", fixtureUser).Run() != nil {
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", fixtureAddress, "-U", fixtureUser).Run() != nil {
select {
case <-ctx.Done():
t.Fatal("fixture startup timed out")
@@ -153,7 +159,7 @@ func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationT
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: fixtureHost,
HostAddr: "127.0.0.1",
HostAddr: fixtureAddress,
Port: port,
ManagementDatabase: fixtureUser,
TLSMode: mode,
@@ -163,8 +169,8 @@ func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationT
}
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
Name: secretName,
UsernameKey: "login",
PasswordKey: "credential",
UsernameKey: managementUsernameKey,
PasswordKey: managementPasswordKey,
})
if err != nil {
t.Fatal(err)
@@ -195,6 +201,7 @@ func (r *gatedReader) Read(ctx context.Context, ref instance.CredentialReference
// credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。
type credentialFixture struct {
config *rest.Config
ctx context.Context
client *kubernetes.Clientset
reader *secretadapter.SecretCredentials
@@ -226,7 +233,7 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
if err != nil {
t.Fatal("cannot create test client")
}
for _, namespace := range []string{controllerNamespace, "unrelated"} {
for _, namespace := range []string{controllerNamespace, unrelatedNamespace} {
_, err := client.CoreV1().Namespaces().Create(
ctx,
&corev1.Namespace{Name: namespace},
@@ -259,6 +266,7 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
t.Cleanup(service.Close)
return &credentialFixture{
config: config,
ctx: ctx,
client: client,
reader: reader,
@@ -276,8 +284,8 @@ func (f *credentialFixture) createSecret(t *testing.T, namespace string) {
secret := &corev1.Secret{
Name: secretName,
Data: map[string][]byte{
"login": []byte(fixtureUser),
"credential": []byte(fixturePassword),
managementUsernameKey: []byte(fixtureUser),
managementPasswordKey: []byte(fixturePassword),
},
}
if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil {
@@ -0,0 +1,210 @@
//go:build integration
package postgresql_test
import (
"bytes"
"context"
"os"
"testing"
"time"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
"sigs.k8s.io/controller-runtime/pkg/envtest"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/yaml"
)
const watchRevisionAnnotation = "test.ayatori/observation"
func TestInstanceControllerWithRealPostgreSQL(t *testing.T) {
f := newCredentialFixture(t)
if _, err := envtest.InstallCRDs(f.config, envtest.CRDInstallOptions{
Paths: []string{"../../../../config/crd/bases"}, ErrorIfPathMissing: true,
}); err != nil {
t.Fatal(err)
}
scheme := runtime.NewScheme()
for _, install := range []func(*runtime.Scheme) error{databasev1alpha1.AddToScheme, corev1.AddToScheme, rbacv1.AddToScheme} {
if err := install(scheme); err != nil {
t.Fatal(err)
}
}
apiClient, err := client.New(f.config, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
restricted := instanceControllerRBAC(t, f, apiClient)
credentials, err := secretadapter.NewSecretCredentials(restricted, controllerNamespace)
if err != nil {
t.Fatal(err)
}
service, err := application.NewInstanceService(credentials, postgresql.Connector{})
if err != nil {
t.Fatal(err)
}
// 同进程 -count 重复启动测试 manager;生产继续校验 controller 名称唯一。
skipRepeatedName := true
manager, err := ctrl.NewManager(restricted, ctrl.Options{
Scheme: scheme, Cache: databasecontroller.InstanceCacheOptions(controllerNamespace),
Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedName},
})
if err != nil {
t.Fatal(err)
}
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: controllerNamespace}
if err := reconciler.SetupWithManager(manager); err != nil {
t.Fatal(err)
}
managerContext, stop := context.WithCancel(f.ctx)
done := make(chan error, 1)
go func() { done <- manager.Start(managerContext) }()
t.Cleanup(func() {
stop()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(20 * time.Second):
t.Error("Instance manager 未停止")
}
service.Close()
})
object := &databasev1alpha1.PostgreSQLInstance{}
object.Name = "native-instance"
object.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{
Host: fixtureHost, HostAddr: fixtureAddress, Port: int32(f.port), SSLMode: "disable",
}
object.Spec.AdminCredentialRef = databasev1alpha1.AdminCredentialReference{
Name: secretName, UsernameKey: managementUsernameKey, PasswordKey: managementPasswordKey,
}
if err := apiClient.Create(f.ctx, object); err != nil {
t.Fatal(err)
}
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
// 30 秒轮询前必须收到 Secret 创建事件;实际 controller 使用 namespace Role + metadata watch。
useNativeManager(t, f)
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
before := f.backendIDs(t)
f.updateSecret(t, func(secret *corev1.Secret) {
secret.Annotations = map[string]string{watchRevisionAnnotation: "changed"}
})
// 用实际 API 事件触发重验,metadata 改动不应换池。
time.Sleep(200 * time.Millisecond)
if f.backendIDs(t) != before {
t.Fatal("无关 Secret metadata 修改重建了连接")
}
f.queryPostgres(t, "ALTER ROLE native_manager PASSWORD '"+rotatedPassword+"'")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte("invalid-test-password") })
awaitInstanceReason(t, f, apiClient, object, "AuthenticationFailed")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
if f.backendIDs(t) == before {
t.Fatal("凭据轮换没有替换旧连接")
}
f.queryPostgres(t, "ALTER ROLE native_manager NOCREATEROLE")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recheck" })
awaitInstanceReason(t, f, apiClient, object, "InsufficientPrivileges")
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE")
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recovered" })
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
if err := f.client.CoreV1().Secrets(controllerNamespace).Delete(f.ctx, secretName, metav1.DeleteOptions{}); err != nil {
t.Fatal(err)
}
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
if f.backendIDs(t) != "" {
t.Fatal("Secret 删除后旧连接未释放")
}
}
func awaitInstanceReason(t *testing.T, f *credentialFixture, apiClient client.Client,
object *databasev1alpha1.PostgreSQLInstance, reason string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if err := apiClient.Get(f.ctx, client.ObjectKeyFromObject(object), object); err != nil {
t.Fatal(err)
}
condition := meta.FindStatusCondition(object.Status.Conditions, "Ready")
if condition != nil && condition.Reason == reason && condition.ObservedGeneration == object.Generation {
if (condition.Status == metav1.ConditionTrue) != (reason == "ManagementReady") {
t.Fatal("Ready 与检查结果不一致")
}
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("watch 未及时推进到 %s", reason)
}
func instanceControllerRBAC(t *testing.T, f *credentialFixture, apiClient client.Client) *rest.Config {
t.Helper()
roleBytes, err := os.ReadFile("../../../../config/rbac/role.yaml")
if err != nil {
t.Fatal(err)
}
role := &rbacv1.ClusterRole{}
if err := yaml.Unmarshal(roleBytes, role); err != nil {
t.Fatal(err)
}
if err := apiClient.Create(f.ctx, role); err != nil {
t.Fatal(err)
}
user := "instance-controller-test"
binding := &rbacv1.ClusterRoleBinding{}
binding.Name = user
binding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}
binding.Subjects = []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: user}}
if err := apiClient.Create(f.ctx, binding); err != nil {
t.Fatal(err)
}
credentialBytes, err := os.ReadFile("../../../../config/rbac/database_credentials_role.yaml")
if err != nil {
t.Fatal(err)
}
namespaceRole := &rbacv1.Role{}
decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader(credentialBytes), 4096)
if err := decoder.Decode(namespaceRole); err != nil {
t.Fatal(err)
}
namespaceRole.Namespace = controllerNamespace
if err := apiClient.Create(f.ctx, namespaceRole); err != nil {
t.Fatal(err)
}
namespaceBinding := &rbacv1.RoleBinding{}
namespaceBinding.Name, namespaceBinding.Namespace = user, controllerNamespace
namespaceBinding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: namespaceRole.Name}
namespaceBinding.Subjects = binding.Subjects
if err := apiClient.Create(f.ctx, namespaceBinding); err != nil {
t.Fatal(err)
}
config := rest.CopyConfig(f.config)
config.Impersonate.UserName = user
restrictedClient, err := client.New(config, client.Options{Scheme: apiClient.Scheme()})
if err != nil {
t.Fatal(err)
}
secret := &corev1.Secret{}
err = restrictedClient.Get(f.ctx, client.ObjectKey{Namespace: unrelatedNamespace, Name: secretName}, secret)
if !apierrors.IsForbidden(err) {
t.Fatal("Instance controller 可以跨 namespace 读取 Secret")
}
return config
}
@@ -0,0 +1,58 @@
package postgresql
import (
"context"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
// 只读取当前执行角色的属性,不能从可继承的角色成员关系推导 CREATEDB/CREATEROLE。
// 所有事实来自同一条语句;不创建探针数据库,不修改管理账号或持久 schema。
const inspectManagementStatement = `
SELECT
pg_catalog.current_setting('server_version'),
ARRAY(SELECT name::text FROM pg_catalog.pg_available_extensions ORDER BY name),
role.rolsuper,
role.rolcreaterole,
role.rolcreatedb,
pg_catalog.pg_is_in_recovery() OR
pg_catalog.current_setting('transaction_read_only')::boolean
FROM pg_catalog.pg_roles AS role
WHERE role.rolname = current_user`
func (d *database) InspectManagement(ctx context.Context) (application.DatabaseMetadata, error) {
var metadata application.DatabaseMetadata
var superuser, createRole, createDatabase, readOnly bool
err := d.pool.QueryRow(ctx, inspectManagementStatement).Scan(
&metadata.Version, &metadata.AvailableExtensions,
&superuser, &createRole, &createDatabase, &readOnly,
)
if err != nil {
return application.DatabaseMetadata{}, safeError(err, application.ErrObservation)
}
checks := instance.ManagementChecks{
Connection: instance.CheckPassed,
Metadata: instance.CheckPassed,
Roles: nativePrivilege(createRole && !superuser),
Databases: nativePrivilege(createDatabase && !superuser),
// CREATEROLE 可管理自己新建角色的 membership;供应时必须显式取得 SET 权限,
// 再以 owner 操作数据库 ACL。这里不授权操作任意导入角色或他人数据库。
Grants: nativePrivilege(createRole && createDatabase && !superuser),
// 新建数据库 owner 可安装 trusted 扩展。具体扩展仍需逐请求执行和回读,
// 非 trusted 扩展不能因出现在 available 列表就视为可安装。
Extensions: nativePrivilege(createRole && createDatabase && !superuser),
}
if readOnly {
checks.Databases = instance.CheckUnavailable
}
metadata.Management = checks
return metadata, nil
}
func nativePrivilege(allowed bool) instance.CheckResult {
if allowed {
return instance.CheckPassed
}
return instance.CheckInsufficientPrivileges
}
@@ -0,0 +1,152 @@
//go:build integration
package postgresql_test
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5"
corev1 "k8s.io/api/core/v1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const nativeManager = "native_manager"
func useNativeManager(t *testing.T, f *credentialFixture) {
t.Helper()
f.queryPostgres(t, "CREATE ROLE native_manager LOGIN CREATEDB CREATEROLE PASSWORD '"+fixturePassword+"'")
f.createSecret(t, controllerNamespace)
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(nativeManager) })
}
func assessManagement(t *testing.T, f *credentialFixture) instance.Snapshot {
t.Helper()
observation, err := f.service.ObserveManagement(f.ctx, f.target)
if err != nil {
t.Fatal(err)
}
aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false)
if err != nil {
t.Fatal(err)
}
if err := aggregate.BeginValidation(); err != nil {
t.Fatal(err)
}
capabilities, err := observation.Capabilities()
if err != nil {
t.Fatal(err)
}
if err := aggregate.AssessManagement(capabilities); err != nil {
t.Fatal(err)
}
return aggregate.Snapshot()
}
func TestNativeManagementPrivileges(t *testing.T) {
f := newCredentialFixture(t)
useNativeManager(t, f)
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
t.Fatal("原生非 superuser 管理账号未通过检查")
}
before := f.backendIDs(t)
for _, attribute := range []string{"NOCREATEROLE", "NOCREATEDB"} {
f.queryPostgres(t, "ALTER ROLE native_manager "+attribute)
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
t.Fatal("已有连接忽略了管理权限撤回")
}
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE CREATEDB")
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
t.Fatal("管理权限恢复后无法重新就绪")
}
}
if f.backendIDs(t) != before {
t.Fatal("权限检查不应要求重建连接才生效")
}
f.queryPostgres(t, "ALTER ROLE native_manager SET default_transaction_read_only = on")
f.service.Forget(f.target.Identity().Name())
if snapshot := assessManagement(t, f); snapshot.Failure != instance.DependencyUnavailable {
t.Fatal("只读会话不应标记可供应")
}
f.queryPostgres(t, "ALTER ROLE native_manager RESET default_transaction_read_only")
f.service.Forget(f.target.Identity().Name())
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
t.Fatal("恢复可写会话后没有就绪")
}
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(fixtureUser) })
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
t.Fatal("不应以 superuser 绕过非特权账号合同")
}
reads := 0
f.gate.beforeRead = func() {
reads++
if reads == 2 {
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
}
}
observation, err := f.service.ObserveManagement(f.ctx, f.target)
if !errors.Is(err, application.ErrCredentialsChanged) || observation.Target().Validate() == nil {
t.Fatal("管理观察期间凭据轮换应丢弃全部能力结果")
}
if f.backendIDs(t) != "" {
t.Fatal("中途轮换后不应保留旧管理连接")
}
}
// 以实际非 superuser 会话验证能力矩阵的依据,不用超级用户执行 SQL 模拟管理账号。
// 这些固定名称只存在于本测试独占容器,生产观察本身不会创建探针对象。
func TestNativeManagementSupplyContract(t *testing.T) {
f := newCredentialFixture(t)
useNativeManager(t, f)
config, err := pgx.ParseConfig("")
if err != nil {
t.Fatal("无法装配隔离测试连接")
}
config.Host, config.Port = fixtureAddress, uint16(f.port)
config.Database, config.User, config.Password = fixtureUser, nativeManager, fixturePassword
config.TLSConfig, config.Fallbacks = nil, nil
connection, err := pgx.ConnectConfig(f.ctx, config)
if err != nil {
t.Fatal("非 superuser 测试连接失败")
}
t.Cleanup(func() { _ = connection.Close(context.Background()) })
execute := func(statement string) {
t.Helper()
if _, err := connection.Exec(f.ctx, statement); err != nil {
t.Fatalf("原生管理能力合同未满足,步骤 %q", statement)
}
}
execute("CREATE ROLE managed_owner LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION")
execute("GRANT managed_owner TO native_manager WITH SET TRUE")
execute("CREATE DATABASE managed_database OWNER managed_owner")
execute("SET ROLE managed_owner")
execute("REVOKE CONNECT ON DATABASE managed_database FROM PUBLIC")
execute("GRANT CONNECT ON DATABASE managed_database TO managed_owner")
execute("RESET ROLE")
config.Database = "managed_database"
tenantConnection, err := pgx.ConnectConfig(f.ctx, config)
if err != nil {
t.Fatal("管理账号无法访问其受管数据库")
}
defer func() { _ = tenantConnection.Close(context.Background()) }()
if _, err := tenantConnection.Exec(f.ctx, "SET ROLE managed_owner; CREATE EXTENSION hstore"); err != nil {
t.Fatal("owner 无法安装 trusted 扩展")
}
var installed bool
if err := tenantConnection.QueryRow(f.ctx, "SELECT EXISTS (SELECT FROM pg_catalog.pg_extension WHERE extname = 'hstore')").Scan(&installed); err != nil || !installed {
t.Fatal("扩展安装后实际回读失败")
}
if _, err := tenantConnection.Exec(f.ctx, "CREATE EXTENSION file_fdw"); err == nil {
t.Fatal("非 trusted 扩展不应被 Ready 隐式授权")
}
if err := tenantConnection.Close(f.ctx); err != nil {
t.Fatal("关闭目标数据库连接失败")
}
execute("SET ROLE managed_owner")
execute("DROP DATABASE managed_database")
execute("RESET ROLE")
execute("DROP ROLE managed_owner")
}
@@ -0,0 +1,48 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql
import (
"context"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
)
// 使用 pg_catalog 限定名称,避免管理账号的 search_path 改变查询来源。
// 一个语句读取版本和可用列表;ARRAY 子查询在无行时返回空数组,而非 NULL。
// 不查询 pg_extension:已安装集合不能代表服务器提供的全部扩展。
const inspectMetadataStatement = `
SELECT
pg_catalog.current_setting('server_version'),
ARRAY(
SELECT name::text
FROM pg_catalog.pg_available_extensions
ORDER BY name
)`
func (d *database) InspectMetadata(ctx context.Context) (application.DatabaseMetadata, error) {
var metadata application.DatabaseMetadata
err := d.pool.QueryRow(ctx, inspectMetadataStatement).Scan(
&metadata.Version,
&metadata.AvailableExtensions,
)
if err != nil {
// 不返回部分结果,也不把查询失败转换为“成功观察到空列表”。
return application.DatabaseMetadata{}, safeError(err, application.ErrObservation)
}
return metadata, nil
}
@@ -0,0 +1,116 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package postgresql_test
import (
"errors"
"testing"
corev1 "k8s.io/api/core/v1"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestMetadataObservesAvailableExtensionsWithoutInstalling(t *testing.T) {
f := newCredentialFixture(t)
f.createSecret(t, controllerNamespace)
if installed := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'hstore'"); installed != "0" {
t.Fatal("fixture unexpectedly has hstore installed")
}
// 提供同名遮蔽对象,验证 adapter 不依赖管理账号可修改的 search_path。
f.queryPostgres(t, "CREATE VIEW public.pg_available_extensions AS SELECT 'fake_extension'::name AS name")
f.queryPostgres(t, "ALTER ROLE postgres SET search_path = public, pg_catalog")
schemasBefore := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace")
observed, err := f.service.ObserveMetadata(f.ctx, f.target)
if err != nil {
t.Fatal(err)
}
if !observed.Target().Matches(f.target) || observed.Version() == "" {
t.Fatal("metadata was not bound to the current target")
}
requested := instance.NewExtensionSet([]string{"hstore", fixtureExtension})
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("available but uninstalled extension was omitted")
}
unsupported := instance.NewExtensionSet([]string{"fake_extension", "HSTORE"})
check := observed.Extensions().Check(unsupported)
if check.Decision != instance.ExtensionsUnsupported || len(check.Unsupported) != 2 {
t.Fatal("metadata accepted shadowed or case-normalized extension names")
}
if installed := f.queryPostgres(t, "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'hstore'"); installed != "0" {
t.Fatal("metadata observation installed an extension")
}
if schemas := f.queryPostgres(t, "SELECT string_agg(nspname, ',' ORDER BY nspname) FROM pg_catalog.pg_namespace"); schemas != schemasBefore {
t.Fatal("metadata observation changed database schemas")
}
aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false)
if err != nil {
t.Fatal(err)
}
if err := aggregate.ObserveExtensions(observed.Target(), observed.Extensions()); err != nil {
t.Fatal(err)
}
if aggregate.CheckExtensions(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("domain rejected observed extension availability")
}
if err := aggregate.RequireProvisioningReady(); err == nil {
t.Fatal("extension availability incorrectly authorized provisioning")
}
}
func TestMetadataPermissionFailureAndRecovery(t *testing.T) {
f := newCredentialFixture(t)
f.createSecret(t, controllerNamespace)
// 低权限账号也能读取可用列表;这不能证明具备 role/database/extension 管理权限。
f.queryPostgres(t, "CREATE ROLE metadata_reader LOGIN PASSWORD '"+fixturePassword+"'")
f.updateSecret(t, func(secret *corev1.Secret) {
secret.Data["login"] = []byte("metadata_reader")
})
if flags := f.queryPostgres(t, "SELECT rolsuper, rolcreaterole, rolcreatedb FROM pg_catalog.pg_roles WHERE rolname = 'metadata_reader'"); flags != "f|f|f" {
t.Fatal("metadata reader unexpectedly has management privileges")
}
requested := instance.NewExtensionSet([]string{fixtureExtension})
observed, err := f.service.ObserveMetadata(f.ctx, f.target)
if err != nil || observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatalf("read-only account could not observe metadata: %v", err)
}
// 仅操作本测试独占容器的 catalog ACL;失败不能转换成“不支持任何扩展”。
f.queryPostgres(t, "REVOKE SELECT ON pg_catalog.pg_available_extensions FROM PUBLIC")
failed, err := f.service.ObserveMetadata(f.ctx, f.target)
if !errors.Is(err, application.ErrObservation) {
t.Fatalf("metadata permission failure was not reported safely: %v", err)
}
if failed.Version() != "" || failed.Target().Validate() == nil {
t.Fatal("permission failure returned partial metadata")
}
if failed.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
t.Fatal("permission failure returned an observed empty set")
}
if f.backendIDs(t) != "" {
t.Fatal("failed metadata connection was retained")
}
f.queryPostgres(t, "GRANT SELECT ON pg_catalog.pg_available_extensions TO PUBLIC")
recovered, err := f.service.ObserveMetadata(f.ctx, f.target)
if err != nil || recovered.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatalf("metadata observation did not recover: %v", err)
}
}
@@ -107,7 +107,7 @@ func TestPostgreSQLTLSHostIdentity(t *testing.T) {
if err != nil {
t.Fatal("trusted DNS SAN connection failed", err)
}
if version, err := db.Version(ctx); err != nil || version == "" {
if metadata, err := db.InspectMetadata(ctx); err != nil || metadata.Version == "" {
db.Close()
t.Fatal("TLS metadata read failed", err)
}
@@ -0,0 +1,116 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import (
"crypto/rand"
"encoding/base64"
"errors"
"regexp"
"strconv"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var ErrApplicationCredentialInvalid = errors.New("application credential is invalid")
var applicationIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
// ApplicationCredential 是内存中的应用连接凭据,不得放入 CR 或普通日志。
// 它与 Instance 管理凭据分开,固定输出交付合同中的七键,不生成带密码的 URI。
type ApplicationCredential struct {
username string
password string
database string
endpoint instance.Endpoint
}
func NewApplicationCredential(username, password, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
if !applicationIdentifier.MatchString(username) || !applicationIdentifier.MatchString(database) || password == "" {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
if endpoint.Validate() != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
return ApplicationCredential{
username: username,
password: password,
database: database,
endpoint: endpoint,
}, nil
}
// GenerateApplicationCredential 仅供已获准首次创建凭据的供应步骤调用。
// 不能在读取失败、写入结果不确定或重启后无条件重新调用。
func GenerateApplicationCredential(username, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
password := make([]byte, 32)
rand.Read(password)
return NewApplicationCredential(username, base64.RawURLEncoding.EncodeToString(password), database, endpoint)
}
func (c ApplicationCredential) String() string { return "[redacted application credential]" }
func (c ApplicationCredential) GoString() string { return c.String() }
func (c ApplicationCredential) MarshalJSON() ([]byte, error) {
return []byte(`"[redacted application credential]"`), nil
}
// SecretData 只在凭据后端或数据库连接边界使用;返回值包含明文密码,禁止记录日志。
// 每次返回独立 map,调用方不能修改已经构造的凭据。
func (c ApplicationCredential) SecretData() map[string]any {
endpoint := c.endpoint.Values()
return map[string]any{
"username": c.username,
"password": c.password,
"database": c.database,
"host": endpoint.Host,
"hostaddr": endpoint.HostAddr,
"port": strconv.Itoa(endpoint.Port),
"sslmode": string(endpoint.TLSMode),
}
}
func (c ApplicationCredential) Validate() error {
_, err := NewApplicationCredential(c.username, c.password, c.database, c.endpoint)
return err
}
// ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。
func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) {
values := make(map[string]string, 7)
for _, key := range []string{"username", "password", "database", "host", "hostaddr", "port", "sslmode"} {
value, ok := data[key].(string)
if !ok || value == "" {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
values[key] = value
}
port, err := strconv.Atoi(values["port"])
if err != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: values["host"],
HostAddr: values["hostaddr"],
Port: port,
ManagementDatabase: values["database"],
TLSMode: instance.TLSMode(values["sslmode"]),
})
if err != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
return NewApplicationCredential(values["username"], values["password"], values["database"], endpoint)
}
@@ -0,0 +1,81 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application_test
import (
"encoding/json"
"fmt"
"maps"
"strings"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestApplicationCredential(t *testing.T) {
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432,
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
})
if err != nil {
t.Fatal(err)
}
first, err := application.GenerateApplicationCredential("owner", "app", endpoint)
if err != nil {
t.Fatal(err)
}
second, err := application.GenerateApplicationCredential("owner", "app", endpoint)
if err != nil {
t.Fatal(err)
}
data := first.SecretData()
if len(data) != 7 || data["password"] == second.SecretData()["password"] || len(data["password"].(string)) != 43 {
t.Fatal("expected seven keys and independent 256-bit passwords")
}
parsed, err := application.ParseApplicationCredential(data)
if err != nil || !maps.Equal(parsed.SecretData(), data) {
t.Fatal("credential did not round trip")
}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatal(err)
}
for _, output := range []string{fmt.Sprint(first), fmt.Sprintf("%+v", first), fmt.Sprintf("%#v", first), string(encoded)} {
if strings.Contains(output, data["password"].(string)) {
t.Fatal("credential formatting leaked the password")
}
}
data["password"] = "changed"
if first.SecretData()["password"] == "changed" {
t.Fatal("caller mutated credential")
}
for key := range data {
invalid := maps.Clone(data)
delete(invalid, key)
if _, err := application.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted missing %s", key)
}
invalid[key] = 42
if _, err := application.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted non-string %s", key)
}
}
if (application.ApplicationCredential{}).Validate() == nil {
t.Fatal("accepted zero credential")
}
}
@@ -0,0 +1,170 @@
package application
import (
"context"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
)
// 快照版本只用于协调读写,不进入领域规则。Generation 用于确认整个申请未在回读期间变化。
type BindingTenant struct {
binding.Tenant
Revision string
Generation int64
}
type BindingDatabase struct {
binding.Database
Revision string
}
// BindingResources 是这个用例所需的操作,不是通用 Repository 或跨系统事务接口。
// 查不到对象时返回 nil;写入必须检查传入快照的版本,不能覆盖并发修改。
type BindingResources interface {
Tenant(context.Context, string, string) (*BindingTenant, error)
Database(context.Context, string) (*BindingDatabase, error)
Instance(context.Context, string) (*binding.Instance, error)
BeginBinding(context.Context, *BindingTenant, *BindingStatus) (*BindingTenant, error)
CreateDatabase(context.Context, binding.Target, binding.TenantIdentity) (*BindingDatabase, error)
RecordInstance(context.Context, *BindingDatabase, string) (*BindingDatabase, error)
BindDatabase(context.Context, *BindingDatabase, binding.TenantIdentity) (*BindingDatabase, error)
}
// BindingStatus 是用例结果,资源呈现层决定如何写成 Conditions/status。
type BindingStatus struct {
Phase string
Reason string
Message string
Database *binding.Identity
}
type BindingResult struct {
Tenant *BindingTenant
Status BindingStatus
RetrySoon bool
}
type BindingService struct {
Resources BindingResources
}
func (s BindingService) Reconcile(ctx context.Context, namespace, name string) (BindingResult, error) {
tenant, err := s.Resources.Tenant(ctx, namespace, name)
if err != nil || tenant == nil {
return BindingResult{}, err
}
if tenant.Deleting {
return bindingResult(tenant, binding.Deleting, "DeletionPending",
"删除清理尚未接入;保留 finalizer 和 Database 绑定,未执行后端删除"), nil
}
target, err := tenant.Request.Resolve(tenant.Identity)
if err != nil {
return bindingResult(tenant, tenant.Phase, "InvalidRequest", err.Error()), nil
}
// 持久固定申请,再创建/绑定资源;不是预先宣告双向绑定成功。
var checkpoint *BindingStatus
if tenant.Phase != binding.Binding && tenant.Phase != binding.Bound {
checkpoint = &BindingStatus{Phase: binding.Binding, Reason: "BindingPending", Message: "申请目标已固定,等待资源侧绑定"}
}
tenant, err = s.Resources.BeginBinding(ctx, tenant, checkpoint)
if err != nil {
return BindingResult{}, err
}
database, issue, err := s.resolveDatabase(ctx, tenant, target)
if err != nil {
return BindingResult{}, err
}
if issue != nil {
return bindingResult(tenant, tenant.Phase, issue.Reason, issue.Message), nil
}
if issue := database.CanBind(tenant.Tenant); issue != nil {
return bindingResult(tenant, tenant.Phase, issue.Reason, issue.Message), nil
}
database, err = s.Resources.BindDatabase(ctx, database, tenant.Identity)
if err != nil {
return BindingResult{}, err
}
return s.confirmBinding(ctx, tenant, database)
}
func (s BindingService) resolveDatabase(ctx context.Context, tenant *BindingTenant, target binding.Target) (
*BindingDatabase, *binding.Issue, error,
) {
database, err := s.Resources.Database(ctx, target.Name)
if err != nil {
return nil, nil, err
}
if database == nil && target.Provision == nil {
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "指定的 Database 尚不存在,等待资源出现"}, nil
}
instanceName := ""
var observed *binding.Database
if database != nil {
instanceName, observed = database.Instance, &database.Database
}
if target.Provision != nil {
instanceName = target.Provision.Instance
if database != nil && !database.MatchesProvision(target, tenant.Identity) {
return nil, &binding.Issue{Reason: binding.Conflict,
Message: "动态 Database 名称已存在,但目标或 Tenant UID 不匹配;请核实记录,未自动认领"}, nil
}
}
instance, err := s.Resources.Instance(ctx, instanceName)
if err != nil {
return nil, nil, err
}
if instance == nil {
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "引用的 Instance 尚不存在"}, nil
}
if issue := instance.Check(observed); issue != nil {
return nil, issue, nil
}
if database == nil {
database, err = s.Resources.CreateDatabase(ctx, target, tenant.Identity)
if err != nil {
return nil, nil, err
}
}
// 只有首次动态记录可以补入实例身份;导入必须先有资源观察。
if database.InstanceUID == "" && target.Provision != nil {
database, err = s.Resources.RecordInstance(ctx, database, instance.Identity.UID)
if err != nil {
return nil, nil, err
}
}
if database.InstanceUID == "" {
return nil, &binding.Issue{Reason: binding.DependencyUnavailable, Message: "Database 尚未完成实例身份验证"}, nil
}
return database, nil, nil
}
func (s BindingService) confirmBinding(ctx context.Context, tenant *BindingTenant, database *BindingDatabase) (BindingResult, error) {
latest, err := s.Resources.Tenant(ctx, tenant.Identity.Namespace, tenant.Identity.Name)
if err != nil || latest == nil {
return BindingResult{}, err
}
if latest.Identity != tenant.Identity || latest.Deleting {
return BindingResult{}, nil
}
if latest.Generation != tenant.Generation {
return BindingResult{RetrySoon: true}, nil
}
observed, err := s.Resources.Database(ctx, database.Identity.Name)
if err != nil {
return BindingResult{}, err
}
if observed == nil || observed.Identity != database.Identity || observed.Tenant == nil {
return bindingResult(latest, latest.Phase, binding.Conflict, "资源侧身份或绑定已变化,未完成申请侧绑定"), nil
}
if issue := observed.CanBind(latest.Tenant); issue != nil {
return bindingResult(latest, latest.Phase, issue.Reason, issue.Message), nil
}
result := bindingResult(latest, binding.Bound, "BindingComplete",
"双向绑定已记录;尚未执行供应、应用登录验证或凭据交付,不能 Ready")
result.Status.Database = &observed.Identity
return result, nil
}
func bindingResult(tenant *BindingTenant, phase, reason, message string) BindingResult {
return BindingResult{Tenant: tenant, Status: BindingStatus{Phase: phase, Reason: reason, Message: message}}
}
@@ -0,0 +1,181 @@
package application
import (
"context"
"errors"
"reflect"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
)
const (
bindingReadTenant = "tenant"
bindingBegin = "begin"
bindingReadInstance = "instance"
bindingCreate = "create"
bindingRecordInstance = "record-instance"
bindingWriteResource = "bind"
bindingDatabaseOperation = "database"
bindingTestNamespace = "apps"
bindingTestName = "app"
bindingTestInstance = "shared"
)
func TestBindingServiceOrder(t *testing.T) {
resources := bindingFixture()
service := BindingService{Resources: resources}
result, err := service.Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if err != nil {
t.Fatal(err)
}
want := []string{bindingReadTenant, bindingBegin, bindingDatabaseOperation, bindingReadInstance, bindingCreate, bindingRecordInstance, bindingWriteResource, bindingReadTenant, bindingDatabaseOperation}
if !reflect.DeepEqual(resources.calls, want) {
t.Fatalf("协调顺序 = %v, want %v", resources.calls, want)
}
if result.Status.Phase != binding.Bound || result.Status.Database == nil || result.Status.Database.UID != "database-uid" {
t.Fatalf("绑定结果不符: %+v", result.Status)
}
if resources.tenant.Phase != binding.Binding || resources.tenant.Database != nil {
t.Fatal("service 只能返回待呈现结果,不能提前写申请侧绑定")
}
if resources.database.Tenant == nil || *resources.database.Tenant != resources.tenant.Identity {
t.Fatal("返回完成结果之前必须写入资源侧绑定")
}
}
func TestBindingServiceStopsOnIOFailure(t *testing.T) {
for _, operation := range []string{bindingReadTenant, bindingBegin, bindingDatabaseOperation, bindingReadInstance, bindingCreate, bindingRecordInstance, bindingWriteResource} {
t.Run(operation, func(t *testing.T) {
resources := bindingFixture()
resources.failAt = operation
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if !errors.Is(err, errBindingTest) || result.Status.Database != nil {
t.Fatalf("IO 失败不应被转换为绑定成功: result=%+v, err=%v", result, err)
}
if resources.calls[len(resources.calls)-1] != operation {
t.Fatalf("失败后继续执行了操作: %v", resources.calls)
}
})
}
}
func TestBindingServiceDoesNotWriteWhenDeletingOrInvalid(t *testing.T) {
for _, deleting := range []bool{false, true} {
resources := bindingFixture()
resources.tenant.Deleting = deleting
resources.tenant.Request = binding.Request{}
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if err != nil || len(resources.calls) != 1 || result.Status.Database != nil {
t.Fatalf("删除或无效申请不应触及资源: calls=%v, err=%v", resources.calls, err)
}
want := "InvalidRequest"
if deleting {
want = "DeletionPending"
}
if result.Status.Reason != want {
t.Fatalf("Reason = %s, want %s", result.Status.Reason, want)
}
}
}
func TestBindingServiceConfirmsIdentityAgain(t *testing.T) {
resources := bindingFixture()
resources.replaceOnReadback = true
result, err := (BindingService{Resources: resources}).Reconcile(t.Context(), bindingTestNamespace, bindingTestName)
if err != nil {
t.Fatal(err)
}
if result.Status.Reason != binding.Conflict || result.Status.Database != nil {
t.Fatal("资源侧写入后发生身份变化时不能返回绑定完成")
}
}
// 这里只记录用例操作,不模拟 Kubernetes 校验;真实 IO 契约由 controller envtest 覆盖。
type bindingTestResources struct {
tenant *BindingTenant
database *BindingDatabase
instance *binding.Instance
calls []string
failAt string
replaceOnReadback bool
bound bool
}
var errBindingTest = errors.New("injected resource operation failure")
func bindingFixture() *bindingTestResources {
tenant := &BindingTenant{Generation: 1}
tenant.Tenant = binding.Tenant{
Identity: binding.TenantIdentity{Namespace: bindingTestNamespace, Name: bindingTestName, UID: "tenant-uid"},
Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: bindingTestInstance}},
}
return &bindingTestResources{
tenant: tenant,
instance: &binding.Instance{Identity: binding.Identity{Name: bindingTestInstance, UID: "instance-uid"}, Ready: true},
}
}
func (r *bindingTestResources) record(operation string) error {
r.calls = append(r.calls, operation)
if r.failAt == operation {
return errBindingTest
}
return nil
}
func (r *bindingTestResources) Tenant(context.Context, string, string) (*BindingTenant, error) {
return r.tenant, r.record(bindingReadTenant)
}
func (r *bindingTestResources) Database(context.Context, string) (*BindingDatabase, error) {
if r.bound && r.replaceOnReadback {
replaced := *r.database
replaced.Identity.UID = "replacement"
return &replaced, r.record(bindingDatabaseOperation)
}
return r.database, r.record(bindingDatabaseOperation)
}
func (r *bindingTestResources) Instance(context.Context, string) (*binding.Instance, error) {
return r.instance, r.record(bindingReadInstance)
}
func (r *bindingTestResources) BeginBinding(_ context.Context, tenant *BindingTenant, status *BindingStatus) (*BindingTenant, error) {
if err := r.record(bindingBegin); err != nil {
return nil, err
}
if status != nil {
r.tenant.Phase = status.Phase
}
return tenant, nil
}
func (r *bindingTestResources) CreateDatabase(_ context.Context, target binding.Target, tenant binding.TenantIdentity) (*BindingDatabase, error) {
if err := r.record(bindingCreate); err != nil {
return nil, err
}
r.database = &BindingDatabase{}
r.database.Database = binding.Database{
Identity: binding.Identity{Name: target.Name, UID: "database-uid"}, Tenant: &tenant,
Instance: target.Provision.Instance, Name: target.Provision.Database, LoginRole: target.Provision.LoginRole, Source: "Provision",
}
return r.database, nil
}
func (r *bindingTestResources) RecordInstance(_ context.Context, database *BindingDatabase, uid string) (*BindingDatabase, error) {
if err := r.record(bindingRecordInstance); err != nil {
return nil, err
}
database.InstanceUID = uid
return database, nil
}
func (r *bindingTestResources) BindDatabase(_ context.Context, database *BindingDatabase, tenant binding.TenantIdentity) (*BindingDatabase, error) {
if err := r.record(bindingWriteResource); err != nil {
return nil, err
}
database.Tenant = &tenant
r.bound = true
return database, nil
}
@@ -0,0 +1,149 @@
package application
import (
"context"
"errors"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const (
instanceDependencyUnavailable = "DependencyUnavailable"
instanceAuthenticationFailed = "AuthenticationFailed"
)
// InstanceRecord 是 API 快照;Revision 仅用于持久化并发保护,不是领域版本。
type InstanceRecord struct {
Target instance.ObservationTarget
Revision string
Deleting bool
}
type InstanceResources interface {
LoadInstance(context.Context, string) (*InstanceRecord, error)
ProtectInstance(context.Context, *InstanceRecord) (*InstanceRecord, error)
// InstanceReferences 返回一个可定位的阻塞引用;空字符串表示没有引用。
InstanceReferences(context.Context, string) (string, error)
}
type InstanceObserver interface {
ObserveManagement(context.Context, instance.ObservationTarget) (InstanceObservation, error)
Forget(string)
}
type InstanceResult struct {
Record *InstanceRecord
Snapshot instance.Snapshot
Reason string
Message string
RemoveProtection bool
}
// InstanceReconciliation 协调 API 保护、实时观察和领域判断,不拼装 Kubernetes status。
type InstanceReconciliation struct {
Resources InstanceResources
Observer InstanceObserver
}
func (s *InstanceReconciliation) Reconcile(ctx context.Context, name string) (InstanceResult, error) {
record, err := s.Resources.LoadInstance(ctx, name)
if err != nil {
return InstanceResult{}, err
}
if record == nil {
s.Observer.Forget(name)
return InstanceResult{}, nil
}
if record.Deleting {
return s.deleting(ctx, record)
}
record, err = s.Resources.ProtectInstance(ctx, record)
if err != nil {
return InstanceResult{}, err
}
// 每轮从无证据的领域对象开始;持久化 Ready 和连接存活不能替代本轮检查。
aggregate, err := instance.Reconstitute(record.Target, instance.Snapshot{}, false)
if err != nil {
return InstanceResult{}, err
}
if err := aggregate.BeginValidation(); err != nil {
return InstanceResult{}, err
}
observation, observationErr := s.Observer.ObserveManagement(ctx, record.Target)
result := InstanceResult{Record: record}
if observationErr != nil {
result.Snapshot = aggregate.Snapshot()
result.Snapshot.Readiness = instance.NotReady
result.Snapshot.ObservedRevision = record.Target.Revision().Value()
result.Reason, result.Message = observationFailure(observationErr)
return result, nil
}
capabilities, err := observation.Capabilities()
if err != nil {
return InstanceResult{}, err
}
if err := aggregate.AssessManagement(capabilities); err != nil {
return InstanceResult{}, err
}
result.Snapshot = aggregate.Snapshot()
result.Snapshot.ReportedVersion = observation.Version()
result.Reason, result.Message = managementResult(result.Snapshot.Failure)
return result, nil
}
func (s *InstanceReconciliation) deleting(ctx context.Context, record *InstanceRecord) (InstanceResult, error) {
name := record.Target.Identity().Name()
s.Observer.Forget(name)
aggregate, err := instance.Reconstitute(record.Target, instance.Snapshot{}, true)
if err != nil {
return InstanceResult{}, err
}
if err := aggregate.BeginDeletion(); err != nil {
return InstanceResult{}, err
}
result := InstanceResult{Record: record, Snapshot: aggregate.Snapshot(), Reason: "Deleting"}
reference, err := s.Resources.InstanceReferences(ctx, name)
if err != nil {
result.Reason = instanceDependencyUnavailable
result.Message = "无法确认 Database/Tenant 引用已解除;保留 Instance 删除保护并重试"
return result, nil
}
if reference != "" {
result.Reason = "InstanceInUse"
result.Message = "仍被 " + reference + " 引用;先处理该资源,不会级联删除外部数据库"
return result, nil
}
result.Message = "引用已解除,仅移除登记保护;不删除 PostgreSQL 或凭据"
result.RemoveProtection = true
return result, nil
}
func observationFailure(err error) (string, string) {
switch {
case errors.Is(err, ErrAuthentication):
return instanceAuthenticationFailed, "管理连接认证或 TLS 校验失败;检查管理 Secret 和 CA/证书配置"
case errors.Is(err, ErrCredentialsInvalid):
return "InvalidCredentials", "管理 Secret 的用户名或密码字段缺失;检查引用字段映射"
case errors.Is(err, ErrCredentialsChanged):
return "CredentialsChanged", "观察期间管理凭据变化,已丢弃结果并关闭旧连接;等待重新验证"
case errors.Is(err, ErrCredentialsUnavailable):
return instanceDependencyUnavailable, "无法读取管理 Secret;检查其是否存在及 controller namespace 内的读取权限"
default:
return instanceDependencyUnavailable, "管理连接或能力查询失败;检查 PostgreSQL 可达性、catalog 读取权限和超时"
}
}
func managementResult(failure instance.Failure) (string, string) {
switch failure {
case instance.NoFailure:
return "ManagementReady", "当前管理能力检查通过;具体资源授权和扩展安装仍需执行时验证"
case instance.InsufficientPrivileges:
return "InsufficientPrivileges", "原生管理要求非 superuser 且具备 CREATEDB/CREATEROLE;不会自动修改账号权限"
case instance.DependencyUnavailable:
return instanceDependencyUnavailable, "当前 PostgreSQL 不可写或所需管理能力暂不可用"
case instance.AuthenticationFailed:
return instanceAuthenticationFailed, "当前管理能力检查未通过认证"
default:
return "ObservationIncomplete", "管理能力检查尚有缺项,不能仅凭 metadata 查询成功标记 Ready"
}
}
@@ -0,0 +1,79 @@
package application
import (
"context"
"errors"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestInstanceFailurePresentation(t *testing.T) {
cases := []struct {
err error
reason string
}{
{ErrAuthentication, "AuthenticationFailed"},
{ErrCredentialsInvalid, "InvalidCredentials"},
{ErrCredentialsChanged, "CredentialsChanged"},
{ErrCredentialsUnavailable, instanceDependencyUnavailable},
{context.DeadlineExceeded, instanceDependencyUnavailable},
{errors.New("private backend detail"), instanceDependencyUnavailable},
}
for _, test := range cases {
reason, message := observationFailure(test.err)
if reason != test.reason || message == "" || message == test.err.Error() {
t.Fatal("观察失败没有安全且可诊断的状态")
}
}
for _, failure := range []instance.Failure{
instance.NoFailure, instance.ObservationIncomplete, instance.DependencyUnavailable,
instance.AuthenticationFailed, instance.InsufficientPrivileges,
} {
reason, message := managementResult(failure)
if reason == "" || message == "" || (reason == "ManagementReady") != (failure == instance.NoFailure) {
t.Fatal("领域能力判定与状态不一致")
}
}
}
func TestMetadataCannotEstablishManagementReadiness(t *testing.T) {
source := &sourceStub{}
source.credentials, _ = NewCredentials("test", serviceTestPassword)
connector := &connectorStub{}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
target := serviceTarget(t, "uid", "postgres.test", "management", 1)
if _, err := service.ObserveMetadata(t.Context(), target); err != nil {
t.Fatal(err)
}
connector.databases[0].metadata.Management = instance.ManagementChecks{
Connection: instance.CheckPassed, Metadata: instance.CheckPassed,
Roles: instance.CheckPassed, Databases: instance.CheckPassed,
Grants: instance.CheckPassed, Extensions: instance.CheckPassed,
}
observation, err := service.ObserveMetadata(t.Context(), target)
if err != nil {
t.Fatal(err)
}
capabilities, err := observation.Capabilities()
if err != nil {
t.Fatal(err)
}
aggregate, err := instance.Reconstitute(target, instance.Snapshot{}, false)
if err != nil {
t.Fatal(err)
}
if err := aggregate.BeginValidation(); err != nil {
t.Fatal(err)
}
if err := aggregate.AssessManagement(capabilities); err != nil {
t.Fatal(err)
}
if aggregate.Snapshot().Failure != instance.ObservationIncomplete {
t.Fatal("metadata 入口不应携带完整管理检查")
}
}
@@ -33,9 +33,10 @@ var (
)
// Database 与 Connector 复用原项目 internal/instance/service.go 的能力边界。
// 版本查询只是本切片的连通性观察,不能产生领域 Ready。
// Metadata 只查询版本与可用扩展,不能产生领域 Ready。
type Database interface {
Version(context.Context) (string, error)
InspectMetadata(context.Context) (DatabaseMetadata, error)
InspectManagement(context.Context) (DatabaseMetadata, error)
Close()
}
@@ -74,19 +75,34 @@ func NewInstanceService(source CredentialReader, connector Connector) (*Instance
func (s *InstanceService) String() string { return "[redacted instance service]" }
func (s *InstanceService) GoString() string { return s.String() }
// ObserveVersion 返回当前目标和凭据下的版本;任何失败均返回空结果。
// 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。
// ObserveVersion 是完整 metadata 读取的便捷入口,不再维护另一条连接或查询路径。
func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.ObservationTarget) (string, error) {
observation, err := s.ObserveMetadata(ctx, target)
return observation.Version(), err
}
// ObserveMetadata 返回当前目标和凭据下的版本与扩展;任何失败均丢弃全部结果。
// 调用者仍需使用 CR resourceVersion 保存前提防止 spec 并发修改;本方法不建立跨系统事务。
func (s *InstanceService) ObserveMetadata(ctx context.Context, target instance.ObservationTarget) (InstanceObservation, error) {
return s.observe(ctx, target, false)
}
// ObserveManagement 复用同一凭据刷新与回读边界,但每轮重新检查原生管理能力。
func (s *InstanceService) ObserveManagement(ctx context.Context, target instance.ObservationTarget) (InstanceObservation, error) {
return s.observe(ctx, target, true)
}
func (s *InstanceService) observe(ctx context.Context, target instance.ObservationTarget, management bool) (InstanceObservation, error) {
if err := target.Validate(); err != nil {
return "", err
return InstanceObservation{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return "", ErrClosed
return InstanceObservation{}, ErrClosed
}
if err := ctx.Err(); err != nil {
return "", err
return InstanceObservation{}, err
}
// 先读取有效凭据。读取失败时不得继续使用缓存中的旧连接。
@@ -94,11 +110,11 @@ func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.Ob
credentials, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return "", credentialError(err)
return InstanceObservation{}, credentialError(err)
}
if credentials.username == "" || credentials.password == "" {
s.release(name)
return "", ErrCredentialsInvalid
return InstanceObservation{}, ErrCredentialsInvalid
}
// 连接身份与有效值均未变化时复用 pgxpool;generation 本身不要求换池。
@@ -111,7 +127,7 @@ func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.Ob
if current == nil {
database, err := s.connector.Connect(ctx, target.Definition().Endpoint(), credentials)
if err != nil {
return "", err
return InstanceObservation{}, err
}
current = &entry{
target: target,
@@ -121,23 +137,39 @@ func (s *InstanceService) ObserveVersion(ctx context.Context, target instance.Ob
s.entries[name] = current
}
version, err := current.database.Version(ctx)
var metadata DatabaseMetadata
if management {
metadata, err = current.database.InspectManagement(ctx)
} else {
metadata, err = current.database.InspectMetadata(ctx)
// 即使 adapter 误填权限,也不能把只读 metadata 入口升级为 Ready。
metadata.Management = instance.ManagementChecks{}
}
if err != nil {
s.release(name)
return "", err
return InstanceObservation{}, err
}
if metadata.Version == "" {
s.release(name)
return InstanceObservation{}, ErrObservation
}
// 回读后再检查凭据,避免把轮换前取得的结果交给新凭据的调用链。
latest, err := s.source.Read(ctx, target.Definition().AdminCredential())
if err != nil {
s.release(name)
return "", credentialError(err)
return InstanceObservation{}, credentialError(err)
}
if latest != credentials {
s.release(name)
return "", ErrCredentialsChanged
return InstanceObservation{}, ErrCredentialsChanged
}
return version, nil
return InstanceObservation{
target: target,
version: metadata.Version,
extensions: instance.ObserveExtensionSupport(metadata.AvailableExtensions),
management: metadata.Management,
}, nil
}
func credentialError(err error) error {
@@ -147,7 +179,7 @@ func credentialError(err error) error {
return ErrCredentialsUnavailable
}
// Forget 只释放本地连接;不删除数据库或 registry,不替代 Instance finalizer。
// Forget 只释放本地连接;不删除数据库,不替代 Instance finalizer。
func (s *InstanceService) Forget(name string) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -24,6 +24,8 @@ import (
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
const serviceTestPassword = "test-only"
// 延续源项目 Service 测试,用于穷举身份与装配失败;真实行为由 adapter 集成测试验证。
type sourceStub struct {
credentials Credentials
@@ -37,9 +39,16 @@ func (s *sourceStub) Read(context.Context, instance.CredentialReference) (Creden
type databaseStub struct {
closes int
err error
metadata DatabaseMetadata
}
func (d *databaseStub) Version(context.Context) (string, error) { return "17", d.err }
func (d *databaseStub) InspectManagement(ctx context.Context) (DatabaseMetadata, error) {
return d.InspectMetadata(ctx)
}
func (d *databaseStub) InspectMetadata(context.Context) (DatabaseMetadata, error) {
return d.metadata, d.err
}
func (d *databaseStub) Close() {
d.closes++
}
@@ -53,7 +62,12 @@ func (c *connectorStub) Connect(context.Context, instance.Endpoint, Credentials)
if c.err != nil {
return nil, c.err
}
db := &databaseStub{}
db := &databaseStub{
metadata: DatabaseMetadata{
Version: "17",
AvailableExtensions: []string{"plpgsql"},
},
}
c.databases = append(c.databases, db)
return db, nil
}
@@ -98,7 +112,7 @@ func serviceTarget(t *testing.T, uid, host, secret string, generation int64) ins
}
func TestInstanceConnectionIdentity(t *testing.T) {
source := &sourceStub{credentials: Credentials{username: testUsername, password: "test-only"}}
source := &sourceStub{credentials: Credentials{username: testUsername, password: serviceTestPassword}}
connector := &connectorStub{}
service, err := NewInstanceService(source, connector)
if err != nil {
@@ -118,9 +132,13 @@ func TestInstanceConnectionIdentity(t *testing.T) {
{"same name with new UID", serviceTarget(t, "uid-2", "second", "replacement", 1), 4},
}
for _, testCase := range cases {
if _, err := service.ObserveVersion(ctx, testCase.target); err != nil {
observation, err := service.ObserveMetadata(ctx, testCase.target)
if err != nil {
t.Fatal(err)
}
if !observation.Target().Matches(testCase.target) {
t.Fatalf("%s: observation was bound to a previous target", testCase.name)
}
if len(connector.databases) != testCase.wantConnections {
t.Fatalf("%s: got %d connections, want %d", testCase.name, len(connector.databases), testCase.wantConnections)
}
@@ -137,11 +155,64 @@ func TestInstanceConnectionIdentity(t *testing.T) {
}
}
func TestMetadataObservationFreshnessAndFailure(t *testing.T) {
source := &sourceStub{credentials: Credentials{username: testUsername, password: serviceTestPassword}}
connector := &connectorStub{}
service, err := NewInstanceService(source, connector)
if err != nil {
t.Fatal(err)
}
defer service.Close()
ctx := context.Background()
target := serviceTarget(t, "metadata-uid", "first", "admin", 1)
observed, err := service.ObserveMetadata(ctx, target)
if err != nil || observed.Version() != "17" || !observed.Target().Matches(target) {
t.Fatalf("metadata observation: %v", err)
}
requested := instance.NewExtensionSet([]string{"plpgsql"})
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("extension list was not observed")
}
// 连接可以复用,但每轮必须重新查询;旧观察还必须与 adapter 的可变 slice 脱离。
database := connector.databases[0]
database.metadata.AvailableExtensions[0] = "replacement"
if observed.Extensions().Check(requested).Decision != instance.ExtensionsAccepted {
t.Fatal("adapter mutation changed a completed observation")
}
refreshed, err := service.ObserveMetadata(ctx, target)
if err != nil || refreshed.Extensions().Check(requested).Decision != instance.ExtensionsUnsupported {
t.Fatalf("extension list was cached across observations: %v", err)
}
database.metadata.AvailableExtensions = nil
empty, err := service.ObserveMetadata(ctx, target)
if err != nil || empty.Extensions().Check(requested).Decision != instance.ExtensionsUnsupported {
t.Fatalf("successful empty list was treated as unobserved: %v", err)
}
// 即使 adapter 附带部分数据,错误仍使整个观察失效。
database.err = ErrObservation
failed, err := service.ObserveMetadata(ctx, target)
if !errors.Is(err, ErrObservation) || failed.Version() != "" || failed.Target().Validate() == nil {
t.Fatal("failed query returned a bound observation")
}
if failed.Extensions().Check(requested).Decision != instance.ExtensionSupportUnobserved {
t.Fatal("failed query was interpreted as an empty extension list")
}
if _, err := service.ObserveMetadata(ctx, target); err != nil {
t.Fatalf("retry after query failure: %v", err)
}
connector.databases[1].metadata.Version = ""
if _, err := service.ObserveMetadata(ctx, target); !errors.Is(err, ErrObservation) {
t.Fatal("missing server version was accepted as complete metadata")
}
}
func TestInstanceAssemblyFailureRecovery(t *testing.T) {
ctx := context.Background()
target := serviceTarget(t, "uid-1", "first", "admin", 1)
source := &sourceStub{
credentials: Credentials{username: testUsername, password: "test-only"},
credentials: Credentials{username: testUsername, password: serviceTestPassword},
err: errors.New("unsafe source error"),
}
connector := &connectorStub{err: ErrConnection}
+48
View File
@@ -0,0 +1,48 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import "git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
// DatabaseMetadata 是一次只读查询的事实,可附带原生管理检查,但不包含就绪结论。
// AvailableExtensions 是服务器提供的可用列表,不是已安装列表或安装授权。
type DatabaseMetadata struct {
Version string
AvailableExtensions []string
// Management 仅由 InspectManagement 填充;metadata 查询必须保持未观察。
Management instance.ManagementChecks
}
// InstanceObservation 只在查询成功且有效凭据再次核对一致后产生。
// target 绑定本次调用,而非连接最初创建时的 generation;零值表示没有观察。
type InstanceObservation struct {
target instance.ObservationTarget
version string
extensions instance.ExtensionSupport
management instance.ManagementChecks
}
// Capabilities 保留缺项为未观察;不能从 metadata 的成功补齐管理检查。
func (o InstanceObservation) Capabilities() (instance.CapabilityObservation, error) {
return instance.NewCapabilityObservation(o.target, o.version, o.management)
}
func (o InstanceObservation) Target() instance.ObservationTarget { return o.target }
func (o InstanceObservation) Version() string { return o.version }
func (o InstanceObservation) Extensions() instance.ExtensionSupport {
return o.extensions
}
@@ -0,0 +1,47 @@
// Package controller 将 Database 用例接入 Kubernetes 事件和重试调度。
package controller
import (
"context"
"time"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const dependencyRetry = 30 * time.Second
type BindingReconciler struct {
Client client.Client
Reader client.Reader
}
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqltenants/finalizers,verbs=update
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqldatabases/finalizers,verbs=update
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch
func (r *BindingReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
resources := &kubernetes.BindingResources{Client: r.Client, Reader: r.Reader}
service := application.BindingService{Resources: resources}
result, err := service.Reconcile(ctx, request.Namespace, request.Name)
if err != nil {
return ctrl.Result{}, err
}
if err := resources.Present(ctx, result); err != nil {
return ctrl.Result{}, err
}
if result.RetrySoon {
return ctrl.Result{RequeueAfter: time.Millisecond}, nil
}
if result.Tenant == nil {
return ctrl.Result{}, nil
}
// watch 是主入口,低频重查覆盖依赖事件映射失败,不做冲突忙循环。
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
}
@@ -0,0 +1,518 @@
package controller
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/envtest"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/yaml"
)
const (
bindingNamespace = "binding-tests"
phaseBinding = "Binding"
phaseBound = "Bound"
reasonConflict = "Conflict"
reasonDependency = "DependencyUnavailable"
TenantFinalizer = kubernetes.TenantFinalizer
DatabaseFinalizer = kubernetes.DatabaseFinalizer
)
func targetDatabaseName(tenant *databasev1alpha1.PostgreSQLTenant) string {
return "tenant-" + string(tenant.UID)
}
func tenantReference(tenant *databasev1alpha1.PostgreSQLTenant) *databasev1alpha1.TenantReference {
return &databasev1alpha1.TenantReference{
Namespace: tenant.Namespace, Name: databasev1alpha1.ObjectName(tenant.Name), UID: tenant.UID,
}
}
func TestBindingController(t *testing.T) {
apiClient, config, scheme := bindingEnvironment(t)
t.Run("动态申请和幂等重试", func(t *testing.T) { testDynamicBinding(t, apiClient) })
t.Run("双向写入之间重启", func(t *testing.T) { testBindingRestart(t, apiClient) })
t.Run("并发申请只有一个绑定", func(t *testing.T) { testConcurrentBinding(t, apiClient) })
t.Run("Released和同名重建", func(t *testing.T) { testBindingIdentity(t, apiClient) })
t.Run("目标固定与删除保护", func(t *testing.T) { testBindingProtection(t, apiClient) })
t.Run("拒绝陈旧观察和新实例身份", func(t *testing.T) { testStaleObservation(t, apiClient) })
t.Run("呈现结果不覆盖并发修改", func(t *testing.T) { testPresentationVersion(t, apiClient) })
t.Run("依赖稍后出现的watch", func(t *testing.T) { testBindingWatch(t, apiClient, config, scheme) })
}
func bindingEnvironment(t *testing.T) (client.Client, *rest.Config, *runtime.Scheme) {
t.Helper()
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
t.Skip("运行 make test 启动真实 API server")
}
scheme := runtime.NewScheme()
if err := databasev1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := rbacv1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
crdPath, err := filepath.Abs("../../../config/crd/bases")
if err != nil {
t.Fatal(err)
}
environment := &envtest.Environment{CRDDirectoryPaths: []string{crdPath}, ErrorIfCRDPathMissing: true}
config, err := environment.Start()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Error(err)
}
})
apiClient, err := client.New(config, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
namespace := &corev1.Namespace{}
namespace.Name = bindingNamespace
requireCreate(t, apiClient, namespace)
return apiClient, config, scheme
}
func testDynamicBinding(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "dynamic-instance")
tenant := provisionTenant("dynamic", instance.Name)
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef == nil || tenant.Status.Phase != phaseBound {
t.Fatal("动态申请未建立双向绑定")
}
database := &databasev1alpha1.PostgreSQLDatabase{}
database.Name = targetDatabaseName(tenant)
reload(t, apiClient, database)
if database.Spec.Database != "dynamic" || database.Spec.LoginRole != "dynamic" ||
database.Spec.ReclaimPolicy != databasev1alpha1.ReclaimRetain ||
*database.Spec.TenantRef != *tenantReference(tenant) || database.Status.InstanceUID != instance.UID {
t.Fatal("动态资源目标、默认值或身份不符")
}
if len(database.OwnerReferences) != 0 || !controllerutil.ContainsFinalizer(database, DatabaseFinalizer) {
t.Fatal("Database 不应随 Tenant GC,且必须先有删除保护")
}
beforeTenant, beforeDatabase := tenant.ResourceVersion, database.ResourceVersion
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
reload(t, apiClient, database)
if tenant.ResourceVersion != beforeTenant || database.ResourceVersion != beforeDatabase {
t.Fatal("幂等重试产生了无意义写入")
}
assertNotReady(t, tenant, "BindingComplete")
}
// 只在真实 API 调用边界注入错误,底层仍使用 API server 的并发、status 与 CEL 语义。
type failedTenantStatusClient struct {
client.Client
}
func (c *failedTenantStatusClient) Status() client.SubResourceWriter {
return &failedTenantStatusWriter{SubResourceWriter: c.Client.Status()}
}
type failedTenantStatusWriter struct {
client.SubResourceWriter
}
func (w *failedTenantStatusWriter) Update(ctx context.Context, object client.Object, options ...client.SubResourceUpdateOption) error {
if tenant, ok := object.(*databasev1alpha1.PostgreSQLTenant); ok && tenant.Status.DatabaseRef != nil {
return errors.New("injected tenant status write failure")
}
return w.SubResourceWriter.Update(ctx, object, options...)
}
func testBindingRestart(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "restart-instance")
tenant := provisionTenant("restart", instance.Name)
requireCreate(t, apiClient, tenant)
first := &BindingReconciler{Client: &failedTenantStatusClient{Client: apiClient}, Reader: apiClient}
if _, err := first.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)}); err == nil {
t.Fatal("预期第二次绑定写入失败")
}
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef != nil || tenant.Status.Phase != phaseBinding {
t.Fatal("失败后不应伪造申请侧完成")
}
database := &databasev1alpha1.PostgreSQLDatabase{}
database.Name = targetDatabaseName(tenant)
reload(t, apiClient, database)
if database.Spec.TenantRef == nil || database.Spec.TenantRef.UID != tenant.UID {
t.Fatal("失败后资源侧绑定不应回滚")
}
// 新建 reconciler,无旧内存,只从 API 中读取进度。
restarted := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, restarted, tenant)
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef == nil || tenant.Status.DatabaseRef.UID != database.UID {
t.Fatal("重启后未补齐同一资源绑定")
}
}
func testConcurrentBinding(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "concurrent-instance")
database := availableDatabase(t, apiClient, "concurrent-db", instance)
tenants := []*databasev1alpha1.PostgreSQLTenant{
existingTenant("contender-one", database.Name), existingTenant("contender-two", database.Name),
}
for _, tenant := range tenants {
requireCreate(t, apiClient, tenant)
}
var workers sync.WaitGroup
results := make(chan error, len(tenants))
for _, tenant := range tenants {
workers.Go(func() {
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(tenant)})
results <- err
})
}
workers.Wait()
close(results)
for err := range results {
if err != nil && !apierrors.IsConflict(err) {
t.Fatalf("并发协调出现非版本冲突错误: %v", err)
}
}
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
bound := 0
for _, tenant := range tenants {
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
if tenant.Status.DatabaseRef != nil {
bound++
} else {
assertNotReady(t, tenant, reasonConflict)
}
}
if bound != 1 {
t.Fatalf("绑定申请数 = %d, want 1", bound)
}
}
func testBindingIdentity(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "identity-instance")
database := availableDatabase(t, apiClient, "released-db", instance)
database.Spec.TenantRef = &databasev1alpha1.TenantReference{
Namespace: bindingNamespace, Name: "identity", UID: "previous-tenant-uid",
}
if err := apiClient.Update(t.Context(), database); err != nil {
t.Fatal(err)
}
database.Status.Phase = "Released"
if err := apiClient.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
tenant := existingTenant("identity", database.Name)
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonConflict)
if tenant.Status.DatabaseRef != nil {
t.Fatal("同名新 Tenant 不应继承旧 UID 的绑定")
}
// 同名动态记录没有匹配 UID,不能通过名称猜测这是先前创建的资源。
dynamic := provisionTenant("collision", instance.Name)
requireCreate(t, apiClient, dynamic)
collision := availableDatabase(t, apiClient, targetDatabaseName(dynamic), instance)
reconcileOK(t, reconciler, dynamic)
reload(t, apiClient, dynamic)
assertNotReady(t, dynamic, reasonConflict)
reload(t, apiClient, collision)
if collision.Spec.TenantRef != nil {
t.Fatal("同名未知记录被认领")
}
}
func testBindingProtection(t *testing.T, apiClient client.Client) {
tenant := provisionTenant("protection", "missing-instance")
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonDependency)
original := tenant.DeepCopy()
tenant.Spec.Provision.InstanceRef.Name = "other-instance"
if err := apiClient.Update(t.Context(), tenant); !apierrors.IsInvalid(err) {
t.Fatalf("Binding 后目标修改 = %v, want Invalid", err)
}
tenant = original
if err := apiClient.Delete(t.Context(), tenant); err != nil {
t.Fatal(err)
}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
if tenant.DeletionTimestamp.IsZero() || !controllerutil.ContainsFinalizer(tenant, TenantFinalizer) {
t.Fatal("未实现清理时不应提前移除删除保护")
}
assertNotReady(t, tenant, "DeletionPending")
}
func testStaleObservation(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "stale-instance")
database := availableDatabase(t, apiClient, "stale-database", instance)
// 被观察后不能更换实际数据库目标,修改回收策略仍允许。
changed := database.DeepCopy()
changed.Spec.Database = "different"
if err := apiClient.Update(t.Context(), changed); !apierrors.IsInvalid(err) {
t.Fatalf("已观察目标修改 = %v, want Invalid", err)
}
database.Spec.ReclaimPolicy = databasev1alpha1.ReclaimDelete
if err := apiClient.Update(t.Context(), database); err != nil {
t.Fatal(err)
}
tenant := existingTenant("stale", database.Name)
requireCreate(t, apiClient, tenant)
reconciler := &BindingReconciler{Client: apiClient, Reader: apiClient}
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonDependency)
// 即使同名新 Instance 已 Ready,也不能覆盖 Database 记录的旧 Instance UID。
if err := apiClient.Delete(t.Context(), instance); err != nil {
t.Fatal(err)
}
readyInstance(t, apiClient, instance.Name)
reconcileOK(t, reconciler, tenant)
reload(t, apiClient, tenant)
assertNotReady(t, tenant, reasonConflict)
}
func testBindingWatch(t *testing.T, apiClient client.Client, config *rest.Config, scheme *runtime.Scheme) {
controllerConfig := bindingControllerConfig(t, apiClient, config)
// controller-runtime 的名称登记跨 manager 生命周期保留;允许 go test -count 重复顺序启动。
// 每轮 cleanup 等待旧 manager 退出,生产 manager 不关闭名称校验。
skipRepeatedTestName := true
manager, err := ctrl.NewManager(controllerConfig, ctrl.Options{
Scheme: scheme, Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedTestName},
})
if err != nil {
t.Fatal(err)
}
reconciler := &BindingReconciler{}
if err := reconciler.SetupWithManager(t.Context(), manager); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- manager.Start(ctx) }()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(10 * time.Second):
t.Error("manager 未及时停止")
}
})
if !manager.GetCache().WaitForCacheSync(ctx) {
t.Fatal("cache 未同步")
}
tenant := existingTenant("watch", "late-database")
requireCreate(t, apiClient, tenant)
waitForTenant(t, apiClient, tenant, func(current *databasev1alpha1.PostgreSQLTenant) bool {
condition := meta.FindStatusCondition(current.Status.Conditions, "Ready")
return condition != nil && condition.Reason == reasonDependency
})
instance := readyInstance(t, apiClient, "late-instance")
availableDatabase(t, apiClient, "late-database", instance)
// 小于低频重试周期,只能靠 informer/watch 事件收敛,而不是手工调用 Reconcile。
waitForTenant(t, apiClient, tenant, func(current *databasev1alpha1.PostgreSQLTenant) bool {
return current.Status.Phase == phaseBound && current.Status.DatabaseRef != nil
})
}
func testPresentationVersion(t *testing.T, apiClient client.Client) {
instance := readyInstance(t, apiClient, "presentation-instance")
tenant := provisionTenant("presentation", instance.Name)
requireCreate(t, apiClient, tenant)
resources := &kubernetes.BindingResources{Client: apiClient, Reader: apiClient}
service := application.BindingService{Resources: resources}
result, err := service.Reconcile(t.Context(), tenant.Namespace, tenant.Name)
if err != nil {
t.Fatal(err)
}
// 用例完成资源侧写入后,模拟另一个客户端修改不属于绑定目标的字段。
reload(t, apiClient, tenant)
tenant.Spec.SecretName = "updated-delivery"
tenant.Annotations = map[string]string{"example.test/keep": "preserved"}
if err := apiClient.Update(t.Context(), tenant); err != nil {
t.Fatal(err)
}
if err := resources.Present(t.Context(), result); !apierrors.IsConflict(err) {
t.Fatalf("过期结果呈现 = %v, want Conflict", err)
}
reconcileOK(t, &BindingReconciler{Client: apiClient, Reader: apiClient}, tenant)
reload(t, apiClient, tenant)
if tenant.Status.Phase != phaseBound || tenant.Spec.SecretName != "updated-delivery" ||
tenant.Annotations["example.test/keep"] != "preserved" {
t.Fatal("重新协调未完成绑定或覆盖了其他字段")
}
}
func bindingControllerConfig(t *testing.T, apiClient client.Client, config *rest.Config) *rest.Config {
t.Helper()
content, err := os.ReadFile("../../../config/rbac/role.yaml")
if err != nil {
t.Fatal(err)
}
role := &rbacv1.ClusterRole{}
if err := yaml.Unmarshal(content, role); err != nil {
t.Fatal(err)
}
requireCreate(t, apiClient, role)
binding := &rbacv1.ClusterRoleBinding{}
binding.Name = "binding-controller-test"
binding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}
binding.Subjects = []rbacv1.Subject{{APIGroup: rbacv1.GroupName, Kind: "User", Name: binding.Name}}
requireCreate(t, apiClient, binding)
controllerConfig := rest.CopyConfig(config)
controllerConfig.Impersonate = rest.ImpersonationConfig{UserName: binding.Name}
restricted, err := client.New(controllerConfig, client.Options{Scheme: apiClient.Scheme()})
if err != nil {
t.Fatal(err)
}
// 绑定角色没有凭据读取权限,也不需要测试中的管理用户权限。
secret := &corev1.Secret{}
if err := restricted.Get(t.Context(), client.ObjectKey{Namespace: bindingNamespace, Name: "not-readable"}, secret); !apierrors.IsForbidden(err) {
t.Fatalf("绑定 controller 读取 Secret = %v, want Forbidden", err)
}
return controllerConfig
}
func waitForTenant(t *testing.T, apiClient client.Client, tenant *databasev1alpha1.PostgreSQLTenant,
predicate func(*databasev1alpha1.PostgreSQLTenant) bool) {
t.Helper()
deadline := time.NewTimer(10 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
current := &databasev1alpha1.PostgreSQLTenant{}
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(tenant), current); err == nil && predicate(current) {
return
}
select {
case <-deadline.C:
t.Fatalf("Tenant %s 未在 watch 期限内收敛", tenant.Name)
case <-ticker.C:
}
}
}
func readyInstance(t *testing.T, apiClient client.Client, name string) *databasev1alpha1.PostgreSQLInstance {
t.Helper()
instance := &databasev1alpha1.PostgreSQLInstance{}
instance.Name = name
instance.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{Host: "postgres.example.test", HostAddr: "127.0.0.1"}
instance.Spec.AdminCredentialRef.Name = "admin"
requireCreate(t, apiClient, instance)
instance.Status.Conditions = readyConditions(instance.Generation)
if err := apiClient.Status().Update(t.Context(), instance); err != nil {
t.Fatal(err)
}
return instance
}
func availableDatabase(t *testing.T, apiClient client.Client, name string,
instance *databasev1alpha1.PostgreSQLInstance) *databasev1alpha1.PostgreSQLDatabase {
t.Helper()
database := &databasev1alpha1.PostgreSQLDatabase{}
database.Name = name
database.Spec = databasev1alpha1.PostgreSQLDatabaseSpec{
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(instance.Name)},
Database: "existing", LoginRole: "existing", Source: "Import",
CredentialRef: &databasev1alpha1.CredentialReference{Mount: "secret", Path: "existing/app"},
}
requireCreate(t, apiClient, database)
database.Status.InstanceUID = instance.UID
database.Status.Phase = "Available"
database.Status.Conditions = readyConditions(database.Generation)
if err := apiClient.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
return database
}
func readyConditions(generation int64) []metav1.Condition {
return []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Verified",
Message: "测试提供的后端观察", ObservedGeneration: generation, LastTransitionTime: metav1.Now()}}
}
func provisionTenant(name, instance string) *databasev1alpha1.PostgreSQLTenant {
tenant := &databasev1alpha1.PostgreSQLTenant{}
tenant.Name, tenant.Namespace = name, bindingNamespace
tenant.Spec.Provision = &databasev1alpha1.DatabaseProvisionRequest{
InstanceRef: databasev1alpha1.InstanceReference{Name: databasev1alpha1.ObjectName(instance)},
}
return tenant
}
func existingTenant(name, database string) *databasev1alpha1.PostgreSQLTenant {
tenant := &databasev1alpha1.PostgreSQLTenant{}
tenant.Name, tenant.Namespace = name, bindingNamespace
tenant.Spec.DatabaseRef = &databasev1alpha1.DatabaseReference{Name: databasev1alpha1.ObjectName(database)}
return tenant
}
func requireCreate(t *testing.T, apiClient client.Client, object client.Object) {
t.Helper()
if err := apiClient.Create(t.Context(), object); err != nil {
t.Fatal(err)
}
}
func reload(t *testing.T, apiClient client.Client, object client.Object) {
t.Helper()
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(object), object); err != nil {
t.Fatal(err)
}
}
func reconcileOK(t *testing.T, reconciler *BindingReconciler, tenant *databasev1alpha1.PostgreSQLTenant) {
t.Helper()
if _, err := reconciler.Reconcile(t.Context(), ctrl.Request{
NamespacedName: client.ObjectKeyFromObject(tenant),
}); err != nil {
t.Fatal(err)
}
}
func assertNotReady(t *testing.T, tenant *databasev1alpha1.PostgreSQLTenant, reason string) {
t.Helper()
condition := meta.FindStatusCondition(tenant.Status.Conditions, "Ready")
if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != reason {
t.Fatalf("Ready condition 不符: %+v", condition)
}
}
@@ -0,0 +1,75 @@
package controller
import (
"context"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
)
const targetDatabaseIndex = "database.bindingTarget"
func (r *BindingReconciler) SetupWithManager(ctx context.Context, manager ctrl.Manager) error {
if r.Client == nil {
r.Client = manager.GetClient()
}
if r.Reader == nil {
r.Reader = manager.GetAPIReader()
}
if err := manager.GetFieldIndexer().IndexField(ctx, &databasev1alpha1.PostgreSQLTenant{},
targetDatabaseIndex, func(object client.Object) []string {
tenant := object.(*databasev1alpha1.PostgreSQLTenant)
return []string{kubernetes.BindingTargetName(tenant)}
}); err != nil {
return err
}
return ctrl.NewControllerManagedBy(manager).
Named("database-binding").
For(&databasev1alpha1.PostgreSQLTenant{}).
Watches(&databasev1alpha1.PostgreSQLDatabase{}, handler.EnqueueRequestsFromMapFunc(r.requestsForDatabase)).
Watches(&databasev1alpha1.PostgreSQLInstance{}, handler.EnqueueRequestsFromMapFunc(r.requestsForInstance)).
Complete(r)
}
func (r *BindingReconciler) requestsForDatabase(ctx context.Context, object client.Object) []ctrl.Request {
tenants := &databasev1alpha1.PostgreSQLTenantList{}
if err := r.Client.List(ctx, tenants, client.MatchingFields{targetDatabaseIndex: object.GetName()}); err != nil {
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Database 事件;等待低频重试")
return nil
}
requests := make([]ctrl.Request, 0, len(tenants.Items))
for _, tenant := range tenants.Items {
requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tenant)})
}
return requests
}
func (r *BindingReconciler) requestsForInstance(ctx context.Context, object client.Object) []ctrl.Request {
// 当前只有 homelab 内部申请,使用 cache 列表过滤,不维护另一份实例/租户集合。
tenants := &databasev1alpha1.PostgreSQLTenantList{}
if err := r.Client.List(ctx, tenants); err != nil {
ctrl.LoggerFrom(ctx).Error(err, "无法映射 Instance 事件;等待低频重试")
return nil
}
requests := make([]ctrl.Request, 0, len(tenants.Items))
for _, tenant := range tenants.Items {
instanceName := ""
if tenant.Spec.Provision != nil {
instanceName = string(tenant.Spec.Provision.InstanceRef.Name)
} else if tenant.Spec.DatabaseRef != nil {
database := &databasev1alpha1.PostgreSQLDatabase{}
if err := r.Client.Get(ctx, types.NamespacedName{Name: string(tenant.Spec.DatabaseRef.Name)}, database); err != nil {
continue
}
instanceName = string(database.Spec.InstanceRef.Name)
}
if instanceName == object.GetName() {
requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&tenant)})
}
}
return requests
}
@@ -0,0 +1,42 @@
package controller
import (
"context"
"time"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type InstanceReconciler struct {
Client client.Client
Reader client.Reader
Observer application.InstanceObserver
SecretNamespace string
}
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=database.ayatori.ddupan.top,resources=postgresqlinstances/finalizers,verbs=update
// Secret 权限单独声明为 namespace Role,不放入生成的 ClusterRole。
func (r *InstanceReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
resources := &kubernetes.InstanceResources{Client: r.Client, Reader: r.Reader}
service := application.InstanceReconciliation{Resources: resources, Observer: r.Observer}
observationContext, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
result, err := service.Reconcile(observationContext, request.Name)
if err != nil {
return ctrl.Result{}, err
}
// 查询超时后仍用 worker context 保存安全失败结果;manager 停止时不强行写入。
if err := resources.PresentInstance(ctx, result); err != nil {
return ctrl.Result{}, err
}
if result.Record == nil || result.RemoveProtection {
return ctrl.Result{}, nil
}
return ctrl.Result{RequeueAfter: dependencyRetry}, nil
}
@@ -0,0 +1,192 @@
package controller
import (
"context"
"errors"
"testing"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
type instanceBackend struct {
checks instance.ManagementChecks
err error
inspect func()
closed int
}
func (b *instanceBackend) Read(context.Context, instance.CredentialReference) (application.Credentials, error) {
return application.NewCredentials("fixture", "test-only-instance-password")
}
func (b *instanceBackend) Connect(context.Context, instance.Endpoint, application.Credentials) (application.Database, error) {
return b, nil
}
func (b *instanceBackend) InspectMetadata(context.Context) (application.DatabaseMetadata, error) {
return application.DatabaseMetadata{Version: "18"}, nil
}
func (b *instanceBackend) InspectManagement(context.Context) (application.DatabaseMetadata, error) {
if b.inspect != nil {
b.inspect()
}
return application.DatabaseMetadata{Version: "18", Management: b.checks}, b.err
}
func (b *instanceBackend) Close() { b.closed++ }
func newInstanceReconciler(t *testing.T, apiClient client.Client, backend *instanceBackend) *InstanceReconciler {
t.Helper()
service, err := application.NewInstanceService(backend, backend)
if err != nil {
t.Fatal(err)
}
t.Cleanup(service.Close)
return &InstanceReconciler{Client: apiClient, Reader: apiClient, Observer: service}
}
func reconcileInstance(t *testing.T, reconciler *InstanceReconciler, object *databasev1alpha1.PostgreSQLInstance) {
t.Helper()
if _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(object)}); err != nil {
t.Fatal(err)
}
}
func assertInstanceReason(t *testing.T, object *databasev1alpha1.PostgreSQLInstance, reason string) {
t.Helper()
condition := meta.FindStatusCondition(object.Status.Conditions, "Ready")
if condition == nil || condition.Reason != reason || condition.ObservedGeneration != object.Generation {
t.Fatalf("Instance 状态不是当前 generation 的 %s", reason)
}
if reason != "ManagementReady" && condition.Status != metav1.ConditionFalse {
t.Fatal("失败状态仍为 Ready")
}
}
func TestInstanceObservationAPI(t *testing.T) {
apiClient, _, _ := bindingEnvironment(t)
backend := &instanceBackend{checks: instance.ManagementChecks{
Connection: instance.CheckPassed, Metadata: instance.CheckPassed,
Roles: instance.CheckPassed, Databases: instance.CheckPassed,
Grants: instance.CheckPassed, Extensions: instance.CheckPassed,
}}
reconciler := newInstanceReconciler(t, apiClient, backend)
object := readyInstance(t, apiClient, "observed-instance")
backend.inspect = func() {
current := &databasev1alpha1.PostgreSQLInstance{}
current.Name = object.Name
reload(t, apiClient, current)
if !controllerutil.ContainsFinalizer(current, kubernetes.InstanceFinalizer) {
t.Fatal("观察早于 finalizer 持久化")
}
}
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "ManagementReady")
if object.Status.Phase != string(instance.PhaseReady) || object.Status.PostgreSQLVersion != "18" {
t.Fatal("当前成功观察未呈现")
}
before := object.ResourceVersion
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
if object.ResourceVersion != before {
t.Fatal("相同观察不应反复写入 status")
}
backend.err = application.ErrAuthentication
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "AuthenticationFailed")
if backend.closed != 1 || object.Status.PostgreSQLVersion != "" {
t.Fatal("观察失败应释放连接并清除旧版本结果")
}
backend.err = nil
backend.checks.Grants = instance.CheckUnobserved
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "ObservationIncomplete")
backend.checks.Grants = instance.CheckPassed
// 用新 service/reconciler 恢复;不依赖上轮领域对象或 Ready。
reconciler = newInstanceReconciler(t, apiClient, backend)
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "ManagementReady")
backend.inspect = func() {
reload(t, apiClient, object)
object.Annotations = map[string]string{"concurrent": "kept-by-instance-test"}
if err := apiClient.Update(t.Context(), object); err != nil {
t.Fatal(err)
}
}
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(object)})
if !apierrors.IsConflict(err) {
t.Fatal("旧观察不应覆盖在途 API 修改")
}
backend.inspect = nil
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
if object.Annotations["concurrent"] != "kept-by-instance-test" {
t.Fatal("重试覆盖了其他字段")
}
}
type failedReferenceReader struct{ client.Reader }
func (*failedReferenceReader) List(context.Context, client.ObjectList, ...client.ListOption) error {
return errors.New("injected reference list failure")
}
func TestInstanceDeletionProtection(t *testing.T) {
apiClient, _, _ := bindingEnvironment(t)
backend := &instanceBackend{}
reconciler := newInstanceReconciler(t, apiClient, backend)
object := readyInstance(t, apiClient, "protected-instance")
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
database := availableDatabase(t, apiClient, "retained-database", object)
database.Status.Phase = "Released"
if err := apiClient.Status().Update(t.Context(), database); err != nil {
t.Fatal(err)
}
tenant := provisionTenant("pending-request", object.Name)
requireCreate(t, apiClient, tenant)
if err := apiClient.Delete(t.Context(), object); err != nil {
t.Fatal(err)
}
backend.inspect = func() { t.Fatal("删除中不应连接 PostgreSQL") }
reconciler.Reader = &failedReferenceReader{Reader: apiClient}
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, reasonDependency)
reconciler.Reader = apiClient
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "InstanceInUse")
if object.Status.Phase != string(instance.PhaseDeleting) || backend.closed != 1 {
t.Fatal("删除没有停止本地观察")
}
if err := apiClient.Delete(t.Context(), database); err != nil {
t.Fatal(err)
}
reconcileInstance(t, reconciler, object)
reload(t, apiClient, object)
assertInstanceReason(t, object, "InstanceInUse")
if err := apiClient.Delete(t.Context(), tenant); err != nil {
t.Fatal(err)
}
reconcileInstance(t, reconciler, object)
if err := apiClient.Get(t.Context(), client.ObjectKeyFromObject(object), object); !apierrors.IsNotFound(err) {
t.Fatal("最后一个引用解除后 Instance 应可删除")
}
reconcileInstance(t, reconciler, object)
}
@@ -0,0 +1,77 @@
package controller
import (
"context"
"errors"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/validation"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
)
// InstanceCacheOptions 必须在创建 manager 时使用;只 watch 固定 namespace 的 Secret metadata。
// SecretCredentials 始终直读 API,不会令共享 cache 保存密码。
func InstanceCacheOptions(namespace string) cache.Options {
return cache.Options{ByObject: map[client.Object]cache.ByObject{
&corev1.Secret{}: {Namespaces: map[string]cache.Config{namespace: {}}},
}}
}
func (r *InstanceReconciler) SetupWithManager(manager ctrl.Manager) error {
if r.Observer == nil || len(validation.IsDNS1123Label(r.SecretNamespace)) != 0 {
return errors.New("instance observer and valid management Secret namespace required")
}
if r.Client == nil {
r.Client = manager.GetClient()
}
if r.Reader == nil {
r.Reader = manager.GetAPIReader()
}
return ctrl.NewControllerManagedBy(manager).
Named("database-instance").
For(&databasev1alpha1.PostgreSQLInstance{}).
WatchesMetadata(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.instancesForSecret)).
Watches(&databasev1alpha1.PostgreSQLDatabase{}, handler.EnqueueRequestsFromMapFunc(r.instanceForReference)).
Watches(&databasev1alpha1.PostgreSQLTenant{}, handler.EnqueueRequestsFromMapFunc(r.instanceForReference)).
Complete(r)
}
func (r *InstanceReconciler) instancesForSecret(ctx context.Context, object client.Object) []ctrl.Request {
if object.GetNamespace() != r.SecretNamespace {
return nil
}
instances := &databasev1alpha1.PostgreSQLInstanceList{}
if err := r.Client.List(ctx, instances); err != nil {
ctrl.LoggerFrom(ctx).Error(err, "无法映射管理 Secret 事件;等待低频重试")
return nil
}
var requests []ctrl.Request
for _, item := range instances.Items {
if string(item.Spec.AdminCredentialRef.Name) == object.GetName() {
request := ctrl.Request{Name: item.Name}
requests = append(requests, request)
}
}
return requests
}
func (r *InstanceReconciler) instanceForReference(_ context.Context, object client.Object) []ctrl.Request {
var name string
switch item := object.(type) {
case *databasev1alpha1.PostgreSQLDatabase:
name = string(item.Spec.InstanceRef.Name)
case *databasev1alpha1.PostgreSQLTenant:
if item.Spec.Provision != nil {
name = string(item.Spec.Provision.InstanceRef.Name)
}
}
if name == "" {
return nil
}
request := ctrl.Request{Name: name}
return []ctrl.Request{request}
}
+136
View File
@@ -0,0 +1,136 @@
// Package binding 定义资源与申请的纯绑定规则,不访问 Kubernetes 或数据库。
package binding
import (
"fmt"
"regexp"
)
const (
Binding = "Binding"
Bound = "Bound"
Deleting = "Deleting"
Conflict = "Conflict"
DependencyUnavailable = "DependencyUnavailable"
)
type Identity struct {
Name string
UID string
}
type TenantIdentity struct {
Namespace string
Name string
UID string
}
// Request 保留用户输入;Resolve 产生默认值已确定的目标,不修改原请求。
type Request struct {
Provision *ProvisionRequest
ExistingDatabase string
}
type ProvisionRequest struct {
Instance string
Database string
LoginRole string
}
type Target struct {
Name string
Provision *ProvisionRequest
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
func (r Request) Resolve(tenant TenantIdentity) (Target, error) {
if (r.Provision == nil) == (r.ExistingDatabase == "") {
return Target{}, fmt.Errorf("必须且只能选择动态申请或已有 Database")
}
if r.Provision == nil {
return Target{Name: r.ExistingDatabase}, nil
}
provision := *r.Provision
if provision.Database == "" {
provision.Database = tenant.Name
}
if provision.LoginRole == "" {
provision.LoginRole = tenant.Name
}
if !identifier.MatchString(provision.Database) || !identifier.MatchString(provision.LoginRole) {
return Target{}, fmt.Errorf("动态 database/loginRole 必须符合 PostgreSQL identifier 规则;省略时使用 Tenant 名称")
}
return Target{Name: DynamicDatabaseName(tenant.UID), Provision: &provision}, nil
}
func DynamicDatabaseName(tenantUID string) string { return "tenant-" + tenantUID }
type Tenant struct {
Identity TenantIdentity
Request Request
Phase string
Deleting bool
Database *Identity
}
// Database 是绑定所需的资源事实,不包含存储版本、Conditions 或客户端对象。
type Database struct {
Identity Identity
Instance string
InstanceUID string
Name string
LoginRole string
Source string
Tenant *TenantIdentity
Phase string
Deleting bool
Ready bool
}
type Instance struct {
Identity Identity
Deleting bool
Ready bool
}
type Issue struct {
Reason string
Message string
}
func (d Database) MatchesProvision(target Target, tenant TenantIdentity) bool {
return target.Provision != nil && d.Source == "Provision" && d.Instance == target.Provision.Instance &&
d.Name == target.Provision.Database && d.LoginRole == target.Provision.LoginRole &&
d.Tenant != nil && *d.Tenant == tenant
}
func (i Instance) Check(database *Database) *Issue {
if i.Deleting || !i.Ready {
return &Issue{DependencyUnavailable, "Instance 正在删除或尚无当前版本的 Ready 观察"}
}
if database != nil && database.InstanceUID != "" && database.InstanceUID != i.Identity.UID {
return &Issue{Conflict, "Instance UID 已变化;请核实实例身份,未迁移或接管资源"}
}
return nil
}
func (d Database) CanBind(tenant Tenant) *Issue {
if tenant.Database != nil && tenant.Database.UID != d.Identity.UID {
return &Issue{Conflict, fmt.Sprintf("Database %s 的 UID 与已记录绑定不同;请核实同名重建,未接管新对象", d.Identity.Name)}
}
if d.Deleting || d.Phase == "Released" || d.Phase == Deleting {
return &Issue{Conflict, "Database 正在删除或处于 Released;请由管理员核实并处理,未重新分配"}
}
if d.Tenant != nil {
if *d.Tenant != tenant.Identity {
return &Issue{Conflict, fmt.Sprintf("Database %s 已绑定 Tenant %s/%s(UID %s);未抢占",
d.Identity.Name, d.Tenant.Namespace, d.Tenant.Name, d.Tenant.UID)}
}
return nil
}
if d.Phase != "Available" || !d.Ready {
return &Issue{DependencyUnavailable, "Database 尚未完成验证并进入 Available,等待资源观察"}
}
return nil
}
@@ -0,0 +1,134 @@
package binding
import "testing"
const (
testNamespace = "apps"
testApp = "app"
testInstance = "shared"
testOwner = "owner"
testExisting = "existing"
testOther = "other"
)
func TestRequestResolve(t *testing.T) {
tenant := TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "tenant-uid"}
tests := []struct {
name string
request Request
valid bool
}{
{"动态默认值", Request{Provision: &ProvisionRequest{Instance: testInstance}}, true},
{"显式名称", Request{Provision: &ProvisionRequest{Instance: testInstance, Database: "custom", LoginRole: testOwner}}, true},
{"已有资源", Request{ExistingDatabase: testExisting}, true},
{"没有入口", Request{}, false},
{"同时指定入口", Request{Provision: &ProvisionRequest{}, ExistingDatabase: testExisting}, false},
{"非法库名", Request{Provision: &ProvisionRequest{Database: "bad-name"}}, false},
{"非法角色名", Request{Provision: &ProvisionRequest{LoginRole: "bad-name"}}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
target, err := tc.request.Resolve(tenant)
if (err == nil) != tc.valid {
t.Fatalf("Resolve() = %v, valid = %v", err, tc.valid)
}
if !tc.valid {
return
}
if tc.request.Provision == nil {
if target.Name != testExisting || target.Provision != nil {
t.Fatal("已有资源不应推导 Instance 或供应请求")
}
return
}
if target.Name != "tenant-tenant-uid" || target.Provision == tc.request.Provision {
t.Fatal("目标名称不稳定,或 Resolve 未复制输入")
}
if tc.request.Provision.Database == "" && target.Provision.Database != tenant.Name {
t.Fatal("数据库默认名称不符")
}
if tc.request.Provision.LoginRole == "" && target.Provision.LoginRole != tenant.Name {
t.Fatal("角色默认名称不符")
}
})
}
}
func TestDatabaseCanBind(t *testing.T) {
tenant := Tenant{Identity: TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "current"}}
tests := []struct {
name string
change func(*Database, *Tenant)
reason string
}{
{"空闲且就绪", func(*Database, *Tenant) {}, ""},
{"同一绑定重试", func(d *Database, t *Tenant) { d.Tenant = &t.Identity; d.Ready = false }, ""},
{"尚未观察", func(d *Database, _ *Tenant) { d.Ready = false }, DependencyUnavailable},
{"尚未Available", func(d *Database, _ *Tenant) { d.Phase = "Pending" }, DependencyUnavailable},
{"Released", func(d *Database, _ *Tenant) { d.Phase = "Released" }, Conflict},
{"删除标记", func(d *Database, _ *Tenant) { d.Deleting = true }, Conflict},
{"删除阶段", func(d *Database, _ *Tenant) { d.Phase = Deleting }, Conflict},
{"已被占用", func(d *Database, _ *Tenant) { d.Tenant = &TenantIdentity{UID: testOther} }, Conflict},
{"同名新申请", func(d *Database, t *Tenant) { old := t.Identity; old.UID = "old"; d.Tenant = &old }, Conflict},
{"同名新资源", func(_ *Database, t *Tenant) { t.Database = &Identity{Name: "resource", UID: "old"} }, Conflict},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
database := Database{Identity: Identity{Name: "resource", UID: "database-uid"}, Phase: "Available", Ready: true}
currentTenant := tenant
tc.change(&database, &currentTenant)
checkIssue(t, database.CanBind(currentTenant), tc.reason)
})
}
}
func TestInstanceCheck(t *testing.T) {
instance := Instance{Identity: Identity{UID: "instance"}, Ready: true}
checkIssue(t, instance.Check(nil), "")
checkIssue(t, instance.Check(&Database{InstanceUID: "instance"}), "")
checkIssue(t, instance.Check(&Database{InstanceUID: "replaced"}), Conflict)
instance.Ready = false
checkIssue(t, instance.Check(nil), DependencyUnavailable)
instance.Ready, instance.Deleting = true, true
checkIssue(t, instance.Check(nil), DependencyUnavailable)
}
func TestMatchesProvision(t *testing.T) {
tenant := TenantIdentity{Namespace: testNamespace, Name: testApp, UID: "tenant"}
target := Target{Provision: &ProvisionRequest{Instance: testInstance, Database: testApp, LoginRole: testOwner}}
database := Database{Source: "Provision", Instance: testInstance, Name: testApp, LoginRole: testOwner, Tenant: &tenant}
if !database.MatchesProvision(target, tenant) {
t.Fatal("相同目标与身份应允许重试")
}
mutations := []func(*Database){
func(d *Database) { d.Source = "Import" },
func(d *Database) { d.Instance = testOther },
func(d *Database) { d.Name = testOther },
func(d *Database) { d.LoginRole = testOther },
func(d *Database) { d.Tenant = nil },
func(d *Database) { d.Tenant = &TenantIdentity{UID: testOther} },
}
for _, mutate := range mutations {
changed := database
mutate(&changed)
if changed.MatchesProvision(target, tenant) {
t.Fatal("不匹配的记录不能仅靠名称被认领")
}
}
if database.MatchesProvision(Target{}, tenant) {
t.Fatal("已有资源申请不是动态供应重试")
}
}
func checkIssue(t *testing.T, issue *Issue, reason string) {
t.Helper()
if reason == "" {
if issue != nil {
t.Fatalf("不应拒绝: %+v", issue)
}
return
}
if issue == nil || issue.Reason != reason || issue.Message == "" {
t.Fatalf("issue = %+v, want %s 与可读诊断", issue, reason)
}
}
@@ -24,7 +24,6 @@ type Phase string
const (
PhasePending Phase = "Pending"
PhaseValidating Phase = "Validating"
PhaseInitializingRegistry Phase = "InitializingRegistry"
PhaseReady Phase = "Ready"
PhaseDeleting Phase = "Deleting"
)
@@ -61,7 +60,7 @@ func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*
return nil, err
}
switch snapshot.Phase {
case PhasePending, PhaseValidating, PhaseInitializingRegistry, PhaseReady, PhaseDeleting:
case PhasePending, PhaseValidating, PhaseReady, PhaseDeleting:
default:
snapshot.Phase = PhasePending
snapshot.Readiness = Unknown
@@ -39,7 +39,7 @@ func lifecycleInstance(t *testing.T, snapshot instance.Snapshot, deleting bool)
// Acceptance: docs/database/domain-instance.md §3, checkpoint reconstruction and intent-only transitions.
func TestReconstituteCheckpoints(t *testing.T) {
for _, phase := range []instance.Phase{
instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry,
instance.PhasePending, instance.PhaseValidating,
instance.PhaseReady, instance.PhaseDeleting,
} {
snapshot := instance.Snapshot{Phase: phase, ObservedRevision: 1, Readiness: instance.Ready, ReportedVersion: "17"}
@@ -97,7 +97,7 @@ func TestDeletionRequiresRequestAndPreventsValidation(t *testing.T) {
t.Fatal("rejected deletion mutated state")
}
for _, phase := range []instance.Phase{
instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry,
instance.PhasePending, instance.PhaseValidating,
instance.PhaseReady, instance.PhaseDeleting,
} {
snapshot.Phase = phase
+12 -113
View File
@@ -27,8 +27,6 @@ const (
DependencyUnavailable
AuthenticationFailed
InsufficientPrivileges
RegistryIncompatible
RegistryNotUsable
)
// CheckResult 的零值表示未观察,不能视为成功。
@@ -70,32 +68,22 @@ func (c ManagementChecks) failure() Failure {
return NoFailure
}
type RegistryState uint8
const (
RegistryUnobserved RegistryState = iota
RegistryAbsent
RegistryNeedsMigration
RegistryUsable
RegistryUnsupported
RegistryUnavailable
)
// CapabilityObservation 是值对象,不包含连接、凭据或可变集合。
type CapabilityObservation struct {
target ObservationTarget
version string
checks ManagementChecks
registry RegistryState
}
func NewCapabilityObservation(target ObservationTarget, version string,
checks ManagementChecks, registry RegistryState,
func NewCapabilityObservation(
target ObservationTarget,
version string,
checks ManagementChecks,
) (CapabilityObservation, error) {
if err := target.Validate(); err != nil {
return CapabilityObservation{}, err
}
return CapabilityObservation{target: target, version: version, checks: checks, registry: registry}, nil
return CapabilityObservation{target: target, version: version, checks: checks}, nil
}
func (o CapabilityObservation) managementFailure() Failure {
@@ -108,29 +96,6 @@ func (o CapabilityObservation) managementFailure() Failure {
return NoFailure
}
func (o CapabilityObservation) registryFailure() Failure {
switch o.registry {
case RegistryUsable:
return NoFailure
case RegistryAbsent, RegistryNeedsMigration:
return RegistryNotUsable
case RegistryUnsupported:
return RegistryIncompatible
case RegistryUnavailable:
return DependencyUnavailable
default:
return ObservationIncomplete
}
}
type PreparationDecision uint8
const (
PreparationDenied PreparationDecision = iota
PreparationAllowed
AlreadyUsable
)
func (i *Instance) acceptObservation(o CapabilityObservation, phase Phase) error {
if !i.target.Matches(o.target) {
return errors.New("capability observation target does not match instance")
@@ -149,75 +114,11 @@ func (i *Instance) fail(failure Failure) {
i.snapshot.ObservedRevision = i.target.Revision().Value()
}
// AssessManagement 只推进意图,不执行 registry 写入,也不完成 observedRevision。
// AssessManagement 根据本轮完整能力观察完成验证,不执行外部写入。
func (i *Instance) AssessManagement(o CapabilityObservation) error {
if err := i.acceptObservation(o, PhaseValidating); err != nil {
return err
}
if failure := o.managementFailure(); failure != NoFailure {
i.fail(failure)
return nil
}
if failure := o.registryFailure(); failure != NoFailure && failure != RegistryNotUsable {
i.fail(failure)
return nil
}
i.snapshot.Phase = PhaseInitializingRegistry
i.snapshot.Readiness = Unknown
i.snapshot.Failure = NoFailure
i.evidence = nil
return nil
}
// PlanRegistryPreparation 不证明 checkpoint 已落盘;应用层必须先保存意图再执行写入。
func (i *Instance) PlanRegistryPreparation(o CapabilityObservation) (PreparationDecision, error) {
if err := i.acceptObservation(o, PhaseInitializingRegistry); err != nil {
return PreparationDenied, err
}
if failure := o.managementFailure(); failure != NoFailure {
i.fail(failure)
return PreparationDenied, nil
}
switch o.registry {
case RegistryUsable:
return AlreadyUsable, nil
case RegistryAbsent, RegistryNeedsMigration:
return PreparationAllowed, nil
default:
i.fail(o.registryFailure())
return PreparationDenied, nil
}
}
// RegistryPreparationResult 只能是安全失败或完整回读,不能表达裸操作成功。
type RegistryPreparationResult struct {
observation CapabilityObservation
failure Failure
}
func RegistryReadBack(o CapabilityObservation) RegistryPreparationResult {
return RegistryPreparationResult{observation: o}
}
func RegistryPreparationFailed(target ObservationTarget, failure Failure) (RegistryPreparationResult, error) {
if err := target.Validate(); err != nil {
return RegistryPreparationResult{}, err
}
if failure < ObservationIncomplete || failure > RegistryNotUsable {
return RegistryPreparationResult{}, errors.New("registry preparation requires a known failure category")
}
return RegistryPreparationResult{observation: CapabilityObservation{target: target}, failure: failure}, nil
}
func (i *Instance) AssessRegistryResult(result RegistryPreparationResult) error {
o := result.observation
if err := i.acceptObservation(o, PhaseInitializingRegistry); err != nil {
return err
}
if result.failure != NoFailure {
i.fail(result.failure)
return nil
}
i.assessComplete(o)
return nil
}
@@ -227,12 +128,12 @@ func (i *Instance) assessComplete(o CapabilityObservation) {
i.fail(failure)
return
}
if failure := o.registryFailure(); failure != NoFailure {
i.fail(failure)
return
i.snapshot = Snapshot{
Phase: PhaseReady,
ObservedRevision: i.target.Revision().Value(),
Readiness: Ready,
ReportedVersion: o.version,
}
i.snapshot = Snapshot{Phase: PhaseReady, ObservedRevision: i.target.Revision().Value(),
Readiness: Ready, ReportedVersion: o.version}
i.evidence = &o
}
@@ -244,10 +145,8 @@ func (i *Instance) AssessReadiness(o CapabilityObservation) error {
if i.snapshot.ObservedRevision != i.target.Revision().Value() {
return i.BeginValidation()
}
if o.managementFailure() != NoFailure || o.registry == RegistryUnavailable {
if o.managementFailure() != NoFailure {
i.snapshot.Phase = PhaseValidating
} else if o.registryFailure() != NoFailure {
i.snapshot.Phase = PhaseInitializingRegistry
}
i.assessComplete(o)
return nil
@@ -32,11 +32,9 @@ func completeChecks() instance.ManagementChecks {
}
}
func capability(t *testing.T, value *instance.Instance, checks instance.ManagementChecks,
registry instance.RegistryState,
) instance.CapabilityObservation {
func capability(t *testing.T, value *instance.Instance, checks instance.ManagementChecks) instance.CapabilityObservation {
t.Helper()
o, err := instance.NewCapabilityObservation(value.Target(), testServerVersion, checks, registry)
o, err := instance.NewCapabilityObservation(value.Target(), testServerVersion, checks)
if err != nil {
t.Fatal(err)
}
@@ -45,8 +43,8 @@ func capability(t *testing.T, value *instance.Instance, checks instance.Manageme
func readyInstance(t *testing.T) *instance.Instance {
t.Helper()
i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false)
if err := i.AssessRegistryResult(instance.RegistryReadBack(capability(t, i, completeChecks(), instance.RegistryUsable))); err != nil {
i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false)
if err := i.AssessManagement(capability(t, i, completeChecks())); err != nil {
t.Fatal(err)
}
if err := i.RequireProvisioningReady(); err != nil {
@@ -60,38 +58,27 @@ func TestReadinessRequiresCompleteReadBack(t *testing.T) {
if err := i.BeginValidation(); err != nil {
t.Fatal(err)
}
absent := capability(t, i, completeChecks(), instance.RegistryAbsent)
if err := i.AssessManagement(absent); err != nil {
if i.RequireProvisioningReady() == nil {
t.Fatal("validation intent authorized provisioning")
}
if err := i.AssessManagement(capability(t, i, instance.ManagementChecks{})); err != nil {
t.Fatal(err)
}
if s := i.Snapshot(); s.Phase != instance.PhaseInitializingRegistry || s.ObservedRevision != 0 || s.Readiness != instance.Unknown {
t.Fatalf("management observation prematurely concluded readiness: %+v", s)
}
for range 2 {
decision, err := i.PlanRegistryPreparation(absent)
if err != nil || decision != instance.PreparationAllowed {
t.Fatalf("preparation: %v, %v", decision, err)
if snapshot := i.Snapshot(); snapshot.Phase != instance.PhaseValidating ||
snapshot.Readiness != instance.NotReady || snapshot.Failure != instance.ObservationIncomplete {
t.Fatalf("incomplete observation accepted: %+v", snapshot)
}
if i.RequireProvisioningReady() == nil {
t.Fatal("preparation authorized provisioning")
t.Fatal("incomplete observation authorized provisioning")
}
}
if err := i.AssessRegistryResult(instance.RegistryReadBack(absent)); err != nil {
if err := i.AssessManagement(capability(t, i, completeChecks())); err != nil {
t.Fatal(err)
}
if i.Snapshot().Failure != instance.RegistryNotUsable || i.RequireProvisioningReady() == nil {
t.Fatal("absent registry accepted as ready")
}
usable := capability(t, i, completeChecks(), instance.RegistryUsable)
decision, err := i.PlanRegistryPreparation(usable)
if err != nil || decision != instance.AlreadyUsable {
t.Fatalf("retry after external preparation: %v, %v", decision, err)
}
if err := i.AssessRegistryResult(instance.RegistryReadBack(usable)); err != nil {
t.Fatal(err)
}
if s := i.Snapshot(); s.Readiness != instance.Ready || s.ReportedVersion != testServerVersion || s.ObservedRevision != i.Target().Revision().Value() {
t.Fatalf("complete observation not accepted: %+v", s)
snapshot := i.Snapshot()
if snapshot.Phase != instance.PhaseReady || snapshot.Readiness != instance.Ready ||
snapshot.ReportedVersion != testServerVersion ||
snapshot.ObservedRevision != i.Target().Revision().Value() {
t.Fatalf("complete management observation did not establish readiness: %+v", snapshot)
}
if err := i.RequireProvisioningReady(); err != nil {
t.Fatal(err)
@@ -109,7 +96,7 @@ func TestReadinessRecoveryAndInvalidation(t *testing.T) {
t.Fatal("persisted Ready fabricated fresh evidence")
}
for range 2 {
if err := restored.AssessReadiness(capability(t, restored, completeChecks(), instance.RegistryUsable)); err != nil {
if err := restored.AssessReadiness(capability(t, restored, completeChecks())); err != nil {
t.Fatal(err)
}
if err := restored.RequireProvisioningReady(); err != nil {
@@ -138,120 +125,77 @@ func TestReadinessRecoveryAndInvalidation(t *testing.T) {
}
func TestEachManagementCheckIsRequired(t *testing.T) {
for field := range 6 {
for _, result := range []instance.CheckResult{instance.CheckUnobserved, instance.CheckUnavailable,
instance.CheckAuthenticationFailed, instance.CheckInsufficientPrivileges, 255} {
checkNames := []string{"connection", "metadata", "roles", "databases", "grants", "extensions"}
failures := []struct {
name string
result instance.CheckResult
want instance.Failure
}{
{"unobserved", instance.CheckUnobserved, instance.ObservationIncomplete},
{"unavailable", instance.CheckUnavailable, instance.DependencyUnavailable},
{"authentication", instance.CheckAuthenticationFailed, instance.AuthenticationFailed},
{"privileges", instance.CheckInsufficientPrivileges, instance.InsufficientPrivileges},
{"unknown", 255, instance.ObservationIncomplete},
}
for field, name := range checkNames {
for _, failure := range failures {
for _, phase := range []instance.Phase{instance.PhaseValidating, instance.PhaseReady} {
t.Run(name+"/"+failure.name+"/"+string(phase), func(t *testing.T) {
checks := completeChecks()
fields := []*instance.CheckResult{&checks.Connection, &checks.Metadata, &checks.Roles,
&checks.Databases, &checks.Grants, &checks.Extensions}
*fields[field] = result
i := readyInstance(t)
if err := i.AssessReadiness(capability(t, i, checks, instance.RegistryUsable)); err != nil {
fields := []*instance.CheckResult{
&checks.Connection, &checks.Metadata, &checks.Roles,
&checks.Databases, &checks.Grants, &checks.Extensions,
}
*fields[field] = failure.result
value := lifecycleInstance(t, instance.Snapshot{Phase: phase}, false)
assess := value.AssessManagement
if phase == instance.PhaseReady {
value = readyInstance(t)
assess = value.AssessReadiness
}
if err := assess(capability(t, value, checks)); err != nil {
t.Fatal(err)
}
if s := i.Snapshot(); s.Phase != instance.PhaseValidating || s.Readiness != instance.NotReady ||
s.Failure == instance.NoFailure || i.RequireProvisioningReady() == nil {
t.Fatalf("check %d result %d accepted: %+v", field, result, s)
snapshot := value.Snapshot()
if snapshot.Phase != instance.PhaseValidating ||
snapshot.Readiness != instance.NotReady ||
snapshot.Failure != failure.want ||
snapshot.ObservedRevision != value.Target().Revision().Value() {
t.Fatalf("incorrect failed observation: %+v", snapshot)
}
if value.RequireProvisioningReady() == nil {
t.Fatal("failed check authorized provisioning")
}
}
}
func TestRegistryDecisionsAndReadinessLoss(t *testing.T) {
for _, tc := range []struct {
state instance.RegistryState
decision instance.PreparationDecision
failure instance.Failure
}{
{instance.RegistryUsable, instance.AlreadyUsable, instance.NoFailure},
{instance.RegistryAbsent, instance.PreparationAllowed, instance.RegistryNotUsable},
{instance.RegistryNeedsMigration, instance.PreparationAllowed, instance.RegistryNotUsable},
{instance.RegistryUnsupported, instance.PreparationDenied, instance.RegistryIncompatible},
{instance.RegistryUnavailable, instance.PreparationDenied, instance.DependencyUnavailable},
{instance.RegistryUnobserved, instance.PreparationDenied, instance.ObservationIncomplete},
{255, instance.PreparationDenied, instance.ObservationIncomplete},
} {
i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false)
o := capability(t, i, completeChecks(), tc.state)
decision, err := i.PlanRegistryPreparation(o)
if err != nil || decision != tc.decision {
t.Fatalf("registry %d: %v, %v", tc.state, decision, err)
}
i = readyInstance(t)
if err := i.AssessReadiness(o); err != nil {
// 依赖恢复后重新验证,不保留失败或旧就绪证据。
if err := value.AssessManagement(capability(t, value, completeChecks())); err != nil {
t.Fatal(err)
}
if i.Snapshot().Failure != tc.failure {
t.Fatalf("registry %d: %+v", tc.state, i.Snapshot())
}
if tc.state != instance.RegistryUsable {
wantPhase := instance.PhaseInitializingRegistry
if tc.state == instance.RegistryUnavailable {
wantPhase = instance.PhaseValidating
}
if i.Snapshot().Phase != wantPhase || i.RequireProvisioningReady() == nil {
t.Fatal("registry drift retained readiness")
}
}
}
}
func TestInitializationRejectsIncompleteOrFailedManagement(t *testing.T) {
for _, tc := range []struct {
checks instance.ManagementChecks
registry instance.RegistryState
failure instance.Failure
}{
{instance.ManagementChecks{}, instance.RegistryUsable, instance.ObservationIncomplete},
{completeChecks(), instance.RegistryUnsupported, instance.RegistryIncompatible},
{completeChecks(), instance.RegistryUnavailable, instance.DependencyUnavailable},
} {
i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseValidating}, false)
if err := i.AssessManagement(capability(t, i, tc.checks, tc.registry)); err != nil {
t.Fatal(err)
}
if s := i.Snapshot(); s.Phase != instance.PhaseValidating || s.Failure != tc.failure ||
s.ObservedRevision != i.Target().Revision().Value() || s.Readiness != instance.NotReady {
t.Fatalf("invalid management accepted: %+v", s)
}
}
i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false)
decision, err := i.PlanRegistryPreparation(capability(t, i, instance.ManagementChecks{}, instance.RegistryAbsent))
if err != nil || decision != instance.PreparationDenied || i.Snapshot().Failure != instance.ObservationIncomplete {
t.Fatal("incomplete management allowed registry writes")
}
if err := i.AssessRegistryResult(instance.RegistryReadBack(capability(t, i, completeChecks(), instance.RegistryUsable))); err != nil {
t.Fatal(err)
}
if err := i.RequireProvisioningReady(); err != nil {
if err := value.RequireProvisioningReady(); err != nil {
t.Fatal("dependency recovery did not restore readiness", err)
}
})
}
}
}
}
func TestReadinessMethodsRejectWrongPhaseAndDeletion(t *testing.T) {
for _, deleting := range []bool{false, true} {
for _, phase := range []instance.Phase{instance.PhasePending, instance.PhaseValidating,
instance.PhaseInitializingRegistry, instance.PhaseReady, instance.PhaseDeleting} {
instance.PhaseReady, instance.PhaseDeleting} {
for _, operation := range []struct {
phase instance.Phase
apply func(*instance.Instance, instance.CapabilityObservation) error
}{
{instance.PhaseValidating, (*instance.Instance).AssessManagement},
{instance.PhaseReady, (*instance.Instance).AssessReadiness},
{instance.PhaseInitializingRegistry, func(i *instance.Instance, o instance.CapabilityObservation) error {
_, err := i.PlanRegistryPreparation(o)
return err
}},
{instance.PhaseInitializingRegistry, func(i *instance.Instance, o instance.CapabilityObservation) error {
return i.AssessRegistryResult(instance.RegistryReadBack(o))
}},
} {
if !deleting && operation.phase == phase {
continue
}
i := lifecycleInstance(t, instance.Snapshot{Phase: phase}, deleting)
before := i.Snapshot()
if err := operation.apply(i, capability(t, i, completeChecks(), instance.RegistryUsable)); err == nil {
if err := operation.apply(i, capability(t, i, completeChecks())); err == nil {
t.Fatalf("phase %s deleting=%t accepted operation for %s", phase, deleting, operation.phase)
}
if i.Snapshot() != before {
@@ -273,7 +217,7 @@ func TestOldGenerationObservationDoesNotReplaceEvidence(t *testing.T) {
if err != nil {
t.Fatal(err)
}
o, err := instance.NewCapabilityObservation(other, testServerVersion, completeChecks(), instance.RegistryUsable)
o, err := instance.NewCapabilityObservation(other, testServerVersion, completeChecks())
if err != nil {
t.Fatal(err)
}
@@ -286,44 +230,14 @@ func TestOldGenerationObservationDoesNotReplaceEvidence(t *testing.T) {
}
}
func TestPreparationFailureCannotEstablishReadiness(t *testing.T) {
i := lifecycleInstance(t, instance.Snapshot{Phase: instance.PhaseInitializingRegistry}, false)
for _, failure := range []instance.Failure{instance.DependencyUnavailable, instance.AuthenticationFailed,
instance.InsufficientPrivileges, instance.RegistryIncompatible} {
result, err := instance.RegistryPreparationFailed(i.Target(), failure)
if err != nil {
t.Fatal(err)
}
if err := i.AssessRegistryResult(result); err != nil {
t.Fatal(err)
}
if s := i.Snapshot(); s.Failure != failure || s.Readiness != instance.NotReady ||
s.Phase != instance.PhaseInitializingRegistry || i.RequireProvisioningReady() == nil {
t.Fatalf("failed operation accepted: %+v", s)
}
}
for _, failure := range []instance.Failure{instance.NoFailure, 255} {
if _, err := instance.RegistryPreparationFailed(i.Target(), failure); err == nil {
t.Fatal("invalid failure accepted")
}
}
}
func TestCapabilityInputsAndLifecycleGuards(t *testing.T) {
i := readyInstance(t)
if _, err := instance.NewCapabilityObservation(instance.ObservationTarget{}, testServerVersion,
completeChecks(), instance.RegistryUsable); err == nil {
completeChecks()); err == nil {
t.Fatal("invalid target accepted")
}
if _, err := instance.RegistryPreparationFailed(instance.ObservationTarget{}, instance.DependencyUnavailable); err == nil {
t.Fatal("invalid failure target accepted")
}
for _, method := range []func(instance.CapabilityObservation) error{
i.AssessManagement, i.AssessReadiness,
func(o instance.CapabilityObservation) error { _, err := i.PlanRegistryPreparation(o); return err },
func(o instance.CapabilityObservation) error {
return i.AssessRegistryResult(instance.RegistryReadBack(o))
},
} {
before := i.Snapshot()
if err := method(instance.CapabilityObservation{}); err == nil || i.Snapshot() != before {
@@ -333,13 +247,13 @@ func TestCapabilityInputsAndLifecycleGuards(t *testing.T) {
old := i.Snapshot()
old.ObservedRevision = 0
changed := lifecycleInstance(t, old, false)
if err := changed.AssessReadiness(capability(t, changed, completeChecks(), instance.RegistryUsable)); err != nil {
if err := changed.AssessReadiness(capability(t, changed, completeChecks())); err != nil {
t.Fatal(err)
}
if s := changed.Snapshot(); s.Phase != instance.PhaseValidating || s.ObservedRevision != 0 || s.Readiness != instance.Unknown {
t.Fatalf("changed generation accepted old checkpoint: %+v", s)
}
o, err := instance.NewCapabilityObservation(i.Target(), "", completeChecks(), instance.RegistryUsable)
o, err := instance.NewCapabilityObservation(i.Target(), "", completeChecks())
if err != nil {
t.Fatal(err)
}