refactor: 分离公共基础设施与 Database 适配器
Verify / lint (pull_request) Successful in 13m32s
Verify / test (pull_request) Successful in 14m5s
Verify / database-integration (pull_request) Successful in 15m1s

This commit is contained in:
2026-09-25 21:55:24 +00:00
parent c20f8930f0
commit 65c60cca45
15 changed files with 193 additions and 70 deletions
@@ -22,10 +22,8 @@ import (
"errors"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation"
typedcore "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
@@ -34,18 +32,16 @@ import (
// SecretCredentials 直接读取 API server,不将 Secret 数据纳入共享 informer cache。
// namespace 在装配时固定,Instance 不能选择跨 namespace 读取。
type SecretCredentials struct {
secrets typedcore.SecretInterface
reader client.Reader
namespace string
}
func NewSecretCredentials(config *rest.Config, namespace string) (*SecretCredentials, error) {
if config == nil || len(validation.IsDNS1123Label(namespace)) != 0 {
return nil, errors.New("valid controller namespace and API configuration required")
// NewSecretCredentials 要求注入 manager.GetAPIReader() 或等价直连 reader,不可使用缓存 reader。
func NewSecretCredentials(reader client.Reader, namespace string) (*SecretCredentials, error) {
if reader == nil || len(validation.IsDNS1123Label(namespace)) != 0 {
return nil, errors.New("valid controller namespace and API reader required")
}
client, err := typedcore.NewForConfig(config)
if err != nil {
return nil, application.ErrCredentialsUnavailable
}
return &SecretCredentials{secrets: client.Secrets(namespace)}, nil
return &SecretCredentials{reader: reader, namespace: namespace}, nil
}
func (r *SecretCredentials) Read(ctx context.Context, ref instance.CredentialReference) (application.Credentials, error) {
@@ -53,7 +49,8 @@ func (r *SecretCredentials) Read(ctx context.Context, ref instance.CredentialRef
return application.Credentials{}, application.ErrCredentialsInvalid
}
keys := ref.Values()
secret, err := r.secrets.Get(ctx, keys.Name, metav1.GetOptions{})
secret := &corev1.Secret{}
err := r.reader.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: keys.Name}, secret)
if err != nil {
return application.Credentials{}, application.ErrCredentialsUnavailable
}
@@ -51,7 +51,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/envtest"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
"git.ddupan.top/panxiao81/ayatori/internal/infra/openbao"
)
const (
@@ -232,6 +232,40 @@ func newKubernetesAuthFixture(t *testing.T) *kubernetesAuthFixture {
}
}
const authRole = "controller"
var testIdentity = openbao.KubernetesIdentity{Namespace: authNamespace, ServiceAccount: authRole, Audience: authAudience}
func waitForAuthentication(t *testing.T, check func() bool) {
t.Helper()
deadline := time.NewTimer(20 * time.Second)
defer deadline.Stop()
for !check() {
select {
case <-deadline.C:
t.Fatal("authentication condition timed out")
case <-time.After(20 * time.Millisecond):
}
}
}
func startSession(t *testing.T, session interface{ Start(context.Context) error }) context.CancelFunc {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- session.Start(ctx) }()
t.Cleanup(func() {
cancel()
select {
case <-done:
case <-time.After(20 * time.Second):
t.Error("authentication did not stop")
}
})
return cancel
}
// 此处验证公共认证会话与 Database 凭据适配器的跨层集成。
func TestKubernetesSessionWithRealTokenReview(t *testing.T) {
api := newKubernetesAuthFixture(t)
ctx := t.Context()
@@ -49,12 +49,11 @@ type Credentials struct {
}
// NewCredentials 不登录、不读取环境 token。调用方必须提供专用的已认证 client。
// 禁用 SDK 写入重试,防止第一次结果丢失后被 CAS 错误掩盖。
// client 由公共 infra 禁用自动重试,防止第一次结果丢失后被 CAS 错误掩盖。
func NewCredentials(client *bao.Client, mount, basePath string) (*Credentials, error) {
if client == nil || !validPath(mount) || !validPath(basePath) {
return nil, ErrInvalidLocation
}
client.SetMaxRetries(0)
return &Credentials{kv: client.KVv2(mount), basePath: basePath}, nil
}
@@ -104,6 +104,7 @@ func fixtureClient(t *testing.T, address string) *bao.Client {
t.Helper()
config := bao.DefaultConfig()
config.Address = address
config.MaxRetries = 0
client, err := bao.NewClient(config)
if err != nil {
t.Fatal("cannot construct fixture client")
@@ -32,6 +32,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
kubeclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
@@ -244,7 +245,11 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
}
}
reader, err := secretadapter.NewSecretCredentials(config, controllerNamespace)
apiReader, err := kubeclient.New(config, kubeclient.Options{})
if err != nil {
t.Fatal(err)
}
reader, err := secretadapter.NewSecretCredentials(apiReader, controllerNamespace)
if err != nil {
t.Fatal(err)
}
@@ -252,7 +257,11 @@ func newCredentialFixture(t *testing.T) *credentialFixture {
if err != nil {
t.Fatal(err)
}
deniedReader, err := secretadapter.NewSecretCredentials(user.Config(), controllerNamespace)
deniedAPIReader, err := kubeclient.New(user.Config(), kubeclient.Options{})
if err != nil {
t.Fatal(err)
}
deniedReader, err := secretadapter.NewSecretCredentials(deniedAPIReader, controllerNamespace)
if err != nil {
t.Fatal(err)
}
@@ -50,14 +50,6 @@ func TestInstanceControllerWithRealPostgreSQL(t *testing.T) {
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{
@@ -68,6 +60,14 @@ func TestInstanceControllerWithRealPostgreSQL(t *testing.T) {
if err != nil {
t.Fatal(err)
}
credentials, err := secretadapter.NewSecretCredentials(manager.GetAPIReader(), controllerNamespace)
if err != nil {
t.Fatal(err)
}
service, err := application.NewInstanceService(credentials, postgresql.Connector{})
if err != nil {
t.Fatal(err)
}
reconciler := &databasecontroller.InstanceReconciler{Observer: service, SecretNamespace: controllerNamespace}
if err := reconciler.SetupWithManager(manager); err != nil {
t.Fatal(err)
@@ -19,6 +19,7 @@ package openbao
import (
"context"
"errors"
"regexp"
"strings"
"sync"
"sync/atomic"
@@ -34,6 +35,17 @@ import (
var ErrAuthenticationConfiguration = errors.New("invalid OpenBao Kubernetes authentication configuration")
var authPathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
func validAuthMount(mount string) bool {
for segment := range strings.SplitSeq(mount, "/") {
if !authPathSegment.MatchString(segment) {
return false
}
}
return true
}
// KubernetesSession 为专用 SDK client 维护短期登录,不持久化或对外返回 token。
// 每次登录通过 manager 的 Kubernetes 身份申请新的 SA JWT,不依赖 controller 的部署位置。
// 续期调度由官方 LifetimeWatcher 负责,不实现自己的 lease 算法。
@@ -47,7 +59,7 @@ type KubernetesSession struct {
ready atomic.Bool
}
// KubernetesIdentity 是部署固定的登录目标,不由 Tenant 选择。
// KubernetesIdentity 是部署固定的登录目标,不由业务请求选择。
type KubernetesIdentity struct {
Namespace string
ServiceAccount string
@@ -60,7 +72,7 @@ func NewKubernetesSession(
mount, role string,
identity KubernetesIdentity,
) (*KubernetesSession, error) {
if client == nil || kubernetes == nil || !validPath(mount) || !pathSegment.MatchString(role) ||
if client == nil || kubernetes == nil || !validAuthMount(mount) || !authPathSegment.MatchString(role) ||
len(validation.IsDNS1123Label(identity.Namespace)) != 0 ||
len(validation.IsDNS1123Subdomain(identity.ServiceAccount)) != 0 || strings.TrimSpace(identity.Audience) == "" {
return nil, ErrAuthenticationConfiguration
@@ -25,6 +25,7 @@ import (
"testing"
"time"
bao "github.com/openbao/openbao/api/v2"
authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
@@ -33,13 +34,26 @@ import (
"k8s.io/client-go/rest"
kubeclient "sigs.k8s.io/controller-runtime/pkg/client"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
"git.ddupan.top/panxiao81/ayatori/internal/infra/openbao"
)
const (
authRole = "controller"
authRole = "controller"
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
)
func fixtureClient(t *testing.T, address string) *bao.Client {
t.Helper()
config := bao.NewConfig()
config.Address = address
client, err := bao.NewClient(config)
if err != nil {
t.Fatal("cannot construct fixture client")
}
client.SetToken(fixtureToken)
return client
}
var testIdentity = openbao.KubernetesIdentity{Namespace: "bao-controller", ServiceAccount: authRole, Audience: "openbao"}
func authenticationClient(t *testing.T, address string) kubeclient.Client {
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package openbao 管理控制面共享的 OpenBao 连接与认证,不依赖任何产品领域。
package openbao
import (
"errors"
"net/url"
"strings"
"time"
bao "github.com/openbao/openbao/api/v2"
)
// NewClient 创建供读写适配器共享的官方 SDK client;身份由 KubernetesSession 管理。
func NewClient(addressURL, caCert string) (*bao.Client, error) {
address, err := url.Parse(addressURL)
if err != nil || address.Scheme != "https" || address.Host == "" || address.User != nil ||
address.RawQuery != "" || address.ForceQuery || address.Fragment != "" ||
(address.Path != "" && address.Path != "/") {
return nil, errors.New("OpenBao address must be an absolute HTTPS URL without credentials, query, fragment or path")
}
// NewConfig 不读取 BAO_TOKEN/BAO_SKIP_VERIFY 等环境配置,不允许旁路 Kubernetes 身份或 TLS。
config := bao.NewConfig()
config.Address = strings.TrimSuffix(addressURL, "/")
// 公共读写 client 不自动重试:写入结果不确定时交由具体用例决定恢复行为。
config.MaxRetries = 0
config.Timeout = 15 * time.Second
if config.Error != nil || config.ConfigureTLS(&bao.TLSConfig{CACert: caCert}) != nil {
return nil, errors.New("cannot configure OpenBao TLS trust")
}
client, err := bao.NewClient(config)
if err != nil {
return nil, errors.New("cannot construct OpenBao client")
}
return client, nil
}
+67
View File
@@ -0,0 +1,67 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package openbao_test
import (
"net/http"
"net/http/httptest"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/infra/openbao"
)
func TestOpenBaoClientDoesNotUseEnvironmentIdentityOrAddress(t *testing.T) {
t.Setenv("BAO_TOKEN", "TEST-ONLY-unwanted-static-token")
t.Setenv("BAO_ADDR", "http://unwanted.invalid")
t.Setenv("BAO_SKIP_VERIFY", "true")
client, err := openbao.NewClient("https://bao.example/", "")
if err != nil {
t.Fatal(err)
}
if client.Address() != "https://bao.example" || client.Token() != "" {
t.Fatal("ambient environment replaced the explicit connection or identity")
}
if client.MaxRetries() != 0 {
t.Fatal("shared client must not automatically retry uncertain writes")
}
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
untrusted, err := openbao.NewClient(server.URL, "")
if err != nil {
t.Fatal(err)
}
untrusted.SetMaxRetries(0)
if _, err := untrusted.Sys().HealthWithContext(t.Context()); err == nil {
t.Fatal("BAO_SKIP_VERIFY bypassed TLS validation")
}
}
func TestOpenBaoClientRejectsUnsafeConfiguration(t *testing.T) {
for _, address := range []string{
"", "http://bao.example", "https://user:[email protected]",
"https://bao.example/?token=secret", "https://bao.example/#secret", "https://bao.example/path",
} {
if _, err := openbao.NewClient(address, ""); err == nil {
t.Fatal("accepted unsafe OpenBao address")
}
}
if _, err := openbao.NewClient("https://bao.example", "/nonexistent/fixture-ca"); err == nil {
t.Fatal("accepted missing explicit CA")
}
}