feat: 接入 Instance 原生管理观测与删除保护
This commit is contained in:
@@ -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"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
|
||||
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
@@ -40,15 +41,19 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureHost = "fixture.invalid"
|
||||
fixtureUser = "postgres"
|
||||
fixtureExtension = "plpgsql"
|
||||
dockerExec = "exec"
|
||||
fixtureImage = "postgres@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73"
|
||||
fixturePassword = "AYATORI-TEST-ONLY-initial-password"
|
||||
rotatedPassword = "AYATORI-TEST-ONLY-rotated-password"
|
||||
controllerNamespace = "database-controller"
|
||||
secretName = "management"
|
||||
fixtureAddress = "127.0.0.1"
|
||||
managementUsernameKey = "login"
|
||||
managementPasswordKey = "credential"
|
||||
unrelatedNamespace = "unrelated"
|
||||
fixtureHost = "fixture.invalid"
|
||||
fixtureUser = "postgres"
|
||||
fixtureExtension = "plpgsql"
|
||||
dockerExec = "exec"
|
||||
fixtureImage = "postgres@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73"
|
||||
fixturePassword = "AYATORI-TEST-ONLY-initial-password"
|
||||
rotatedPassword = "AYATORI-TEST-ONLY-rotated-password"
|
||||
controllerNamespace = "database-controller"
|
||||
secretName = "management"
|
||||
)
|
||||
|
||||
// fixture 不接受外部 DSN,只创建自己的临时容器并按确切 ID 清理。
|
||||
@@ -79,7 +84,7 @@ func postgresFixture(t *testing.T, ctx context.Context) (string, int) {
|
||||
t.Fatal("invalid fixture port")
|
||||
}
|
||||
// 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。
|
||||
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", "127.0.0.1", "-U", fixtureUser).Run() != nil {
|
||||
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", fixtureAddress, "-U", fixtureUser).Run() != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("fixture startup timed out")
|
||||
@@ -154,7 +159,7 @@ func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationT
|
||||
}
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: fixtureHost,
|
||||
HostAddr: "127.0.0.1",
|
||||
HostAddr: fixtureAddress,
|
||||
Port: port,
|
||||
ManagementDatabase: fixtureUser,
|
||||
TLSMode: mode,
|
||||
@@ -164,8 +169,8 @@ func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationT
|
||||
}
|
||||
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
|
||||
Name: secretName,
|
||||
UsernameKey: "login",
|
||||
PasswordKey: "credential",
|
||||
UsernameKey: managementUsernameKey,
|
||||
PasswordKey: managementPasswordKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -196,6 +201,7 @@ func (r *gatedReader) Read(ctx context.Context, ref instance.CredentialReference
|
||||
|
||||
// credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。
|
||||
type credentialFixture struct {
|
||||
config *rest.Config
|
||||
ctx context.Context
|
||||
client *kubernetes.Clientset
|
||||
reader *secretadapter.SecretCredentials
|
||||
@@ -227,7 +233,7 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
|
||||
if err != nil {
|
||||
t.Fatal("cannot create test client")
|
||||
}
|
||||
for _, namespace := range []string{controllerNamespace, "unrelated"} {
|
||||
for _, namespace := range []string{controllerNamespace, unrelatedNamespace} {
|
||||
_, err := client.CoreV1().Namespaces().Create(
|
||||
ctx,
|
||||
&corev1.Namespace{Name: namespace},
|
||||
@@ -260,6 +266,7 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
|
||||
t.Cleanup(service.Close)
|
||||
|
||||
return &credentialFixture{
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
reader: reader,
|
||||
@@ -277,8 +284,8 @@ func (f *credentialFixture) createSecret(t *testing.T, namespace string) {
|
||||
secret := &corev1.Secret{
|
||||
Name: secretName,
|
||||
Data: map[string][]byte{
|
||||
"login": []byte(fixtureUser),
|
||||
"credential": []byte(fixturePassword),
|
||||
managementUsernameKey: []byte(fixtureUser),
|
||||
managementPasswordKey: []byte(fixturePassword),
|
||||
},
|
||||
}
|
||||
if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//go:build integration
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
|
||||
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
databasecontroller "git.ddupan.top/panxiao81/ayatori/internal/database/controller"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
|
||||
"k8s.io/client-go/rest"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
controllerconfig "sigs.k8s.io/controller-runtime/pkg/config"
|
||||
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
const watchRevisionAnnotation = "test.ayatori/observation"
|
||||
|
||||
func TestInstanceControllerWithRealPostgreSQL(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
if _, err := envtest.InstallCRDs(f.config, envtest.CRDInstallOptions{
|
||||
Paths: []string{"../../../../config/crd/bases"}, ErrorIfPathMissing: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scheme := runtime.NewScheme()
|
||||
for _, install := range []func(*runtime.Scheme) error{databasev1alpha1.AddToScheme, corev1.AddToScheme, rbacv1.AddToScheme} {
|
||||
if err := install(scheme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
apiClient, err := client.New(f.config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restricted := instanceControllerRBAC(t, f, apiClient)
|
||||
credentials, err := secretadapter.NewSecretCredentials(restricted, controllerNamespace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := application.NewInstanceService(credentials, postgresql.Connector{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同进程 -count 重复启动测试 manager;生产继续校验 controller 名称唯一。
|
||||
skipRepeatedName := true
|
||||
manager, err := ctrl.NewManager(restricted, ctrl.Options{
|
||||
Scheme: scheme, Cache: databasecontroller.InstanceCacheOptions(controllerNamespace),
|
||||
Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0",
|
||||
Controller: controllerconfig.Controller{SkipNameValidation: &skipRepeatedName},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: controllerNamespace}
|
||||
if err := reconciler.SetupWithManager(manager); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managerContext, stop := context.WithCancel(f.ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- manager.Start(managerContext) }()
|
||||
t.Cleanup(func() {
|
||||
stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Error("Instance manager 未停止")
|
||||
}
|
||||
service.Close()
|
||||
})
|
||||
object := &databasev1alpha1.PostgreSQLInstance{}
|
||||
object.Name = "native-instance"
|
||||
object.Spec.Endpoint = databasev1alpha1.PostgreSQLEndpoint{
|
||||
Host: fixtureHost, HostAddr: fixtureAddress, Port: int32(f.port), SSLMode: "disable",
|
||||
}
|
||||
object.Spec.AdminCredentialRef = databasev1alpha1.AdminCredentialReference{
|
||||
Name: secretName, UsernameKey: managementUsernameKey, PasswordKey: managementPasswordKey,
|
||||
}
|
||||
if err := apiClient.Create(f.ctx, object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
|
||||
// 30 秒轮询前必须收到 Secret 创建事件;实际 controller 使用 namespace Role + metadata watch。
|
||||
useNativeManager(t, f)
|
||||
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
|
||||
before := f.backendIDs(t)
|
||||
f.updateSecret(t, func(secret *corev1.Secret) {
|
||||
secret.Annotations = map[string]string{watchRevisionAnnotation: "changed"}
|
||||
})
|
||||
// 用实际 API 事件触发重验,metadata 改动不应换池。
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if f.backendIDs(t) != before {
|
||||
t.Fatal("无关 Secret metadata 修改重建了连接")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager PASSWORD '"+rotatedPassword+"'")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte("invalid-test-password") })
|
||||
awaitInstanceReason(t, f, apiClient, object, "AuthenticationFailed")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
|
||||
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
|
||||
if f.backendIDs(t) == before {
|
||||
t.Fatal("凭据轮换没有替换旧连接")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager NOCREATEROLE")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recheck" })
|
||||
awaitInstanceReason(t, f, apiClient, object, "InsufficientPrivileges")
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE")
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Annotations[watchRevisionAnnotation] = "recovered" })
|
||||
awaitInstanceReason(t, f, apiClient, object, "ManagementReady")
|
||||
if err := f.client.CoreV1().Secrets(controllerNamespace).Delete(f.ctx, secretName, metav1.DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitInstanceReason(t, f, apiClient, object, "DependencyUnavailable")
|
||||
if f.backendIDs(t) != "" {
|
||||
t.Fatal("Secret 删除后旧连接未释放")
|
||||
}
|
||||
}
|
||||
|
||||
func awaitInstanceReason(t *testing.T, f *credentialFixture, apiClient client.Client,
|
||||
object *databasev1alpha1.PostgreSQLInstance, reason string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := apiClient.Get(f.ctx, client.ObjectKeyFromObject(object), object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
condition := meta.FindStatusCondition(object.Status.Conditions, "Ready")
|
||||
if condition != nil && condition.Reason == reason && condition.ObservedGeneration == object.Generation {
|
||||
if (condition.Status == metav1.ConditionTrue) != (reason == "ManagementReady") {
|
||||
t.Fatal("Ready 与检查结果不一致")
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("watch 未及时推进到 %s", reason)
|
||||
}
|
||||
|
||||
func instanceControllerRBAC(t *testing.T, f *credentialFixture, apiClient client.Client) *rest.Config {
|
||||
t.Helper()
|
||||
roleBytes, err := os.ReadFile("../../../../config/rbac/role.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role := &rbacv1.ClusterRole{}
|
||||
if err := yaml.Unmarshal(roleBytes, role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := apiClient.Create(f.ctx, role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := "instance-controller-test"
|
||||
binding := &rbacv1.ClusterRoleBinding{}
|
||||
binding.Name = user
|
||||
binding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}
|
||||
binding.Subjects = []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: user}}
|
||||
if err := apiClient.Create(f.ctx, binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credentialBytes, err := os.ReadFile("../../../../config/rbac/database_credentials_role.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespaceRole := &rbacv1.Role{}
|
||||
decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader(credentialBytes), 4096)
|
||||
if err := decoder.Decode(namespaceRole); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespaceRole.Namespace = controllerNamespace
|
||||
if err := apiClient.Create(f.ctx, namespaceRole); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespaceBinding := &rbacv1.RoleBinding{}
|
||||
namespaceBinding.Name, namespaceBinding.Namespace = user, controllerNamespace
|
||||
namespaceBinding.RoleRef = rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: namespaceRole.Name}
|
||||
namespaceBinding.Subjects = binding.Subjects
|
||||
if err := apiClient.Create(f.ctx, namespaceBinding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := rest.CopyConfig(f.config)
|
||||
config.Impersonate.UserName = user
|
||||
restrictedClient, err := client.New(config, client.Options{Scheme: apiClient.Scheme()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := &corev1.Secret{}
|
||||
err = restrictedClient.Get(f.ctx, client.ObjectKey{Namespace: unrelatedNamespace, Name: secretName}, secret)
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Fatal("Instance controller 可以跨 namespace 读取 Secret")
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package postgresql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
// 只读取当前执行角色的属性,不能从可继承的角色成员关系推导 CREATEDB/CREATEROLE。
|
||||
// 所有事实来自同一条语句;不创建探针数据库,不修改管理账号或持久 schema。
|
||||
const inspectManagementStatement = `
|
||||
SELECT
|
||||
pg_catalog.current_setting('server_version'),
|
||||
ARRAY(SELECT name::text FROM pg_catalog.pg_available_extensions ORDER BY name),
|
||||
role.rolsuper,
|
||||
role.rolcreaterole,
|
||||
role.rolcreatedb,
|
||||
pg_catalog.pg_is_in_recovery() OR
|
||||
pg_catalog.current_setting('transaction_read_only')::boolean
|
||||
FROM pg_catalog.pg_roles AS role
|
||||
WHERE role.rolname = current_user`
|
||||
|
||||
func (d *database) InspectManagement(ctx context.Context) (application.DatabaseMetadata, error) {
|
||||
var metadata application.DatabaseMetadata
|
||||
var superuser, createRole, createDatabase, readOnly bool
|
||||
err := d.pool.QueryRow(ctx, inspectManagementStatement).Scan(
|
||||
&metadata.Version, &metadata.AvailableExtensions,
|
||||
&superuser, &createRole, &createDatabase, &readOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return application.DatabaseMetadata{}, safeError(err, application.ErrObservation)
|
||||
}
|
||||
checks := instance.ManagementChecks{
|
||||
Connection: instance.CheckPassed,
|
||||
Metadata: instance.CheckPassed,
|
||||
Roles: nativePrivilege(createRole && !superuser),
|
||||
Databases: nativePrivilege(createDatabase && !superuser),
|
||||
// CREATEROLE 可管理自己新建角色的 membership;供应时必须显式取得 SET 权限,
|
||||
// 再以 owner 操作数据库 ACL。这里不授权操作任意导入角色或他人数据库。
|
||||
Grants: nativePrivilege(createRole && createDatabase && !superuser),
|
||||
// 新建数据库 owner 可安装 trusted 扩展。具体扩展仍需逐请求执行和回读,
|
||||
// 非 trusted 扩展不能因出现在 available 列表就视为可安装。
|
||||
Extensions: nativePrivilege(createRole && createDatabase && !superuser),
|
||||
}
|
||||
if readOnly {
|
||||
checks.Databases = instance.CheckUnavailable
|
||||
}
|
||||
metadata.Management = checks
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func nativePrivilege(allowed bool) instance.CheckResult {
|
||||
if allowed {
|
||||
return instance.CheckPassed
|
||||
}
|
||||
return instance.CheckInsufficientPrivileges
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//go:build integration
|
||||
|
||||
package postgresql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
const nativeManager = "native_manager"
|
||||
|
||||
func useNativeManager(t *testing.T, f *credentialFixture) {
|
||||
t.Helper()
|
||||
f.queryPostgres(t, "CREATE ROLE native_manager LOGIN CREATEDB CREATEROLE PASSWORD '"+fixturePassword+"'")
|
||||
f.createSecret(t, controllerNamespace)
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(nativeManager) })
|
||||
}
|
||||
|
||||
func assessManagement(t *testing.T, f *credentialFixture) instance.Snapshot {
|
||||
t.Helper()
|
||||
observation, err := f.service.ObserveManagement(f.ctx, f.target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aggregate, err := instance.Reconstitute(f.target, instance.Snapshot{}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aggregate.BeginValidation(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capabilities, err := observation.Capabilities()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := aggregate.AssessManagement(capabilities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return aggregate.Snapshot()
|
||||
}
|
||||
|
||||
func TestNativeManagementPrivileges(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
useNativeManager(t, f)
|
||||
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
|
||||
t.Fatal("原生非 superuser 管理账号未通过检查")
|
||||
}
|
||||
before := f.backendIDs(t)
|
||||
for _, attribute := range []string{"NOCREATEROLE", "NOCREATEDB"} {
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager "+attribute)
|
||||
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
|
||||
t.Fatal("已有连接忽略了管理权限撤回")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager CREATEROLE CREATEDB")
|
||||
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
|
||||
t.Fatal("管理权限恢复后无法重新就绪")
|
||||
}
|
||||
}
|
||||
if f.backendIDs(t) != before {
|
||||
t.Fatal("权限检查不应要求重建连接才生效")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager SET default_transaction_read_only = on")
|
||||
f.service.Forget(f.target.Identity().Name())
|
||||
if snapshot := assessManagement(t, f); snapshot.Failure != instance.DependencyUnavailable {
|
||||
t.Fatal("只读会话不应标记可供应")
|
||||
}
|
||||
f.queryPostgres(t, "ALTER ROLE native_manager RESET default_transaction_read_only")
|
||||
f.service.Forget(f.target.Identity().Name())
|
||||
if snapshot := assessManagement(t, f); snapshot.Readiness != instance.Ready {
|
||||
t.Fatal("恢复可写会话后没有就绪")
|
||||
}
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementUsernameKey] = []byte(fixtureUser) })
|
||||
if snapshot := assessManagement(t, f); snapshot.Failure != instance.InsufficientPrivileges {
|
||||
t.Fatal("不应以 superuser 绕过非特权账号合同")
|
||||
}
|
||||
reads := 0
|
||||
f.gate.beforeRead = func() {
|
||||
reads++
|
||||
if reads == 2 {
|
||||
f.updateSecret(t, func(secret *corev1.Secret) { secret.Data[managementPasswordKey] = []byte(rotatedPassword) })
|
||||
}
|
||||
}
|
||||
observation, err := f.service.ObserveManagement(f.ctx, f.target)
|
||||
if !errors.Is(err, application.ErrCredentialsChanged) || observation.Target().Validate() == nil {
|
||||
t.Fatal("管理观察期间凭据轮换应丢弃全部能力结果")
|
||||
}
|
||||
if f.backendIDs(t) != "" {
|
||||
t.Fatal("中途轮换后不应保留旧管理连接")
|
||||
}
|
||||
}
|
||||
|
||||
// 以实际非 superuser 会话验证能力矩阵的依据,不用超级用户执行 SQL 模拟管理账号。
|
||||
// 这些固定名称只存在于本测试独占容器,生产观察本身不会创建探针对象。
|
||||
func TestNativeManagementSupplyContract(t *testing.T) {
|
||||
f := newCredentialFixture(t)
|
||||
useNativeManager(t, f)
|
||||
config, err := pgx.ParseConfig("")
|
||||
if err != nil {
|
||||
t.Fatal("无法装配隔离测试连接")
|
||||
}
|
||||
config.Host, config.Port = fixtureAddress, uint16(f.port)
|
||||
config.Database, config.User, config.Password = fixtureUser, nativeManager, fixturePassword
|
||||
config.TLSConfig, config.Fallbacks = nil, nil
|
||||
connection, err := pgx.ConnectConfig(f.ctx, config)
|
||||
if err != nil {
|
||||
t.Fatal("非 superuser 测试连接失败")
|
||||
}
|
||||
t.Cleanup(func() { _ = connection.Close(context.Background()) })
|
||||
execute := func(statement string) {
|
||||
t.Helper()
|
||||
if _, err := connection.Exec(f.ctx, statement); err != nil {
|
||||
t.Fatalf("原生管理能力合同未满足,步骤 %q", statement)
|
||||
}
|
||||
}
|
||||
execute("CREATE ROLE managed_owner LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION")
|
||||
execute("GRANT managed_owner TO native_manager WITH SET TRUE")
|
||||
execute("CREATE DATABASE managed_database OWNER managed_owner")
|
||||
execute("SET ROLE managed_owner")
|
||||
execute("REVOKE CONNECT ON DATABASE managed_database FROM PUBLIC")
|
||||
execute("GRANT CONNECT ON DATABASE managed_database TO managed_owner")
|
||||
execute("RESET ROLE")
|
||||
config.Database = "managed_database"
|
||||
tenantConnection, err := pgx.ConnectConfig(f.ctx, config)
|
||||
if err != nil {
|
||||
t.Fatal("管理账号无法访问其受管数据库")
|
||||
}
|
||||
defer func() { _ = tenantConnection.Close(context.Background()) }()
|
||||
if _, err := tenantConnection.Exec(f.ctx, "SET ROLE managed_owner; CREATE EXTENSION hstore"); err != nil {
|
||||
t.Fatal("owner 无法安装 trusted 扩展")
|
||||
}
|
||||
var installed bool
|
||||
if err := tenantConnection.QueryRow(f.ctx, "SELECT EXISTS (SELECT FROM pg_catalog.pg_extension WHERE extname = 'hstore')").Scan(&installed); err != nil || !installed {
|
||||
t.Fatal("扩展安装后实际回读失败")
|
||||
}
|
||||
if _, err := tenantConnection.Exec(f.ctx, "CREATE EXTENSION file_fdw"); err == nil {
|
||||
t.Fatal("非 trusted 扩展不应被 Ready 隐式授权")
|
||||
}
|
||||
if err := tenantConnection.Close(f.ctx); err != nil {
|
||||
t.Fatal("关闭目标数据库连接失败")
|
||||
}
|
||||
execute("SET ROLE managed_owner")
|
||||
execute("DROP DATABASE managed_database")
|
||||
execute("RESET ROLE")
|
||||
execute("DROP ROLE managed_owner")
|
||||
}
|
||||
Reference in New Issue
Block a user