feat: 接入 Database 三资源 API 与分层绑定协调
确定单库单账号、集群级 Database、资源侧先写绑定和凭据定位合同。领域层承载纯规则,service 协調流程,Kubernetes adapter 负责资源呈现与版本保护。 验证:全量 make test、三轮 race、真实 API server 并发与重启补写、最小 RBAC/watch、lint 和文档检查通过。供应、凭据交付及删除清理尚未实现,保留 DeletionPending/finalizer 边界。
This commit is contained in:
@@ -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,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,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,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