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