refactor: 分离公共基础设施与 Database 适配器
This commit is contained in:
@@ -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)
|
||||
|
||||
+14
-2
@@ -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
|
||||
+16
-2
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user