Files
ayatori/api/database/v1alpha1/validation_test.go
T
panxiao81 7e9e8e828b
Verify / test (pull_request) Successful in 11m39s
Verify / lint (pull_request) Successful in 12m51s
Verify / database-integration (pull_request) Successful in 13m12s
feat: 接入 Database 三资源 API 与分层绑定协调
确定单库单账号、集群级 Database、资源侧先写绑定和凭据定位合同。领域层承载纯规则,service 协調流程,Kubernetes adapter 负责资源呈现与版本保护。

验证:全量 make test、三轮 race、真实 API server 并发与重启补写、最小 RBAC/watch、lint 和文档检查通过。供应、凭据交付及删除清理尚未实现,保留 DeletionPending/finalizer 边界。
2026-09-25 04:09:43 +00:00

313 lines
11 KiB
Go

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
}