Compare commits

...
3 Commits
Author SHA1 Message Date
panxiao81 212ca4be99 test: verify Instance readiness end to end
E2E Tests / Run on Ubuntu (pull_request) Failing after 1m6s
Tests / Run on Ubuntu (pull_request) Successful in 3m55s
Lint / Run on Ubuntu (pull_request) Successful in 4m19s
2026-09-10 18:15:50 +00:00
panxiao81 97f66669a6 feat: reconcile PostgreSQLInstance dependencies 2026-09-10 18:15:50 +00:00
panxiao81 0101de3ffa docs: define controller connection flags 2026-09-10 18:03:05 +00:00
13 changed files with 688 additions and 25 deletions
+33 -2
View File
@@ -20,6 +20,7 @@ import (
"crypto/tls"
"flag"
"os"
"time"
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
@@ -37,6 +38,7 @@ import (
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/controller"
instanceinitializer "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance"
// +kubebuilder:scaffold:imports
)
@@ -61,6 +63,10 @@ func main() {
var probeAddr string
var secureMetrics bool
var enableHTTP2 bool
var openBaoAddress, openBaoConsumerAddress, openBaoAuthMount, openBaoAuthRole string
var openBaoKVMount, openBaoTenantBasePath, openBaoServiceAccountTokenPath string
var externalSecretStoreName, postgreSQLCABundlePath string
var reconcileTimeout time.Duration
var tlsOpts []func(*tls.Config)
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
@@ -79,6 +85,20 @@ func main() {
flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.")
flag.BoolVar(&enableHTTP2, "enable-http2", false,
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
flag.StringVar(&openBaoAddress, "openbao-address", "", "OpenBao API address used by the controller.")
flag.StringVar(&openBaoConsumerAddress, "openbao-consumer-address", "", "OpenBao API address exposed to consumers.")
flag.StringVar(&openBaoAuthMount, "openbao-auth-mount", "kubernetes", "OpenBao Kubernetes auth mount.")
flag.StringVar(&openBaoAuthRole, "openbao-auth-role", "", "OpenBao Kubernetes auth role.")
flag.StringVar(&openBaoKVMount, "openbao-kv-mount", "kv", "OpenBao KV v2 mount.")
flag.StringVar(&openBaoServiceAccountTokenPath, "openbao-service-account-token-path",
"/var/run/secrets/kubernetes.io/serviceaccount/token",
"Projected service account token used for OpenBao authentication.")
flag.StringVar(&openBaoTenantBasePath, "openbao-tenant-base-path", "postgresql-tenants",
"Tenant credential base path.")
flag.StringVar(&externalSecretStoreName, "external-secret-store-name", "", "ESO ClusterSecretStore name.")
flag.StringVar(&postgreSQLCABundlePath, "postgresql-ca-bundle-path", "", "PostgreSQL CA bundle path.")
flag.DurationVar(&reconcileTimeout, "reconcile-timeout", 30*time.Second,
"Deadline for external operations in one reconcile.")
opts := zap.Options{
Development: true,
}
@@ -87,6 +107,18 @@ func main() {
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
instanceInitializer, err := instanceinitializer.New(instanceinitializer.Config{
OpenBaoAddress: openBaoAddress, OpenBaoConsumerAddress: openBaoConsumerAddress,
OpenBaoAuthMount: openBaoAuthMount, OpenBaoAuthRole: openBaoAuthRole,
ServiceAccountTokenPath: openBaoServiceAccountTokenPath, OpenBaoKVMount: openBaoKVMount,
OpenBaoTenantBasePath: openBaoTenantBasePath, ExternalSecretStoreName: externalSecretStoreName,
PostgreSQLCABundlePath: postgreSQLCABundlePath, Timeout: reconcileTimeout,
})
if err != nil {
setupLog.Error(err, "Invalid controller dependency configuration")
os.Exit(1)
}
// if the enable-http2 flag is false (the default), http/2 should be disabled
// due to its vulnerabilities. More specifically, disabling http/2 will
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
@@ -179,8 +211,7 @@ func main() {
}
if err := (&controller.PostgreSQLInstanceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Initializer: instanceInitializer, Timeout: reconcileTimeout,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "Failed to create controller", "controller", "postgresqlinstance")
os.Exit(1)
+3
View File
@@ -63,6 +63,9 @@ spec:
args:
- --leader-elect
- --health-probe-bind-address=:8081
- --openbao-address=https://openbao.openbao.svc:8200
- --openbao-auth-role=postgresql-tenant-operator
- --external-secret-store-name=openbao
image: controller:latest
name: manager
ports:
+17 -10
View File
@@ -25,19 +25,26 @@
## Controller 配置合同
具体 CLI flag/env 名称将在实现时按下表确定;语义和作用域已经固定:
controller 使用以下 CLI flags。必填项缺失、路径无效或 duration 不为正数时,进程必须
在启动 manager 前失败;不得等到 reconcile 时才逐个资源报告配置错误。
| 配置 | 必填/默认 | 说明 |
| CLI flag | 必填/默认 | 说明 |
| --- | --- | --- |
| OpenBao internal API address | 必填 | controller 可访问的 HTTPS 地址 |
| OpenBao consumer API address | 默认同 internal | 写入 Tenant status,必须能被预期外部消费者解析 |
| OpenBao auth mount | 默认 `kubernetes` | Kubernetes auth mount 名称 |
| OpenBao auth role | 必填 | controller ServiceAccount 对应 role |
| OpenBao KV mount | 默认 `kv` | KV v2 mount;开发可显式用 `secret` |
| `--openbao-address` | 必填 | controller 可访问的 OpenBao API address |
| `--openbao-consumer-address` | 默认同 `--openbao-address` | 写入 Tenant status,必须能被预期外部消费者解析 |
| `--openbao-auth-mount` | `kubernetes` | Kubernetes auth mount 名称 |
| `--openbao-auth-role` | 必填 | controller ServiceAccount 对应 role |
| `--openbao-kv-mount` | `kv` | KV v2 mount;开发可显式用 `secret` |
| `--openbao-service-account-token-path` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | Kubernetes auth 使用的投射 token 文件 |
| `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 |
| ESO ClusterSecretStore name | 必填 | controller 创建的 ExternalSecret 固定引用 |
| CA bundle path | 必填(TLS) | 只读 PEM trust bundle,不含私钥 |
| reconcile timeout | 有安全默认 | 单轮外部操作的总期限 |
| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 |
| `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 |
| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 |
address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/`
在规范化后移除。mount、auth mount 和 base path 都使用 mount-relative path 语义,不以
`/` 开头,不含空段、`.` 或 `..`;base path 还不得编码 KV v2 的 `data`/`metadata`
API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用于明确的开发 fixture。
Tenant 路径固定推导为 `<base-path>/<namespace>/<metadata.name>`。namespace/name 都已通过
Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer
+4 -3
View File
@@ -171,9 +171,10 @@ E2E fixture 必须把测试 PostgreSQL、OpenBao 和 ESO 部署进 Kind,并等
-> 删除 Kind
```
当前 controller 只实现 PostgreSQL registry adapter,尚未实现完整 PostgreSQL/OpenBao
provisioning;现有 E2E 只能验证 CRD、manager Deployment 和 metrics endpoint。上述真实依赖 fixture 应与第一个完整
reconcile 纵向切片一起实现,不能在文档中声称已经通过。
当前 controller 已实现第一条 Instance Ready 纵向链路:E2E fixture 在 Kind 内启动
PostgreSQL/OpenBao,配置 Kubernetes auth,验证管理凭据读取、PostgreSQL 登录、registry
migration 和 Instance Ready。Tenant provisioning、ESO、TLS DNS/IP SAN 与删除路径仍需
后续纵向切片覆盖,不能从 Instance Ready 推断这些合同已经通过。
## 测试数据与泄漏检查
+16 -2
View File
@@ -7,6 +7,8 @@ require (
github.com/jackc/tern/v2 v2.4.3
github.com/onsi/ginkgo/v2 v2.27.4
github.com/onsi/gomega v1.39.0
github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0
github.com/openbao/openbao/api/v2 v2.7.0
k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0
sigs.k8s.io/controller-runtime v0.24.1
@@ -29,6 +31,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
@@ -36,12 +39,21 @@ require (
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/cel-go v0.26.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -51,6 +63,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -60,6 +73,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
@@ -82,13 +96,13 @@ require (
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.48.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
+43 -4
View File
@@ -30,6 +30,8 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -44,6 +46,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -61,6 +65,10 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -80,6 +88,25 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -115,10 +142,16 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -133,6 +166,10 @@ github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0 h1:Fw/pJRMpMTH83pMByCyikRHhxuBDYcnyiNSiK8OqJW0=
github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0/go.mod h1:LkXPq4+8aLyQ+qoNBHcJF7nZFx0PYt2FOu+m7sdpAXU=
github.com/openbao/openbao/api/v2 v2.7.0 h1:3CD1l3tr39nQraCgFGAWA5vYvPFzZoZrt3NL7DMQKAc=
github.com/openbao/openbao/api/v2 v2.7.0/go.mod h1:uXbMoyH2pjSvNyTepinUvLde8pOJB82EuhUCfOKnKbo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -149,6 +186,8 @@ github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05Zp
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -219,8 +258,8 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -231,8 +270,8 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
@@ -18,7 +18,9 @@ package controller
import (
"context"
"errors"
"reflect"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
@@ -32,7 +34,15 @@ import (
// PostgreSQLInstanceReconciler reconciles a PostgreSQLInstance object
type PostgreSQLInstanceReconciler struct {
client.Client
Scheme *runtime.Scheme
Scheme *runtime.Scheme
Initializer PostgreSQLInstanceInitializer
Timeout time.Duration
}
// PostgreSQLInstanceInitializer is the external dependency boundary used by the Instance state machine.
type PostgreSQLInstanceInitializer interface {
Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error)
InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error)
}
// +kubebuilder:rbac:groups=database.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;create;update;patch;delete
@@ -62,6 +72,12 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R
setReconcilingCondition(&instance.Status.Conditions, instance.Generation, phaseResult.reconcilingMessage)
}
var reconcileErr error
if r.Initializer != nil && instance.DeletionTimestamp.IsZero() &&
before.Status.Phase != "" && before.Status.Phase != databasev1alpha1.PostgreSQLInstancePhasePending {
reconcileErr = r.reconcileDependencies(ctx, instance)
}
if !reflect.DeepEqual(before.Status, instance.Status) {
if err := r.Status().Patch(ctx, instance, client.MergeFrom(before)); err != nil {
if apierrors.IsConflict(err) {
@@ -71,7 +87,56 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R
}
}
return ctrl.Result{}, nil
return ctrl.Result{}, reconcileErr
}
func (r *PostgreSQLInstanceReconciler) reconcileDependencies(
ctx context.Context,
instance *databasev1alpha1.PostgreSQLInstance,
) error {
if r.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.Timeout)
defer cancel()
}
if instance.Status.Phase == databasev1alpha1.PostgreSQLInstancePhaseReady &&
instance.Status.ObservedGeneration != instance.Generation {
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating
setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are being validated")
return nil
}
var version string
var err error
switch instance.Status.Phase {
case databasev1alpha1.PostgreSQLInstancePhaseValidating:
version, err = r.Initializer.Validate(ctx, instance)
if err == nil {
instance.Status.PostgreSQLVersion = version
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry
setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "PostgreSQL registry is being initialized")
}
case databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry:
version, err = r.Initializer.InitializeRegistry(ctx, instance)
if err == nil {
instance.Status.PostgreSQLVersion = version
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseReady
instance.Status.ObservedGeneration = instance.Generation
setReadyCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are ready")
}
}
if err != nil {
instance.Status.ObservedGeneration = instance.Generation
reason := databasev1alpha1.ReasonDependencyUnavailable
var categorized interface{ ConditionReason() string }
if errors.As(err, &categorized) {
reason = categorized.ConditionReason()
}
setFailedCondition(&instance.Status.Conditions, instance.Generation, reason,
"instance dependency validation failed")
}
return err
}
// SetupWithManager sets up the controller with the Manager.
+48
View File
@@ -17,6 +17,8 @@ limitations under the License.
package controller
import (
"context"
"errors"
"time"
. "github.com/onsi/ginkgo/v2"
@@ -26,7 +28,53 @@ import (
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
)
type fakeInstanceInitializer struct {
validateVersion string
registryVersion string
err error
}
func (f fakeInstanceInitializer) Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) {
return f.validateVersion, f.err
}
func (f fakeInstanceInitializer) InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) {
return f.registryVersion, f.err
}
var _ = Describe("phase handler state machines", func() {
It("advances an Instance through external validation and registry initialization", func() {
instance := &databasev1alpha1.PostgreSQLInstance{
ObjectMeta: metav1.ObjectMeta{Generation: 3},
Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating},
}
reconciler := &PostgreSQLInstanceReconciler{Initializer: fakeInstanceInitializer{
validateVersion: "17.6", registryVersion: "17.6",
}}
Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(Succeed())
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry))
Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(Succeed())
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseReady))
Expect(instance.Status.PostgreSQLVersion).To(Equal("17.6"))
Expect(instance.Status.ObservedGeneration).To(Equal(int64(3)))
Expect(instance.Status.Conditions).To(ConsistOf(And(
HaveField("Status", metav1.ConditionTrue), HaveField("Reason", databasev1alpha1.ReasonReady),
)))
})
It("keeps the safe phase when a dependency is unavailable", func() {
instance := &databasev1alpha1.PostgreSQLInstance{
ObjectMeta: metav1.ObjectMeta{Generation: 2},
Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating},
}
reconciler := &PostgreSQLInstanceReconciler{Initializer: fakeInstanceInitializer{err: errors.New("unavailable")}}
Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(MatchError("unavailable"))
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
Expect(instance.Status.Conditions).To(ConsistOf(And(
HaveField("Status", metav1.ConditionFalse), HaveField("Reason", databasev1alpha1.ReasonDependencyUnavailable),
)))
})
DescribeTable("dispatches Instance phases",
func(instance *databasev1alpha1.PostgreSQLInstance, expected databasev1alpha1.PostgreSQLInstancePhase, hasMessage bool) {
result := newInstanceStateMachine().reconcile(instance)
+14
View File
@@ -32,3 +32,17 @@ func setReconcilingCondition(conditions *[]metav1.Condition, generation int64, m
Message: message,
})
}
func setReadyCondition(conditions *[]metav1.Condition, generation int64, message string) {
apiMeta.SetStatusCondition(conditions, metav1.Condition{
Type: databasev1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue,
ObservedGeneration: generation, Reason: databasev1alpha1.ReasonReady, Message: message,
})
}
func setFailedCondition(conditions *[]metav1.Condition, generation int64, reason, message string) {
apiMeta.SetStatusCondition(conditions, metav1.Condition{
Type: databasev1alpha1.ConditionTypeReady, Status: metav1.ConditionFalse,
ObservedGeneration: generation, Reason: reason, Message: message,
})
}
+216
View File
@@ -0,0 +1,216 @@
/*
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 instance validates PostgreSQLInstance dependencies and initializes
// the controller registry without exposing administrative credentials.
package instance
import (
"context"
"errors"
"net"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
kubernetesauth "github.com/openbao/openbao/api/auth/kubernetes/v2"
openbao "github.com/openbao/openbao/api/v2"
"k8s.io/apimachinery/pkg/util/validation"
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/postgresql/registry"
)
// Config contains the deployment-level settings needed by Instance readiness.
type Config struct {
OpenBaoAddress string
OpenBaoConsumerAddress string
OpenBaoAuthMount string
OpenBaoAuthRole string
ServiceAccountTokenPath string
OpenBaoKVMount string
OpenBaoTenantBasePath string
ExternalSecretStoreName string
PostgreSQLCABundlePath string
Timeout time.Duration
}
// Initializer implements the Instance validation and registry phases.
type Initializer struct{ config Config }
type failure struct {
reason string
message string
}
func (e failure) Error() string { return e.message }
func (e failure) ConditionReason() string { return e.reason }
func dependencyFailure(message string) error {
return failure{reason: databasev1alpha1.ReasonDependencyUnavailable, message: message}
}
func authenticationFailure(message string) error {
return failure{reason: databasev1alpha1.ReasonAuthenticationFailed, message: message}
}
func privilegeFailure(message string) error {
return failure{reason: databasev1alpha1.ReasonInsufficientPrivileges, message: message}
}
// New validates config and constructs an Initializer.
func New(config Config) (*Initializer, error) {
if config.OpenBaoAddress == "" || config.OpenBaoAuthRole == "" || config.OpenBaoAuthMount == "" ||
config.ServiceAccountTokenPath == "" || config.OpenBaoKVMount == "" || config.OpenBaoTenantBasePath == "" ||
config.ExternalSecretStoreName == "" || config.Timeout <= 0 {
return nil, errors.New("initialize instance dependencies: required configuration is missing")
}
if err := validateAddress(config.OpenBaoAddress); err != nil {
return nil, err
}
if config.OpenBaoConsumerAddress == "" {
config.OpenBaoConsumerAddress = config.OpenBaoAddress
}
if err := validateAddress(config.OpenBaoConsumerAddress); err != nil {
return nil, err
}
if !filepath.IsAbs(config.ServiceAccountTokenPath) || validateRelativePath(config.OpenBaoAuthMount, false) != nil ||
validateRelativePath(config.OpenBaoKVMount, false) != nil || validateRelativePath(config.OpenBaoTenantBasePath, true) != nil ||
len(validation.IsDNS1123Subdomain(config.ExternalSecretStoreName)) != 0 {
return nil, errors.New("initialize instance dependencies: invalid path or resource name configuration")
}
return &Initializer{config: config}, nil
}
// Validate authenticates to OpenBao, reads the administrative credential, and
// verifies that PostgreSQL accepts it. It returns only public server metadata.
func (i *Initializer) Validate(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (string, error) {
pool, err := i.connect(ctx, instance)
if err != nil {
return "", err
}
defer pool.Close()
var version string
if err := pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
return "", errors.New("validate PostgreSQL server metadata")
}
return version, nil
}
// InitializeRegistry repeats dependency validation and applies registry migrations.
func (i *Initializer) InitializeRegistry(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (string, error) {
pool, err := i.connect(ctx, instance)
if err != nil {
return "", err
}
defer pool.Close()
if err := registry.NewStore(pool).Bootstrap(ctx); err != nil {
return "", privilegeFailure("initialize PostgreSQL registry")
}
var version string
if err := pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
return "", errors.New("validate PostgreSQL server metadata")
}
return version, nil
}
func (i *Initializer) connect(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (*pgxpool.Pool, error) {
if instance.Spec.Endpoint.SSLMode != databasev1alpha1.PostgreSQLSSLModeDisable && i.config.PostgreSQLCABundlePath == "" {
return nil, errors.New("configure PostgreSQL TLS: CA bundle path is required")
}
ctx, cancel := context.WithTimeout(ctx, i.config.Timeout)
defer cancel()
clientConfig := openbao.DefaultConfig()
clientConfig.Address = i.config.OpenBaoAddress
clientConfig.Timeout = i.config.Timeout
clientConfig.DisableEnvironment = true
client, err := openbao.NewClient(clientConfig)
if err != nil {
return nil, dependencyFailure("create OpenBao client")
}
auth, err := kubernetesauth.NewKubernetesAuth(
i.config.OpenBaoAuthRole,
kubernetesauth.WithMountPath(i.config.OpenBaoAuthMount),
kubernetesauth.WithServiceAccountTokenPath(i.config.ServiceAccountTokenPath),
)
if err != nil {
return nil, errors.New("configure OpenBao Kubernetes authentication")
}
if secret, err := client.Auth().Login(ctx, auth); err != nil || secret == nil || secret.Auth == nil {
return nil, authenticationFailure("authenticate to OpenBao")
}
secret, err := client.KVv2(i.config.OpenBaoKVMount).Get(ctx, instance.Spec.AdminCredentialRef.Path)
if err != nil {
return nil, privilegeFailure("read PostgreSQL administrative credential")
}
username, usernameOK := secret.Data[instance.Spec.AdminCredentialRef.UsernameKey].(string)
password, passwordOK := secret.Data[instance.Spec.AdminCredentialRef.PasswordKey].(string)
if !usernameOK || !passwordOK || username == "" || password == "" {
return nil, errors.New("read PostgreSQL administrative credential fields")
}
connectionURL := &url.URL{
Scheme: "postgresql",
User: url.UserPassword(username, password),
Host: net.JoinHostPort(instance.Spec.Endpoint.Host, strconv.Itoa(int(instance.Spec.Endpoint.Port))),
Path: instance.Spec.Endpoint.Database,
}
query := connectionURL.Query()
query.Set("sslmode", string(instance.Spec.Endpoint.SSLMode))
if instance.Spec.Endpoint.SSLMode != databasev1alpha1.PostgreSQLSSLModeDisable {
query.Set("sslrootcert", i.config.PostgreSQLCABundlePath)
}
connectionURL.RawQuery = query.Encode()
poolConfig, err := pgxpool.ParseConfig(connectionURL.String())
if err != nil {
return nil, errors.New("configure PostgreSQL connection")
}
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, errors.New("connect to PostgreSQL")
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, authenticationFailure("authenticate to PostgreSQL")
}
return pool, nil
}
func validateAddress(value string) error {
parsed, err := url.Parse(value)
if err != nil || !parsed.IsAbs() || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" ||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("initialize instance dependencies: invalid OpenBao address")
}
return nil
}
func validateRelativePath(value string, rejectAPILayer bool) error {
for index, segment := range strings.Split(value, "/") {
if segment == "" || segment == "." || segment == ".." ||
(rejectAPILayer && index == 0 && (segment == "data" || segment == "metadata")) {
return errors.New("invalid mount-relative path")
}
}
return nil
}
+44
View File
@@ -0,0 +1,44 @@
/*
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 instance
import (
"testing"
"time"
)
func TestNewValidatesDeploymentConfiguration(t *testing.T) {
valid := Config{
OpenBaoAddress: "http://openbao:8200", OpenBaoAuthMount: "kubernetes", OpenBaoAuthRole: "controller",
ServiceAccountTokenPath: "/var/run/secrets/token", OpenBaoKVMount: "secret",
OpenBaoTenantBasePath: "postgresql-tenants", ExternalSecretStoreName: "openbao", Timeout: 30 * time.Second,
}
if _, err := New(valid); err != nil {
t.Fatalf("New(valid) error = %v", err)
}
invalid := valid
invalid.OpenBaoTenantBasePath = "metadata/tenants"
if _, err := New(invalid); err == nil {
t.Fatal("New() accepted a KV v2 API-layer base path")
}
invalid = valid
invalid.OpenBaoAddress = "http://user:password@openbao:8200?token=secret"
if _, err := New(invalid); err == nil {
t.Fatal("New() accepted credentials in the OpenBao address")
}
}
+85 -2
View File
@@ -36,6 +36,8 @@ import (
// namespace where the project is deployed in
const namespace = "postgresql-tenant-operator-system"
const dependencyNamespace = "postgresql-tenant-operator-e2e"
// serviceAccountName created for the project
const serviceAccountName = "postgresql-tenant-operator-controller-manager"
@@ -52,9 +54,19 @@ var _ = Describe("Manager", Ordered, func() {
// enforce the restricted security policy to the namespace, installing CRDs,
// and deploying the controller.
BeforeAll(func() {
By("creating manager namespace")
cmd := exec.Command("kubectl", "create", "ns", namespace)
By("deploying disposable PostgreSQL and OpenBao dependencies")
cmd := exec.Command("kubectl", "apply", "-f", "test/e2e/fixtures/instance-dependencies.yaml")
_, err := utils.Run(cmd)
Expect(err).NotTo(HaveOccurred(), "Failed to deploy Instance dependencies")
cmd = exec.Command("kubectl", "wait", "deployment/postgres", "deployment/openbao", "--for=condition=Available",
"-n", dependencyNamespace, "--timeout=3m")
_, err = utils.Run(cmd)
Expect(err).NotTo(HaveOccurred(), "Instance dependencies did not become available")
configureOpenBao()
By("creating manager namespace")
cmd = exec.Command("kubectl", "create", "ns", namespace)
_, err = utils.Run(cmd)
Expect(err).NotTo(HaveOccurred(), "Failed to create namespace")
By("labeling the namespace to enforce the restricted security policy")
@@ -72,6 +84,13 @@ var _ = Describe("Manager", Ordered, func() {
cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", managerImage))
_, err = utils.Run(cmd)
Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager")
By("configuring the controller for disposable dependencies")
argsPatch := `{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081","--openbao-address=http://openbao.postgresql-tenant-operator-e2e.svc:8200","--openbao-auth-role=postgresql-tenant-operator","--openbao-kv-mount=secret","--external-secret-store-name=openbao"]}]}}}}`
cmd = exec.Command("kubectl", "patch", "deployment", "postgresql-tenant-operator-controller-manager",
"-n", namespace, "--type=strategic", "-p", argsPatch)
_, err = utils.Run(cmd)
Expect(err).NotTo(HaveOccurred(), "Failed to configure controller dependency flags")
})
// After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs,
@@ -92,6 +111,10 @@ var _ = Describe("Manager", Ordered, func() {
By("removing manager namespace")
cmd = exec.Command("kubectl", "delete", "ns", namespace)
_, _ = utils.Run(cmd)
By("removing disposable Instance dependencies")
cmd = exec.Command("kubectl", "delete", "-f", "test/e2e/fixtures/instance-dependencies.yaml", "--ignore-not-found")
_, _ = utils.Run(cmd)
})
// After each test, check for failures and collect logs, events,
@@ -268,6 +291,47 @@ var _ = Describe("Manager", Ordered, func() {
Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed())
})
It("should make an Instance Ready after validating OpenBao and initializing PostgreSQL", func() {
serviceIP, err := utils.Run(exec.Command("kubectl", "get", "service", "postgres", "-n", dependencyNamespace,
"-o", "jsonpath={.spec.clusterIP}"))
Expect(err).NotTo(HaveOccurred())
manifest := fmt.Sprintf(`apiVersion: database.ddupan.top/v1alpha1
kind: PostgreSQLInstance
metadata:
name: e2e
spec:
endpoint:
host: postgres.%s.svc
hostaddr: %s
port: 5432
database: postgres
sslMode: disable
adminCredentialRef:
path: infrastructure/postgresql/admin
`, dependencyNamespace, serviceIP)
manifestPath := filepath.Join(GinkgoT().TempDir(), "instance.yaml")
Expect(os.WriteFile(manifestPath, []byte(manifest), 0o600)).To(Succeed())
_, err = utils.Run(exec.Command("kubectl", "apply", "-f", manifestPath))
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
phase, err := utils.Run(exec.Command("kubectl", "get", "postgresqlinstance", "e2e",
"-o", "jsonpath={.status.phase}"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(phase).To(Equal("Ready"))
ready, err := utils.Run(exec.Command("kubectl", "get", "postgresqlinstance", "e2e",
"-o", `jsonpath={.status.conditions[?(@.type=="Ready")].status}`))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ready).To(Equal("True"))
}).Should(Succeed())
output, err := utils.Run(exec.Command("kubectl", "exec", "deployment/postgres", "-n", dependencyNamespace,
"--", "psql", "-U", "postgres", "-d", "postgres", "-Atc",
"SELECT to_regclass('postgresql_tenant_operator.tenant_ownership') IS NOT NULL"))
Expect(err).NotTo(HaveOccurred())
Expect(output).To(Equal("t\n"))
})
// +kubebuilder:scaffold:e2e-webhooks-checks
// TODO: Customize the e2e test suite with scenarios specific to your project.
@@ -282,6 +346,25 @@ var _ = Describe("Manager", Ordered, func() {
})
})
func configureOpenBao() {
By("configuring OpenBao Kubernetes auth and the PostgreSQL administrative credential")
script := `set -eu
bao auth enable kubernetes
bao write auth/kubernetes/config kubernetes_host=https://kubernetes.default.svc \
token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
printf 'path "secret/data/infrastructure/postgresql/admin" { capabilities = ["read"] }\n' | bao policy write postgresql-tenant-operator -
bao write auth/kubernetes/role/postgresql-tenant-operator \
bound_service_account_names=postgresql-tenant-operator-controller-manager \
bound_service_account_namespaces=postgresql-tenant-operator-system \
policies=postgresql-tenant-operator ttl=5m
bao kv put secret/infrastructure/postgresql/admin username=postgres password=postgres-e2e-only
`
cmd := exec.Command("kubectl", "exec", "deployment/openbao", "-n", dependencyNamespace, "--", "sh", "-ec", script)
_, err := utils.Run(cmd)
Expect(err).NotTo(HaveOccurred(), "Failed to configure OpenBao")
}
// serviceAccountToken returns a token for the specified service account in the given namespace.
// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request
// and parsing the resulting token from the API response.
@@ -0,0 +1,98 @@
apiVersion: v1
kind: Namespace
metadata:
name: postgresql-tenant-operator-e2e
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: openbao
namespace: postgresql-tenant-operator-e2e
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: postgresql-tenant-operator-e2e-openbao-token-reviewer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:auth-delegator
subjects:
- kind: ServiceAccount
name: openbao
namespace: postgresql-tenant-operator-e2e
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: postgresql-tenant-operator-e2e
spec:
replicas: 1
selector:
matchLabels: {app: postgres}
template:
metadata:
labels: {app: postgres}
spec:
containers:
- name: postgres
image: postgres:17-alpine
env:
- {name: POSTGRES_USER, value: postgres}
- {name: POSTGRES_PASSWORD, value: postgres-e2e-only}
ports:
- {name: postgresql, containerPort: 5432}
readinessProbe:
exec: {command: [pg_isready, -U, postgres]}
initialDelaySeconds: 2
periodSeconds: 2
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: postgresql-tenant-operator-e2e
spec:
selector: {app: postgres}
ports:
- {name: postgresql, port: 5432, targetPort: postgresql}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: openbao
namespace: postgresql-tenant-operator-e2e
spec:
replicas: 1
selector:
matchLabels: {app: openbao}
template:
metadata:
labels: {app: openbao}
spec:
serviceAccountName: openbao
containers:
- name: openbao
image: openbao/openbao:2.6.1
args: [server, -dev]
env:
- {name: BAO_DEV_LISTEN_ADDRESS, value: "0.0.0.0:8200"}
- {name: BAO_DEV_ROOT_TOKEN_ID, value: dev-only-root-token}
- {name: BAO_ADDR, value: "http://127.0.0.1:8200"}
ports:
- {name: http, containerPort: 8200}
readinessProbe:
exec: {command: [bao, status]}
initialDelaySeconds: 2
periodSeconds: 2
---
apiVersion: v1
kind: Service
metadata:
name: openbao
namespace: postgresql-tenant-operator-e2e
spec:
selector: {app: openbao}
ports:
- {name: http, port: 8200, targetPort: http}