21 Commits
Author SHA1 Message Date
panxiao81 dae546e58e Merge pull request 'ci: 按 pod/vm 标签分配测试 runner' (#14) from ci/runner-labels into main
E2E Tests / Run on Ubuntu (push) Failing after 2s
Tests / Run on Ubuntu (push) Failing after 8m29s
Lint / Run on Ubuntu (push) Successful in 11m24s
Reviewed-on: #14
2026-09-20 20:02:43 +00:00
panxiao81 2270e013f0 ci: route lint and tests to pod runners and e2e to vm
E2E Tests / Run on Ubuntu (pull_request) Failing after 2s
Lint / Run on Ubuntu (pull_request) Failing after 1m2s
Tests / Run on Ubuntu (pull_request) Failing after 1m3s
2026-09-16 17:46:15 +00:00
panxiao81 a84c37d842 Merge pull request 'feat: Instance 聚合最小生命周期与 checkpoint' (#13) from feature/instance-lifecycle-checkpoints into main
E2E Tests / Run on Ubuntu (push) Failing after 31s
Lint / Run on Ubuntu (push) Failing after 1m3s
Tests / Run on Ubuntu (push) Successful in 3m42s
Reviewed-on: #13
2026-09-16 17:40:20 +00:00
panxiao81 0c66760628 feat: add Instance lifecycle checkpoints
Tests / Run on Ubuntu (pull_request) Failing after 1m33s
Lint / Run on Ubuntu (pull_request) Failing after 1m36s
E2E Tests / Run on Ubuntu (pull_request) Failing after 2m14s
2026-09-16 11:24:41 +00:00
panxiao81 da22b6b266 Merge pull request 'feat: Instance 配置与观察目标值对象' (#12) from feature/instance-observation-target into main
E2E Tests / Run on Ubuntu (push) Failing after 57s
Tests / Run on Ubuntu (push) Successful in 5m5s
Lint / Run on Ubuntu (push) Successful in 6m29s
Reviewed-on: #12
2026-09-16 11:20:37 +00:00
panxiao81 e1c6eec91a feat: add Instance definition and observation target
E2E Tests / Run on Ubuntu (pull_request) Failing after 31s
Tests / Run on Ubuntu (pull_request) Successful in 5m10s
Lint / Run on Ubuntu (pull_request) Successful in 6m50s
2026-09-16 11:15:29 +00:00
panxiao81 fdd43f32d1 Merge pull request 'feat: 基于观察结果的 Instance 扩展支持判定' (#11) from feature/instance-extension-support into main
E2E Tests / Run on Ubuntu (push) Failing after 35s
Tests / Run on Ubuntu (push) Successful in 4m49s
Lint / Run on Ubuntu (push) Successful in 5m28s
Reviewed-on: #11
2026-09-16 11:03:10 +00:00
panxiao81 87ec7896b5 feat: add observed Instance extension support checks
E2E Tests / Run on Ubuntu (pull_request) Failing after 47s
Tests / Run on Ubuntu (pull_request) Successful in 5m8s
Lint / Run on Ubuntu (pull_request) Successful in 5m48s
2026-09-14 15:54:01 +00:00
panxiao81 b24b85c31f Merge pull request 'docs: 按实例实际能力判定扩展支持' (#10) from docs/instance-extension-contract into main
E2E Tests / Run on Ubuntu (push) Failing after 49s
Tests / Run on Ubuntu (push) Failing after 5m7s
Lint / Run on Ubuntu (push) Successful in 7m5s
Reviewed-on: #10
2026-09-14 14:25:52 +00:00
panxiao81 55869acfd5 docs: base extension support on instance capabilities
E2E Tests / Run on Ubuntu (pull_request) Failing after 33s
Tests / Run on Ubuntu (pull_request) Successful in 5m33s
Lint / Run on Ubuntu (pull_request) Successful in 7m40s
2026-09-14 14:18:00 +00:00
panxiao81 ed6fd7bb85 Merge pull request 'feat: Instance Identity 与 Revision 值对象及测试' (#9) from feature/instance-identity-values into main
E2E Tests / Run on Ubuntu (push) Failing after 46s
Tests / Run on Ubuntu (push) Successful in 6m2s
Lint / Run on Ubuntu (push) Successful in 7m3s
Reviewed-on: #9
2026-09-14 10:43:53 +00:00
panxiao81 5521d5f98d feat: add Instance identity and revision value objects
E2E Tests / Run on Ubuntu (pull_request) Failing after 30s
Tests / Run on Ubuntu (pull_request) Successful in 5m11s
Lint / Run on Ubuntu (pull_request) Successful in 7m32s
2026-09-14 10:18:42 +00:00
panxiao81 a0f62b1e01 Merge pull request 'feat: Instance CredentialReference 值对象与校验测试' (#8) from feature/instance-credential-reference into main
E2E Tests / Run on Ubuntu (push) Failing after 1m4s
Tests / Run on Ubuntu (push) Successful in 7m6s
Lint / Run on Ubuntu (push) Successful in 8m19s
Reviewed-on: #8
2026-09-13 15:55:31 +00:00
panxiao81 536d6e665f feat: add Instance credential reference value object
E2E Tests / Run on Ubuntu (pull_request) Failing after 41s
Tests / Run on Ubuntu (pull_request) Successful in 6m11s
Lint / Run on Ubuntu (pull_request) Successful in 8m31s
2026-09-13 15:52:17 +00:00
panxiao81 6db3f756b2 Merge pull request 'feat: Instance Endpoint 值对象与纯校验测试' (#7) from feature/instance-endpoint-values into main
E2E Tests / Run on Ubuntu (push) Failing after 59s
Tests / Run on Ubuntu (push) Successful in 5m4s
Lint / Run on Ubuntu (push) Successful in 5m34s
Reviewed-on: #7
2026-09-13 15:44:31 +00:00
panxiao81 bf7b8e4066 feat: add Instance endpoint value object
E2E Tests / Run on Ubuntu (pull_request) Failing after 1m1s
Tests / Run on Ubuntu (pull_request) Successful in 4m28s
Lint / Run on Ubuntu (pull_request) Successful in 5m10s
2026-09-13 15:38:07 +00:00
panxiao81 012efc1581 Merge pull request 'Instance 阶段性基线:Ready 初版与领域设计' (#6) from feature/openbao-adapter into main
E2E Tests / Run on Ubuntu (push) Failing after 1m0s
Tests / Run on Ubuntu (push) Successful in 7m3s
Lint / Run on Ubuntu (push) Successful in 8m8s
2026-09-13 15:26:35 +00:00
panxiao81 bb87316773 docs: clarify Instance identity and deletion semantics
E2E Tests / Run on Ubuntu (pull_request) Failing after 38s
Tests / Run on Ubuntu (pull_request) Successful in 6m52s
Lint / Run on Ubuntu (pull_request) Successful in 8m23s
2026-09-13 15:20:26 +00:00
panxiao81 a0afbf992c docs: define management connection refresh on Secret changes
E2E Tests / Run on Ubuntu (pull_request) Failing after 1m14s
Tests / Run on Ubuntu (pull_request) Successful in 4m27s
Lint / Run on Ubuntu (pull_request) Successful in 4m47s
2026-09-13 15:05:54 +00:00
panxiao81 953878ac47 docs: define Instance domain model and admin Secret boundary
E2E Tests / Run on Ubuntu (pull_request) Failing after 1m21s
Tests / Run on Ubuntu (pull_request) Successful in 4m12s
Lint / Run on Ubuntu (pull_request) Successful in 4m37s
2026-09-13 12:17:03 +00:00
panxiao81 59303f5276 refactor: introduce Instance connection lifecycle service
E2E Tests / Run on Ubuntu (pull_request) Failing after 44s
Tests / Run on Ubuntu (pull_request) Successful in 3m56s
Lint / Run on Ubuntu (pull_request) Successful in 4m18s
2026-09-11 16:10:16 +00:00
40 changed files with 2062 additions and 684 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
permissions: permissions:
contents: read contents: read
name: Run on Ubuntu name: Run on Ubuntu
runs-on: self-hosted runs-on: [self-hosted, pod]
steps: steps:
- name: Clone the code - name: Clone the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
permissions: permissions:
contents: read contents: read
name: Run on Ubuntu name: Run on Ubuntu
runs-on: self-hosted runs-on: [self-hosted, vm]
steps: steps:
- name: Clone the code - name: Clone the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
permissions: permissions:
contents: read contents: read
name: Run on Ubuntu name: Run on Ubuntu
runs-on: self-hosted runs-on: [self-hosted, pod]
steps: steps:
- name: Clone the code - name: Clone the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+1 -1
View File
@@ -19,7 +19,7 @@ COPY . .
# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager ./cmd RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
# Use distroless as minimal base image to package the manager binary # Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details # Refer to https://github.com/GoogleContainerTools/distroless for more details
+2 -2
View File
@@ -132,11 +132,11 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration
.PHONY: build .PHONY: build
build: manifests generate fmt vet ## Build manager binary. build: manifests generate fmt vet ## Build manager binary.
go build -o bin/manager ./cmd go build -o bin/manager cmd/main.go
.PHONY: run .PHONY: run
run: manifests generate fmt vet ## Run a controller from your host. run: manifests generate fmt vet ## Run a controller from your host.
go run ./cmd go run ./cmd/main.go
# If you wish to build the manager image targeting other platforms you can use the --platform flag. # If you wish to build the manager image targeting other platforms you can use the --platform flag.
# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.
-71
View File
@@ -1,71 +0,0 @@
/*
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 main
import (
"errors"
"flag"
"strings"
"time"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/openbao"
bao "github.com/openbao/openbao/api/v2"
)
// dependencyOptions belongs to process startup, never to an Instance.
// Configuration is immutable after assembly; no configuration hot reload is enabled.
type dependencyOptions struct {
address string
consumerAddress string
authMount string
authRole string
kvMount string
tenantBasePath string
externalSecretStoreName string
postgreSQLCABundlePath string
timeout time.Duration
}
func (o *dependencyOptions) bindFlags(flags *flag.FlagSet) {
flags.StringVar(&o.address, "openbao-address", "", "OpenBao API address.")
flags.StringVar(&o.consumerAddress, "openbao-consumer-address", "", "Consumer OpenBao API address.")
flags.StringVar(&o.authMount, "openbao-auth-mount", "kubernetes", "OpenBao Kubernetes auth mount.")
flags.StringVar(&o.authRole, "openbao-auth-role", "", "OpenBao Kubernetes auth role.")
flags.StringVar(&o.kvMount, "openbao-kv-mount", "kv", "OpenBao KV v2 mount.")
flags.StringVar(&o.tenantBasePath, "openbao-tenant-base-path", "postgresql-tenants", "Tenant credential prefix.")
flags.StringVar(&o.externalSecretStoreName, "external-secret-store-name", "", "ESO ClusterSecretStore name.")
flags.StringVar(&o.postgreSQLCABundlePath, "postgresql-ca-bundle-path", "", "PostgreSQL CA bundle path.")
flags.DurationVar(&o.timeout, "reconcile-timeout", 30*time.Second, "External operation deadline.")
}
func (o dependencyOptions) credentials() (*openbao.Credentials, error) {
if o.address == "" || o.authRole == "" || o.authMount == "" || o.kvMount == "" {
return nil, errors.New("OpenBao address, auth role, auth mount and KV mount are required")
}
if o.timeout <= 0 {
return nil, errors.New("reconcile timeout must be positive")
}
// Delegate connection address parsing to the SDK, and avoid implicit BAO_* overrides.
config := bao.NewConfig()
config.Address = strings.TrimRight(o.address, "/")
config.Timeout = o.timeout
client, err := bao.NewClient(config)
if err != nil {
return nil, errors.New("invalid OpenBao client configuration")
}
return openbao.NewCredentials(client, o.kvMount, o.authMount, o.authRole), nil
}
-56
View File
@@ -1,56 +0,0 @@
/*
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 main
import (
"flag"
"io"
"testing"
)
const testAddressFlag = "--openbao-address=http://bao:8200"
func TestDependencyConfigurationFailsBeforeAssembly(t *testing.T) {
for _, args := range [][]string{
{},
{testAddressFlag},
{testAddressFlag, "--openbao-auth-role=controller", "--reconcile-timeout=0s"},
} {
var options dependencyOptions
flags := flag.NewFlagSet("test", flag.ContinueOnError)
flags.SetOutput(io.Discard)
options.bindFlags(flags)
if err := flags.Parse(args); err != nil {
t.Fatal(err)
}
if _, err := options.credentials(); err == nil {
t.Fatal("invalid configuration was accepted")
}
}
}
func TestAssemblyDoesNotRequireLiveDependenciesOrTenantConfiguration(t *testing.T) {
var options dependencyOptions
flags := flag.NewFlagSet("test", flag.ContinueOnError)
options.bindFlags(flags)
if err := flags.Parse([]string{testAddressFlag, "--openbao-auth-role=controller"}); err != nil {
t.Fatal(err)
}
if _, err := options.credentials(); err != nil {
t.Fatal(err)
}
}
+28 -15
View File
@@ -20,6 +20,7 @@ import (
"crypto/tls" "crypto/tls"
"flag" "flag"
"os" "os"
"time"
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them. // to ensure that exec-entrypoint and run can make use of them.
@@ -37,8 +38,7 @@ import (
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/controller" "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/controller"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance" instanceinitializer "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/postgresql"
// +kubebuilder:scaffold:imports // +kubebuilder:scaffold:imports
) )
@@ -63,7 +63,10 @@ func main() {
var probeAddr string var probeAddr string
var secureMetrics bool var secureMetrics bool
var enableHTTP2 bool var enableHTTP2 bool
var dependencies dependencyOptions var openBaoAddress, openBaoConsumerAddress, openBaoAuthMount, openBaoAuthRole string
var openBaoKVMount, openBaoTenantBasePath, openBaoServiceAccountTokenPath string
var externalSecretStoreName, postgreSQLCABundlePath string
var reconcileTimeout time.Duration
var tlsOpts []func(*tls.Config) var tlsOpts []func(*tls.Config)
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ 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.") "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
@@ -82,8 +85,20 @@ func main() {
flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.")
flag.BoolVar(&enableHTTP2, "enable-http2", false, flag.BoolVar(&enableHTTP2, "enable-http2", false,
"If set, HTTP/2 will be enabled for the metrics and webhook servers") "If set, HTTP/2 will be enabled for the metrics and webhook servers")
dependencies.bindFlags(flag.CommandLine) 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{ opts := zap.Options{
Development: true, Development: true,
} }
@@ -92,15 +107,17 @@ func main() {
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
credentials, err := dependencies.credentials() 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 { if err != nil {
setupLog.Error(err, "Invalid controller dependency configuration") setupLog.Error(err, "Invalid controller dependency configuration")
os.Exit(1) os.Exit(1)
} }
instances := instance.NewService(credentials, postgresql.Connector{
CABundlePath: dependencies.postgreSQLCABundlePath,
})
defer instances.Close()
// if the enable-http2 flag is false (the default), http/2 should be disabled // if the enable-http2 flag is false (the default), http/2 should be disabled
// due to its vulnerabilities. More specifically, disabling http/2 will // due to its vulnerabilities. More specifically, disabling http/2 will
@@ -194,10 +211,7 @@ func main() {
} }
if err := (&controller.PostgreSQLInstanceReconciler{ if err := (&controller.PostgreSQLInstanceReconciler{
Client: mgr.GetClient(), Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Initializer: instanceInitializer, Timeout: reconcileTimeout,
Scheme: mgr.GetScheme(),
Instances: instances,
Timeout: dependencies.timeout,
}).SetupWithManager(mgr); err != nil { }).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "Failed to create controller", "controller", "postgresqlinstance") setupLog.Error(err, "Failed to create controller", "controller", "postgresqlinstance")
os.Exit(1) os.Exit(1)
@@ -223,7 +237,6 @@ func main() {
setupLog.Info("Starting manager") setupLog.Info("Starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "Failed to run manager") setupLog.Error(err, "Failed to run manager")
instances.Close()
os.Exit(1) os.Exit(1)
} }
} }
+3
View File
@@ -63,6 +63,9 @@ spec:
args: args:
- --leader-elect - --leader-elect
- --health-probe-bind-address=:8081 - --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 image: controller:latest
name: manager name: manager
ports: ports:
+24 -13
View File
@@ -37,16 +37,22 @@ cluster-scoped,short name 为 `pginstance`。
| `spec.endpoint.port` | int32 | `5432` | 1–65535 | | `spec.endpoint.port` | int32 | `5432` | 1–65535 |
| `spec.endpoint.database` | string | `postgres` | 管理连接 database;合法 PostgreSQL identifier | | `spec.endpoint.database` | string | `postgres` | 管理连接 database;合法 PostgreSQL identifier |
| `spec.endpoint.sslMode` | enum | `verify-full` | `disable`、`require`、`verify-ca`、`verify-full` | | `spec.endpoint.sslMode` | enum | `verify-full` | `disable`、`require`、`verify-ca`、`verify-full` |
| `spec.adminCredentialRef.path` | string | 必填 | 部署级 KV mount 内的 mount-relative path | | `spec.adminCredentialRef.name` | string | 必填 | controller namespace 内的管理 Secret 名称 |
| `spec.adminCredentialRef.usernameKey` | string | `username` | OpenBao record 中的键名 | | `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 |
| `spec.adminCredentialRef.passwordKey` | string | `password` | OpenBao record 中的键名 | | `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 |
| `spec.allowedExtensions` | set[string] | 空集合 | 合法 extension 名称的 allowlist |
`adminCredentialRef.path` 不以 `/` 开头,不含空段、`.`、`..`,也不包含 KV v2 API 的 `adminCredentialRef` 不接受 namespace 或 Bao path。管理 Secret 固定在 controller
`data`/`metadata` 层。它只定位既有管理凭据;controller 不创建或修改该记录。 namespace,名称须合法,两个字段须存在且非空。管理员维护 ExternalSecret,由 ESO
同步;controller 只读管理 Secret,不创建或修改它。此为 2026-09-13 批准的修订,
现有 API types、生成 CRD 和 samples 尚未更新。
Instance endpoint、管理凭据引用和 allowlist 可以修改。修改后 controller 重新验证; Instance endpoint、管理凭据引用可以修改。修改后 controller 重新验证。
删除 allowlist 项目不会自动从已有 Tenant database 删除 extension。 2026-09-14 修订:v1alpha1 不实现 allowedExtensions;现有 API types、生成 CRD 和
samples 中的字段待后续移除,不作为一个可配置但被忽略的策略保留。
扩展请求按目标 PostgreSQL 实际可安装列表判断,可用列表由应用层查询。
endpoint 由管理员负责,不校验变更前后是否同一物理服务器/registry,只重验新配置
的连接与管理能力。新 UID 按新 Instance 处理,不授权接管旧 UID 的 Tenant 资源。
### Status ### Status
@@ -62,6 +68,9 @@ print columns:`Endpoint=.spec.endpoint.host`、`Phase`、`Ready`、`Age`。
Instance `Ready=True` 要求管理凭据可读、TLS/认证成功、server metadata 可读、registry Instance `Ready=True` 要求管理凭据可读、TLS/认证成功、server metadata 可读、registry
可访问且权限预检成功。它不代表数据库已经备份或高可用。 可访问且权限预检成功。它不代表数据库已经备份或高可用。
管理凭据从 Kubernetes Secret 装配;已有有效凭据可访问 PostgreSQL 时,Bao/ESO
暂时不可用不单独撤销 Instance Ready。Tenant 凭据操作仍依赖 Bao。
## PostgreSQLTenant ## PostgreSQLTenant
namespaced,short name 为 `pgtenant`。 namespaced,short name 为 `pgtenant`。
@@ -73,7 +82,7 @@ namespaced,short name 为 `pgtenant`。
| `spec.instanceRef` | string | 必填 | cluster-scoped Instance 名称 | | `spec.instanceRef` | string | 必填 | cluster-scoped Instance 名称 |
| `spec.database` | string | `metadata.name` | 合法 PostgreSQL identifier | | `spec.database` | string | `metadata.name` | 合法 PostgreSQL identifier |
| `spec.loginRole` | string | `metadata.name` | database owner 兼应用 login | | `spec.loginRole` | string | `metadata.name` | database owner 兼应用 login |
| `spec.extensions` | set[string] | 空集合 | 必须属于 Instance allowlist;成功创建后只允许追加 | | `spec.extensions` | set[string] | 空集合 | 必须属于目标实例实际可安装的扩展列表;成功创建后只允许追加 |
| `spec.credential.secretName` | string | `<instance>-<name>-postgresql` | 合法的同 namespace ESO target Secret 名称 | | `spec.credential.secretName` | string | `<instance>-<name>-postgresql` | 合法的同 namespace ESO target Secret 名称 |
| `spec.deletionPolicy` | enum | `Retain` | `Retain` 或 `Delete` | | `spec.deletionPolicy` | enum | `Retain` | `Retain` 或 `Delete` |
@@ -142,8 +151,11 @@ phase 用于进度展示、恢复和排障。
- `Retain` 不需要等待外部依赖;删除 CR 后外部记录保留原 UID 并标记 unmanaged。 - `Retain` 不需要等待外部依赖;删除 CR 后外部记录保留原 UID 并标记 unmanaged。
- `Delete` 添加 finalizer,严格按规格的所有权验证和清理顺序执行;失败保持 finalizer。 - `Delete` 添加 finalizer,严格按规格的所有权验证和清理顺序执行;失败保持 finalizer。
- controller 不为 `PostgreSQLInstance` 级联删除 Tenant 或外部资源;存在引用时 Instance - Instance 开始受管时即添加并保存 finalizer;删除时停止新供应,存在 Tenant 引用
删除应被 finalizer 阻止,直到 Tenant 被删除或管理员使用运维逃生流程。 (包括正在删除的 Tenant)就保留 finalizer,无引用才移除。引用查询失败时继续等待。
不级联删除 Tenant 或外部资源;管理员可使用运维逃生流程。
- finalizer 不禁止创建 Tenant CR;并发创建者遇到删除中或不存在的 Instance 不得
开始供应。首版不增加跨对象锁或准入控制,不承诺跨对象原子删除。
## 示例 ## 示例
@@ -160,8 +172,7 @@ spec:
database: postgres database: postgres
sslMode: verify-full sslMode: verify-full
adminCredentialRef: adminCredentialRef:
path: infrastructure/postgresql/shared/admin name: shared-postgresql-admin
allowedExtensions: [pg_trgm]
--- ---
apiVersion: database.ddupan.top/v1alpha1 apiVersion: database.ddupan.top/v1alpha1
kind: PostgreSQLTenant kind: PostgreSQLTenant
+6 -2
View File
@@ -35,8 +35,12 @@ controller 不运行 PostgreSQL/OpenBao,不管理 VM、存储、备份或 Open
## 资源模型 ## 资源模型
`PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的 `PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的
DNS host、IP host address、端口、管理 database、TLS 模式、OpenBao 管理凭据引用和 DNS host、IP host address、端口、管理 database、TLS 模式和管理 Secret 引用。
extension allowlist。 实际可安装扩展由应用层查询后交给领域对象判定,v1alpha1 不实现管理员 allowlist。
管理连接使用管理员维护的 ExternalSecret 经 ESO 同步到 controller namespace 的
Secret;Instance 只选择 Secret 名称与字段,controller 只读,不直接从 Bao 获取
管理凭据。Tenant 凭据的创建、读取与销毁仍由 controller 直接访问 Bao。
`PostgreSQLTenant` 是 namespaced。一个 Tenant 对应一个 database、一个同时作为 owner `PostgreSQLTenant` 是 namespaced。一个 Tenant 对应一个 database、一个同时作为 owner
的 login role、一组只允许追加的 extension、一个由 controller 推导的 OpenBao KV 的 login role、一组只允许追加的 extension、一个由 controller 推导的 OpenBao KV
+21 -30
View File
@@ -17,7 +17,9 @@
3. 创建 PostgreSQL controller 管理 role 和管理 database 连接权限。 3. 创建 PostgreSQL controller 管理 role 和管理 database 连接权限。
4. 在 OpenBao KV v2 写入管理 role 凭据。 4. 在 OpenBao KV v2 写入管理 role 凭据。
5. 配置 OpenBao Kubernetes auth、controller policy 和面向 ESO 的读取 policy。 5. 配置 OpenBao Kubernetes auth、controller policy 和面向 ESO 的读取 policy。
6. 在 Kubernetes 安装 ESO,创建可读取租户路径的 `ClusterSecretStore`。 6. 安装 ESO,配置独立的管理凭据同步身份和租户凭据读取身份。管理员在 controller
namespace 创建管理 ExternalSecret,确认管理 Secret 已同步;另创建供租户使用的
`ClusterSecretStore`。
7. 创建公开 CA bundle ConfigMap,并挂载到 controller 和需要直接验证数据库的应用。 7. 创建公开 CA bundle ConfigMap,并挂载到 controller 和需要直接验证数据库的应用。
8. 部署 controller,再创建 Instance;等待 Ready 后才创建 Tenant。 8. 部署 controller,再创建 Instance;等待 Ready 后才创建 Tenant。
@@ -25,8 +27,8 @@
## Controller 配置合同 ## Controller 配置合同
controller 使用以下 CLI flags。启动入口加载配置、检查本切片必需项并创建共享依赖, controller 使用以下 CLI flags。必填项缺失、路径无效或 duration 不为正数时,进程必须
配置错误在启动 manager 前失败。外部服务暂时不可用由 reconcile 重试,不阻止进程启动。 在启动 manager 前失败;不得等到 reconcile 时才逐个资源报告配置错误。
| CLI flag | 必填/默认 | 说明 | | CLI flag | 必填/默认 | 说明 |
| --- | --- | --- | | --- | --- | --- |
@@ -35,28 +37,16 @@ controller 使用以下 CLI flags。启动入口加载配置、检查本切片
| `--openbao-auth-mount` | `kubernetes` | Kubernetes auth mount 名称 | | `--openbao-auth-mount` | `kubernetes` | Kubernetes auth mount 名称 |
| `--openbao-auth-role` | 必填 | controller ServiceAccount 对应 role | | `--openbao-auth-role` | 必填 | controller ServiceAccount 对应 role |
| `--openbao-kv-mount` | `kv` | KV v2 mount;开发可显式用 `secret` | | `--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 前缀 | | `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 |
| `--external-secret-store-name` | Tenant 投射时必填 | controller 创建的 ExternalSecret 固定引用;Instance Ready 不依赖此项 | | `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 |
| `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 | | `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 |
| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 | | `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 |
连接地址由 OpenBao SDK 解析,移除末尾 `/`;配置错误不得携带原始地址中的认证信息。 address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/`
生产环境使用 HTTPS;HTTP 只用于开发 fixture。consumer URL 的认证信息限制和 Tenant 在规范化后移除。mount、auth mount 和 base path 都使用 mount-relative path 语义,不以
派生路径限制由对应输出边界负责,Instance 服务不校验尚未使用的 Tenant 配置。 `/` 开头,不含空段、`.` 或 `..`;base path 还不得编码 KV v2 的 `data`/`metadata`
Kubernetes auth 使用 SDK 默认的 ServiceAccount token 挂载路径,不单独暴露路径参数。 API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用于明确的开发 fixture。
启动时创建一个共享 OpenBao client 和凭据源,再注入 Instance 服务。凭据源在有读取需求且
token 即将过期时重新登录;每次登录重新读取投射的 ServiceAccount token。token 被撤销导致
403 时最多重新认证一次,持续 policy 拒绝仍返回错误。
每个 Instance 首次使用时读取管理 username/password 并创建自己的 PostgreSQL 连接池。
后续 reconcile 复用连接池和内存中的凭据,不做自动 PostgreSQL 密码轮换。UID、endpoint
或管理凭据引用改变时释放旧连接并重新装配;只修改 extension allowlist 不重建连接。
删除 Instance 或 controller 正常退出时关闭连接池;重启后按需重新读取 Bao。
仅修改 Bao 中原路径的密码不会触发刷新,管理员需要重启 controller 或修改凭据引用。
基础 manager manifest 不携带 OpenBao 地址、role 等环境值。实际部署通过环境专属
Kustomize overlay 注入上述 args;E2E 由测试 fixture 注入一次性环境配置。
Tenant 路径固定推导为 `<base-path>/<namespace>/<metadata.name>`。namespace/name 都已通过 Tenant 路径固定推导为 `<base-path>/<namespace>/<metadata.name>`。namespace/name 都已通过
Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer
@@ -79,7 +69,7 @@ base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空
- 创建/修改受管 login role; - 创建/修改受管 login role;
- 创建 database 并指定 owner; - 创建 database 并指定 owner;
- 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT; - 撤销 `PUBLIC` CONNECT、授予租户 role CONNECT;
- 连接租户 database 并创建 allowlist extension; - 连接租户 database 并创建实例实际支持、租户申请的 extension;
- 创建和维护 controller 专属 registry schema/table; - 创建和维护 controller 专属 registry schema/table;
- `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。 - `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。
@@ -90,13 +80,12 @@ controller 权限。最终可执行 SQL grant 将随 PostgreSQL adapter 集成
## OpenBao 与 ESO ## OpenBao 与 ESO
controller policy 分成两个范围: controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2
data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。
- 只读 Instance 管理凭据路径; 管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份
- 在固定 tenant base path 下 create/read/update/delete KV v2 data 和 metadata,Delete 只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得
必须能永久删除全部版本及 metadata。 读取 PostgreSQL 管理凭据。controller 不创建或修改管理 ExternalSecret/Secret。
ESO 使用独立身份,只需读取 tenant base path;它不应读取 PostgreSQL 管理凭据。
`ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。 `ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。
controller 创建的 ExternalSecret 与 Tenant 同 namespace,并设置 ownerReference;目标 controller 创建的 ExternalSecret 与 Tenant 同 namespace,并设置 ownerReference;目标
Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、 Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、
@@ -109,8 +98,10 @@ Secret 包含固定七键:`username`、`password`、`database`、`host`、`hos
对应 Secret 是否完成投射。 对应 Secret 是否完成投射。
- namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller - namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller
配置或其他 namespace 的 ExternalSecret。 配置或其他 namespace 的 ExternalSecret。
- controller 无需读取目标 Secret 的 data;验证登录使用从 OpenBao 读取的应用凭据, - controller 只在自身 namespace 读取所引用管理 Secret 的 data,不获得跨 namespace
对 Secret 只检查存在性和 ESO 状态。 的管理 Secret 读取权限。Instance 不允许自选 Secret namespace。
- 对应用目标 Secret,controller 无需读取 data;验证登录使用从 OpenBao 读取的应用
凭据,只检查 Secret 存在性和 ESO 状态。
## 升级与回滚 ## 升级与回滚
+230
View File
@@ -0,0 +1,230 @@
# Instance 领域对象规格
状态:Draft,含已确认决策。日期:2026-09-13。
上层边界见 [领域模型](domain-model.md)。本文只展开 Instance,不包含 Tenant 的供应
实现,也不新增 CRD 字段。设计签名用于评审职责与行为,不是待复制的 Go 接口代码。
## 1. 对象职责与生命周期
Instance 表示一次登记的 PostgreSQL 管理对象,是聚合根。它负责字段与策略校验、
根据观察结果判断能力是否满足要求、保护状态转换规则;不登录数据库,不读取 Bao,
不查询权限或初始化 registry。
本草案选择:**领域对象只接收数据并做业务决策,不直接或通过端口、回调访问外部。**
应用层调用适配器获取事实、执行被允许的操作,并将观察结果交回对象。Instance 不接收
context、客户端或 IO 接口。领域行为不是公共 SetReady:调用方提供事实,不能指定结论。
每轮从 CR 重建一个 Instance;对象不跨 reconcile 缓存,也不是线程共享单例。
管理连接可由装配层跨轮次复用,但连接复用不代表上次能力验证仍然成立。
身份与 endpoint 以管理员声明为准。改变 endpoint 不验证是否同一物理服务器或
registry,不增加安装身份连续性检查;只使旧观察失效,按新配置重验管理能力。
新 CR 是新 Instance,不自动获得旧 UID 资源的所有权,也不迁移或清理旧目标。
下文“观察绑定匹配”仅指结果属于本轮身份/配置,不是物理服务器身份认证协议。
## 2. 字段与值对象
所有可变状态封装在对象内部。构造后身份和本轮 definition 不可变;配置变更通过
下一轮装载新的 definition 处理,不提供任意 SetPhase/SetReady/SetEndpoint。
| 字段 | 类型与内容 | 来源/持久化 | 修改规则 |
| --- | --- | --- | --- |
| identity | InstanceIdentity:UID、name | CR metadata | 本次对象身份内不可变;同名新 UID 是新对象 |
| revision | 正整数,期望配置版本 | metadata.generation | 本轮不可变;不是物理服务器版本 |
| definition.endpoint | Endpoint:host、hostaddr、port、managementDatabase、tlsMode | CR spec | 本轮不可变;新配置重验 |
| definition.adminCredential | CredentialReference:name、usernameKey、passwordKey | CR spec | 只引用 controller namespace 的管理 Secret,不存明文 |
| availableExtensions | 可选的实际可安装扩展集合 | 应用层从目标 PostgreSQL 查询;本轮观察,不新增 status 字段 | 未观察与已观察的空集合不同;目标变化后旧结果失效 |
| checkpoint | Pending/Validating/InitializingRegistry/Ready/Deleting | CR status.phase | 只能由领域动作变更,应用层负责持久化 |
| observedRevision | 最近完成有结论协调的版本 | CR status.observedGeneration | 成功或已知失败时更新,单纯记录意图不更新 |
| readiness | Unknown/Ready/NotReady,加安全失败类别和操作说明 | 由 status Ready Condition 重建,结果再映射回 Condition | 方法更新;不是第二套持久化状态 |
| reportedVersion | 可选服务器版本字符串 | status.postgresqlVersion;验证后从服务器更新 | 仅供展示,不能证明连接成功 |
| deleting | 是否已请求删除 | metadata.deletionTimestamp 映射 | 本轮不可变;优先于其他动作 |
| evidence | 可选 CapabilityEvidence | 本轮外部回读;不新增 status 字段 | 重建时始终为空,不能从 Ready Condition 伪造 |
Endpoint 的构造约束沿用 API:非空 host、合法 IP、1–65535 端口、合法 PostgreSQL
identifier、显式 TLS mode,禁止隐式降级。CredentialReference 包含合法 Secret 名称
及非空字段名,不包含 namespace 或 Bao path;namespace 由应用层固定为 controller
自身 namespace。这里校验领域值,不在对象里校验整个 controller 部署配置。
CapabilityEvidence 包含本轮目标绑定(Instance UID、revision、endpoint、凭据引用)、
server version、管理能力检查结果、registry 观察结果。registry 结果区分
Absent/NeedsMigration/Usable;连接失败不能当作 Absent。它不包含密码、token 或 DSN。
管理能力要求来自规格中的 role/database/grant/extension 操作,不等价于“能执行
SHOW server_version”。具体权限探测矩阵需在 PostgreSQL 适配器规格中定义,不能
让一个没有定义检查内容的布尔值承担验收。
不属于 Instance 的字段:Tenant 清单、客户端、连接池、token TTL、CA 文件句柄、
Kubernetes resourceVersion。resourceVersion 留在应用层作为乐观并发保存的前提。
### 扩展支持判定(2026-09-14 已确认方向)
v1alpha1 按目标 PostgreSQL 实际可安装的扩展列表判断请求,不实现管理员 allowlist。
allowlist 仅保留为后续可选策略,不接受一个看似生效、实际被忽略的策略字段;现有
CRD 的 allowedExtensions 应在对应 API 改动中移除,本次只修订文档。
应用层查询实际可用扩展并提供与本轮目标绑定的观察;Instance 只做集合判断,不
访问数据库。不沿用之前提议的字符正则,不自动改大小写或名称;SQL 适配器仍须
安全引用 identifier。可用列表不是已安装列表,也不保证权限或其他安装前提满足。
未观察/查询失败不得当作空集合或不支持;不得用旧目标的列表授权新目标的操作。
非空请求须属于已观察的可用集合,返回不支持的名称;空请求无需扩展支持判定,
但不绕过 Instance 的其他就绪要求。安装后仍需回读,不能以集合匹配代替安装验证。
列表变化不触发自动卸载;已有扩展的漂移处理留到 Tenant 用例细化。
## 3. 设计签名
```text
Reconstitute(identity, revision, definition, checkpointSnapshot, deleting)
-> Instance | InvalidDefinition
Instance.BeginValidation() -> Outcome
Instance.AssessManagement(observation: CapabilityObservation) -> Outcome
Instance.PlanRegistryPreparation(observation: CapabilityObservation)
-> AlreadyUsable | PreparationAllowed | PreparationDenied
Instance.AssessRegistryResult(result: RegistryPreparationResult) -> Outcome
Instance.AssessReadiness(observation: CapabilityObservation) -> Outcome
Instance.CheckExtensions(requested: ExtensionSet)
-> Accepted | ExtensionsUnsupported | ExtensionSupportUnobserved
Instance.RequireProvisioningReady() -> Accepted | InstanceNotReady
Instance.BeginDeletion() -> Outcome
Instance.Snapshot() -> InstanceSnapshot
```
Outcome 是正常推进、已知失败或方法前提不成立,不包含重试秒数、Kubernetes patch
或原始驱动错误。InstanceSnapshot 只包含 checkpoint、observedRevision、readiness、
reportedVersion,不能序列化 evidence。快照与集合访问返回值副本。
CapabilityObservation 是不可变的事实输入:目标绑定、服务器版本、管理能力检查项和
registry 观察结果;各检查项区分成功、失败、未观察,未观察不视为成功。失败只含安全
类别,不含驱动异常或凭据。对象校验目标绑定与当前身份/配置一致,拒绝不匹配输入,
不改变状态;完整性不足不能产生 Ready。观察结果由应用层收集,对象不能自行证明
这些事实的真实性或实时性;采集来源、同轮次关联和并发检查由应用层保证。
RegistryPreparationResult 为操作失败(目标绑定、安全失败类别)或操作后的完整回读
观察。单独的“迁移调用成功”不是就绪证据。CapabilityEvidence 是对象接受并判定满足
要求的观察值,不是调用方传入的 Ready 布尔值。
### 构造与恢复
Reconstitute 校验期望 definition;无效输入不构造一个可参与用例决策的 Instance。
入口把 InvalidDefinition 映射成 InvalidSpec,不必为了报告坏 CR 而制造非法领域对象。
checkpoint 缺失或未知时保守使用 Pending;reportedVersion 和 Ready 都只是旧观察,
evidence 为空。若 observedRevision 与 revision 不一致,旧 Ready 不得通过供应检查。
### 方法合同
| 方法 | 前置条件/输入 | 行为与状态变化 | 失败语义 |
| --- | --- | --- | --- |
| BeginValidation | 未删除;初次登记、配置变更或需重建 checkpoint | 转 Validating,readiness=Unknown,清空 evidence;不做外部 IO,不推进 observedRevision | deleting 时不启动验证 |
| AssessManagement | 未删除;Validating;目标匹配的观察 | 判定管理访问、metadata、权限是否满足;registry 可用或可安全准备时转 InitializingRegistry,仍为 Unknown;不执行探测 | 失败保持 Validating,NotReady,observedRevision=当前版本 |
| PlanRegistryPreparation | 未删除;InitializingRegistry;本轮前置观察 | 根据管理能力及 registry 现状决定无需写入、允许准备或禁止准备;返回决策,不执行迁移、不标 Ready | 访问失败、不兼容或证据不足时禁止写入,NotReady;保持阶段,更新 observedRevision |
| AssessRegistryResult | 未删除;InitializingRegistry;准备结果或无需写入时的完整回读 | 按全部就绪条件判断回读结果;全满足才 Ready,并更新 observedRevision/version/evidence | 操作失败或回读不满足时保持 InitializingRegistry、NotReady;不得提前 Ready |
| AssessReadiness | 未删除;Ready;本轮观察 | 配置版本不一致时仅 BeginValidation;否则根据全部观察判断是否仍满足就绪条件 | 访问失败转 Validating/NotReady;registry 缺失或需迁移时转 InitializingRegistry,保存后下一轮修复 |
| CheckExtensions | 请求集合;本轮实际可用扩展观察 | 判断请求是否为实际可用集合的子集,返回不支持的名称;无 IO、无状态修改 | ExtensionsUnsupported 或 ExtensionSupportUnobserved;不卸载已存在扩展 |
| RequireProvisioningReady | 供 Tenant 用例使用 | 要求未删除、Ready、observedRevision 匹配,并有本次调用链的新鲜完整 evidence | 不满足即 InstanceNotReady;持久化 Ready 本身不构成授权 |
| BeginDeletion | deleting=true | 转 Deleting,清除供应能力,Unknown;不执行任何数据库或凭据删除 | 引用检查/finalizer 处理失败不得恢复成可供应 |
| Snapshot | 任意合法对象状态 | 返回可安全持久化的结果值 | 不触发 IO,也不改变状态 |
领域方法只检查对象状态,不知道 checkpoint 是否已落盘。“已持久化 checkpoint”是
应用用例执行外部写入的前提。内存字段变成 InitializingRegistry 不代表已保存成功;不能
在同一轮无条件接着执行迁移。通过用例测试验证此约束,而不是伪造一个内存事务。
AssessManagement 成功只是中间步骤,observedRevision 不前移;完成就绪判定或
明确失败才产生相应有结论结果。旧版本字符串可供诊断,但失败会清空 evidence。
Instance 不在本轮暴露 CreateDatabase/DeleteDatabase:Tenant 的供应/销毁授权来自
Tenant 和 OwnershipClaim,不是从 Instance.Ready 推导。数据库执行能力如何承接
已授权动作,留到 Tenant 对象规格,不在这里设计第二个万能 service。
## 4. 应用层与外部访问边界
```text
应用层依赖的适配器能力(不传入 Instance):
InspectManagement(context, target) -> ManagementObservation | AccessFailure
InspectRegistry(context, target) -> RegistryObservation | AccessFailure
EnsureRegistry(context, target) -> Completed | AccessFailure
```
应用层在 IO 前绑定目标并关联结果,领域对象在接受观察时检查身份和配置匹配;旧
endpoint 的成功结果不得用于新 endpoint。Inspect 是只读;EnsureRegistry 是幂等初始化/迁移,
不能顺带建立 Tenant 数据库或接管未知 schema。Completed 不足以推进 Ready,必须回读。
适配器由装配层绑定管理连接;Secret 读取与连接池释放留在该边界之后,Instance
管理连接不涉及 Bao token。适配器不得把基础设施异常转换成 Ready。失败区分依赖不可用、
认证失败、权限不足和 registry 不兼容;不兼容属于不可安全继续,不自动覆写。
registry 不兼容的具体 Condition 映射须在接口规格中确定,不能统一误报权限不足。
管理连接由应用层从 controller namespace 的 Secret 装配;管理员维护 ExternalSecret,
ESO 负责同步。Instance 路径不直接访问 Bao,也不以 Bao/ESO 当前可用性作为就绪条件。
首次装配缺少有效 Secret 时失败;已有凭据可正常访问 PG 时继续按 PG 能力判定。
检测到所引用 Secret 的有效用户名或密码变化时,应用/基础设施层使用新值重建连接池
并重新采集管理能力观察;metadata 或无关字段变化不重建。不要求 Instance generation
变化,也不能复用旧连接的成功观察来证明新凭据有效。Secret 变化监听、连接释放和
刷新均不进入领域对象;应用层保证旧连接观察不混入刷新后的调用链。
controller 不修改 PostgreSQL 密码、不回写 Secret 或 Bao 管理凭据。
## 5. 状态转换与初始化走查
```text
Pending --BeginValidation/保存--> Validating
Validating --AssessManagement(观察)/保存--> InitializingRegistry
InitializingRegistry --AssessRegistryResult(回读结果)/保存--> Ready
Ready --配置变化或访问失败/保存--> Validating
Ready --registry 需修复/保存--> InitializingRegistry
任意阶段 --删除请求/保存--> Deleting
```
1. 入口读取 CR,装配 definition、checkpointSnapshot;客户端不注入领域对象。
2. 应用层按 checkpoint 协调用例;首次调用 BeginValidation,没有 IO。
3. 保存 Validating。若保存失败,结束本轮,不执行 registry 写入。
4. 下一轮应用层调用适配器探测实例,将观察交给 AssessManagement;领域判定通过后
保存 InitializingRegistry,保存失败则停止,不进行迁移。
5. 再下一轮应用层采集前置观察,调用 PlanRegistryPreparation。仅在意图已持久化且
领域允许时调用 EnsureRegistry;AlreadyUsable 则跳过写入,PreparationDenied 则
保存失败结果并停止。允许的操作完成后回读,交给 AssessRegistryResult 决定能否
Ready;操作失败也用安全结果交回,不在应用层直接修改 phase。
6. 入口用原 resourceVersion 前提保存快照;并发变更导致冲突时重新装载,不覆盖新状态。
7. 后续 Ready 检查先由应用层探测,再调用 AssessReadiness;Tenant 用例同样获取当前事实,不能
仅凭另一个 CR 的 Ready Condition 永久缓存授权。实际资源写入仍须处理并发变化。
阶段调度和外部操作顺序在应用层;“观察是否满足业务要求、是否允许准备 registry、
哪些结果算完成、失败退到哪里”在 Instance 方法内。controller 不重复这些规则,
也不直接把 phase 设置成 Ready。领域允许操作并不锁住外部世界,适配器仍须保障幂等
和并发安全;禁止把旧观察当成永久授权。
## 6. 不变量与恢复验收
- UID 不随名称复用;不同 UID 的 evidence/结果不可互用。
- 未完成当前配置的能力回读,不能新产生 Ready,也不能通过供应检查。
- checkpoint 可以落后或被伪造;每次初始化/供应前都核对事实。status 清空只需重新
验证和幂等准备,不删除 registry,更不能重新生成 Tenant 密码。
- 迁移成功而 status 保存失败:重试回读已存在 registry,安全完成,不重复破坏性写入。
- registry 在 Ready 后消失:下一次回读撤销 Ready,保存修复意图后才能重新准备。
- 外部 IO 超时:产生安全失败结果;保存 status 使用仍有效的外层上下文,不能复用
已超时的 IO 上下文而丢失失败状态。
- 已请求删除的 Instance 不允许新供应;BeginDeletion 不删除 PostgreSQL、Tenant 或
Bao。应用层在开始受管时添加并保存 finalizer,而非出现 Tenant 后再添加。
删除时查询所有引用它的 Tenant(含删除中的对象);有引用或查询失败就保留
finalizer,确认无引用才移除。引用查询、finalizer 写入和本地连接释放均不属于
领域 IO,Instance 只根据删除请求禁用供应能力。
- 首版不为 Instance 删除增加跨对象锁或准入控制。并发创建的 Tenant CR 不被
finalizer 拦截,但遇到删除中/不存在的 Instance 不得开始供应;不承诺取消
已在途的外部操作,也不声称引用查询与移除 finalizer 是跨对象原子事务。
- CheckExtensions 失败不能授权扩展安装;可用列表变化不会自行卸载已有扩展。
- Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。
- 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入
得到相同决策。缺少检查项、目标不匹配和旧配置结果不得产生 Ready。
上述每条都对应领域或用例测试;真实权限检查、迁移与并发保障由适配器集成测试
验证。本文为设计文档,未执行或宣称通过这些测试。
## 7. 本轮待评审与后续阻塞项
本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。
管理 Secret 来源、Bao 故障不单独撤销 Instance Ready,以及管理用户名/密码变化时
重建连接池,以及管理员声明的 Instance 身份/endpoint 和简化 finalizer 删除规则
均已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。
批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。
+149
View File
@@ -0,0 +1,149 @@
# 领域模型设计草案
状态:Draft,含已确认决策。日期:2026-09-13。
本文定义领域职责、身份与一致性边界,并用对象规格细化字段和方法合同;方法使用
设计签名,不固定 Go 目录、SDK 或框架,也不批准实现。外部行为以
[系统规格](specification.md) 为准;下列未决问题不能由实现自行决定。
PR #6 的代码和已有 registry 表结构是可评估的实现素材,不反向决定领域模型。
## 1. 领域与统一语言
本系统的领域是“在共享 PostgreSQL 上供应并管理应用租户”,不是数据库服务器运维。
v1alpha1 先采用一个限界上下文,不把 PostgreSQL、Bao、Kubernetes 各自当成业务上下文。
| 术语 | 含义 | 不是什么 |
| --- | --- | --- |
| Instance | 平台登记的外部 PostgreSQL 管理对象及其供应策略 | 连接池、VM 或 controller 单例 |
| Tenant | 一个应用的数据库使用合同及受管资源生命周期 | PostgreSQL database 的别名 |
| Database | 租户数据库的名称、owner、扩展等期望描述与实际观察 | 包含 Bao 登录与连接关闭的操作接口 |
| LoginRole | 同时作为 database owner 和应用登录身份的角色 | 额外的 NOLOGIN owner |
| OwnershipClaim | 某个 Tenant 身份对一组资源名称与凭据位置的所有权声明 | 工作流阶段或仅凭名称推断的归属 |
| CredentialLocation | 固定推导的凭据位置及所有权关联 | 密码本身或用户可任意选择的 KV path |
| CredentialProjection | 把既定凭据交付到目标 Secret 的要求与观察结果 | controller 直接写入明文 Secret |
UID 表示一次 Kubernetes 对象身份;namespace/name 用于定位,不足以证明归属。
database OID 是诊断观察值,不充当本系统的租户身份。
## 2. 候选聚合边界
### Instance:实例能力与供应策略
Instance 是候选聚合根,持有自身身份、endpoint、管理凭据引用、实际可用扩展观察,
以及用于判断当前能力的观察结果。它不持有所有 Tenant 对象的集合。
其行为包括:
- 判断租户申请的 extension 是否在本实例实际可安装列表中;v1alpha1 暂不实现 allowlist。
- 根据管理连接、服务器信息、registry 和权限检查结果判断是否具备供应能力。
- 判断配置变化使哪些能力观察过期,禁止以旧 generation 的 Ready 证明新配置可用。
- 在 registry 初始化完成并回读验证后,接受新的就绪结果。
“探测实例”“准备管理 registry”是应用用例协调的外部操作,不是 Instance 的 IO 方法。
领域对象只接收观察值,负责前提、规则和状态决策;应用层调用适配器获取事实与执行
获准操作。领域对象不持有或调用外部访问端口、客户端或回调。具体选择见
[Instance 字段与行为](domain-instance.md),仍处于待评审状态。
### Tenant:供应合同与资源生命周期
Tenant 是另一个候选聚合根,通过身份引用 Instance,而不是 Instance 的聚合成员。
操作一个 Tenant 不应要求装载、锁定或保存整个实例的租户集合。
Tenant 持有有效的 database/role 名称、请求的扩展、凭据交付目标、删除策略,以及
已建立的资源绑定。它负责:
- 检查绑定后的不可变字段、extension 只追加规则。
- 判断外部部分状态属于本 Tenant、尚不存在,还是与未知资源冲突。
- 决定是否允许继续供应、何时达到 Ready、是否允许释放受管资源。
- 按 Retain/Delete 合同限制行为,禁止把保留资源自动认领给同名新 UID。
Database、LoginRole 和 CredentialProjection 暂不设独立聚合根或独立 CRUD 用例。
它们可作为 Tenant 内的资源描述与观察值;有规则才增加行为,不为了“充血”添加方法。
真实 PostgreSQL database/role 的存在不意味着内存中必须各有一个有身份的实体。
聚合边界是业务规则的保护边界,不表示 Tenant 对应的 PostgreSQL、Bao、ESO 资源
能够一次事务提交。跨系统供应必须允许部分完成。
### OwnershipClaim:跨租户唯一性与持久证据
名称唯一性不可能只靠某个 Tenant 的内存检查保证。需要一项领域能力,在持久化边界
原子认领资源;已有 registry 是其适配器候选,仍需结合 catalog 和 Bao metadata 检查。
Claim 与 Tenant 关联,但不随 Tenant CR 消失:Retain 后证据必须继续存在。因此不能
把它仅视为 CR 的附属 status。是否作为独立的小聚合,先以“可独立持久化、保留并保护
归属不变量的声明”建模;不因此引入新的 CRD。
- 同一身份、同一绑定的重复认领可以成功;不同 UID 或不同绑定不能覆盖。
- Claim 预留名称不等于证明同名外部资源由本 controller 创建。
- 实际写入仍须核对所有权,不能把先查后建当成并发安全保证。
- 当前 registry 的数据库事务不能覆盖 Bao;跨实例的凭据路径竞争也不能靠单个
registry 的唯一约束解决。写入前提与条件创建协议需单独设计和验收。
## 3. 领域、用例与适配器的分工
| 层 | 承担的职责 | 禁止承揽的职责 |
| --- | --- | --- |
| 领域对象/策略 | 身份、有效合同、归属判断、允许的动作、完成条件 | 外部 IO(包括通过接口间接调用)、解析 CLI、生成 Kubernetes Condition |
| 应用用例 | 装载模型与事实、持久化意图、调用能力、回读、提交结果 | 另写一套绕过领域规则的判断流程 |
| controller 入口 | CR 映射、调度、watch、重试、status/finalizer 写入 | 在 reconcile 中重新定义业务规则 |
| 基础设施适配器 | PostgreSQL、registry、Bao、ESO 的实际读写与并发保障 | 自行决定接管、改密码或扩大删除范围 |
| 启动装配 | 校验部署配置,创建共享客户端、连接管理器及用例依赖 | 把连接生命周期当成 Instance 的业务状态 |
领域可使用独立的身份、endpoint、identifier、extension 集合等值对象,不依赖 CRD
类型、pgx pool 或 Bao SDK。Kubernetes 对象的存取与 registry 的存取不是一个通用
`Save(Tenant)` 可以原子完成的事情;不虚构跨系统 Unit of Work。
暂不引入事件总线、事件溯源、通用聚合框架或全套 Repository CRUD。领域建模的依据
是业务规则,而不是接口和目录数量。
## 4. 状态与恢复
CR `status.phase` 仍是已批准的工作流 checkpoint,不在内存对象或 registry 再建一套
权威 phase。领域对象可以由 CR 的期望状态、checkpoint 和外部观察重新构造。
phase 只决定候选步骤,外部证据决定该步骤是否允许执行、是否已经完成。应用层在
写操作前保存意图,调用幂等操作后回读,再保存下一 checkpoint。status 写入失败时,
下次从外部事实识别完成结果;不能重发密码,也不能相信伪造的 Ready。
业务失败区分 InvalidSpec、ImmutableField、Conflict 等;依赖故障由适配器转换成
安全的能力失败,应用层决定重试并映射 Condition。凭据不进入模型序列化、status、
事件或错误明细;只能在实际需要它的执行边界短暂传递。
## 5. 用例走查与验收方向
| 场景 | 领域判定 | 应用与适配器执行/恢复 |
| --- | --- | --- |
| 登记 Instance | 当前配置的能力要求是否满足 | 读取管理凭据,验证连接与权限,准备并回读 registry;完成后才 Ready |
| 供应 Tenant | Instance 策略、绑定与归属允许供应 | 保存意图,认领资源,先写并回读 Bao 凭据,再创建 role/database,登录验证和 ESO 投射 |
| Bao 写入后进程中断 | 同一身份的部分状态可继续 | 回读原凭据继续,不生成第二份密码 |
| 两个 Tenant 竞争名称 | 只有匹配所有权的一方可继续 | 持久化认领和条件写入裁决竞争,失败方 Conflict,不覆盖资源 |
| Delete 中断 | 已消失资源可视为完成;剩余资源仍须归属正确 | 按规格顺序继续删除,全部回读不存在后才清 registry 和 finalizer |
| Retain 后同名 CR 重建 | 新 UID 不等于原所有者 | Conflict,不恢复管理、不改密码 |
领域测试验证规则与决策;adapter 测试验证锁、条件写入、SQL 与协议行为;controller
测试验证 checkpoint 持久化和重启恢复;E2E 验证最终合同。不能只验证一串 mock 调用
就声称实现了最终一致性。
## 6. 决策记录与待细化边界
1. **Instance 身份与物理目标(已确认)**:以管理员声明为准,endpoint 变更不验证
物理服务器/registry 连续性,不增加安装身份绑定检查;旧观察失效,重验新配置
的连接与管理能力。新 CR 视为新 Instance,不自动接管旧 UID 资源或迁移数据。
2. **Retain 完成条件**:外部依赖不可用不能永久阻止 CR 删除,但 registry 又需标记
unmanaged。需定义 CR 消失后的补偿/清扫入口及所需身份依据,不能承诺同时原子
完成两者,也不能在没有回读时声称已写入保留标记。
3. **管理凭据来源与 Ready(已确认)**:Instance 引用 controller namespace 内的
管理 Secret 名称和字段;管理员维护 ExternalSecret,ESO 同步。controller 不直接
从 Bao 读取管理凭据。已有凭据仍可访问 PG 时,Bao/ESO 故障不撤销 Instance Ready;
首次装配无有效 Secret 则失败。Secret 的有效用户名/密码变化时重建管理连接池并
重验,不因无关字段变化重建;controller 不修改 PG 密码,不回写 Secret 或 Bao。
4. **绑定时机**:系统规格写“首次成功后不可变”,API 文档写“首次创建外部状态后
不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失
后恢复;否则供应中途改名称可能产生无人管理的资源。
5. **Instance 删除(已确认)**:开始受管即添加 finalizer;删除期间停止新供应,
有 Tenant 引用就等待,无引用才解除,不级联删除外部资源。首版采用 finalizer
与引用检查,不引入跨对象锁/准入控制;不保证并发创建与删除的原子性。
Tenant 的 Retain 等待决行为留到 Tenant 设计,不属于本轮 Instance 设计范围。
相关用例在决策批准前不进入实现,不同时实现整套模型。
+2 -2
View File
@@ -33,7 +33,7 @@ pg_dump --schema-only --no-owner --no-privileges \
``` ```
检查不受 v1alpha1 管理的对象:额外 roles、跨库依赖、FDW、large objects、订阅、显式 检查不受 v1alpha1 管理的对象:额外 roles、跨库依赖、FDW、large objects、订阅、显式
tablespace、owner/grant 和不在 allowlist 的 extension。无法映射为单 database + 单 login tablespace、owner/grant 和目标实例不支持的 extension。无法映射为单 database + 单 login
owner 的环境必须先人工简化,不能让 controller 猜测。 owner 的环境必须先人工简化,不能让 controller 猜测。
### 2. 创建一致性 dump ### 2. 创建一致性 dump
@@ -84,7 +84,7 @@ pg_restore --exit-on-error --no-owner --no-privileges \
``` ```
extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预演必须确认 restore extension 应由 Tenant spec 创建。若 dump 仍包含 extension 定义,预演必须确认 restore
行为幂等;不在 allowlist 的 extension 必须在迁移前解决。 行为幂等;目标实例不支持的 extension 必须在迁移前解决。
### 6. 验证并切换 ### 6. 验证并切换
-6
View File
@@ -38,12 +38,6 @@ controller 自动重建/纠正。
修复依赖后让正常 reconcile 自动重试。不要通过删除/重建 CR 规避 Conflict;新 UID 只会 修复依赖后让正常 reconcile 自动重试。不要通过删除/重建 CR 规避 Conflict;新 UID 只会
使已有保留资源继续冲突。 使已有保留资源继续冲突。
Instance 管理凭据在首次装配连接时读取并缓存在进程内;连接故障重试不会重新读取 Bao
或自动更换密码。Bao 暂时不可用不会影响已经装配的 PostgreSQL 连接,但会阻止新 Instance
或新凭据引用的装配。手工更换 Bao 同一路径下的管理密码后,需要重启 controller,
或将 Instance 改为引用新的凭据路径。修改 endpoint/凭据引用会关闭旧连接并重新装配;
只修改 allowlist 不会刷新凭据。
## Retain 后的资源 ## Retain 后的资源
Retain 删除完成后,database、role、OpenBao record 和 registry 所有权记录仍存在但标记 Retain 删除完成后,database、role、OpenBao record 和 registry 所有权记录仍存在但标记
+6 -3
View File
@@ -24,7 +24,8 @@ VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 O
## 凭据处理 ## 凭据处理
- controller 使用 Kubernetes auth 获取短期 OpenBao token,不配置长期静态 token。 - controller 使用 Kubernetes auth 获取短期 OpenBao token,不配置长期静态 token。
- 管理凭据只从 Instance 引用读取,不复制到 CR/status/Event/metric/trace。 - 管理凭据只从 Instance 引用的 controller namespace Secret 读取,不复制到
CR/status/Event/metric/trace;管理员维护 ExternalSecret,由 ESO 同步该 Secret。
- 租户密码使用密码学安全随机源生成一次;中断恢复必须复用 OpenBao 现值。 - 租户密码使用密码学安全随机源生成一次;中断恢复必须复用 OpenBao 现值。
- controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。 - controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。
- 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、 - 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、
@@ -41,8 +42,10 @@ VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 O
## 最小权限 ## 最小权限
OpenBao controller identity 只能读取管理凭据范围并管理固定 tenant base path;ESO OpenBao controller identity 只管理固定 tenant base path,不读取管理凭据。管理凭据
identity 只能读取 tenant base path。两者不得共用可访问管理凭据的 policy。 ESO 身份只读管理路径,租户 ESO 身份只读 tenant base path,二者隔离,Tenant 不得
使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance
不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。
PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 函数承载创建或 PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 函数承载创建或
删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向 删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向
+49 -11
View File
@@ -4,7 +4,7 @@
| --- | --- | | --- | --- |
| 状态 | Approved | | 状态 | Approved |
| 目标 API | `database.ddupan.top/v1alpha1` | | 目标 API | `database.ddupan.top/v1alpha1` |
| 最后更新 | 2026-09-10 | | 最后更新 | 2026-09-13 |
| 批准日期 | 2026-09-10 | | 批准日期 | 2026-09-10 |
| 规范范围 | 首次注册外部 PostgreSQL 实例并创建一个应用租户 | | 规范范围 | 首次注册外部 PostgreSQL 实例并创建一个应用租户 |
@@ -38,7 +38,7 @@ v1alpha1 必须实现以下目标:
1. 注册一个已经存在的外部 PostgreSQL 实例并报告连接状态。 1. 注册一个已经存在的外部 PostgreSQL 实例并报告连接状态。
2. 为一个应用租户创建独立 database 和一个同时作为 database owner 的 login role。 2. 为一个应用租户创建独立 database 和一个同时作为 database owner 的 login role。
3. 根据实例 allowlist 安装租户申请的 PostgreSQL extension。 3. 根据实例实际可安装扩展列表检查并安装租户申请的 PostgreSQL extension。
4. 首次生成高强度长期密码,并只把凭据明文写入 OpenBao KV v2。 4. 首次生成高强度长期密码,并只把凭据明文写入 OpenBao KV v2。
5. 为 Kubernetes 应用创建 ExternalSecret,由 ESO 将凭据投射到同 namespace Secret。 5. 为 Kubernetes 应用创建 ExternalSecret,由 ESO 将凭据投射到同 namespace Secret。
6. 同时输出 OpenBao API URL,使 Kubernetes 外的应用可以直接读取凭据。 6. 同时输出 OpenBao API URL,使 Kubernetes 外的应用可以直接读取凭据。
@@ -75,7 +75,7 @@ v1alpha1 不负责:
| controller 工作流阶段 | Kubernetes CR `status.phase` | 状态机 checkpoint;可由外部事实保守重建 | | controller 工作流阶段 | Kubernetes CR `status.phase` | 状态机 checkpoint;可由外部事实保守重建 |
| 应用凭据 | OpenBao KV v2 | Kubernetes API 中不得出现明文 | | 应用凭据 | OpenBao KV v2 | Kubernetes API 中不得出现明文 |
| Kubernetes 凭据投射 | External Secrets Operator | ExternalSecret 由本 controller 管理 | | Kubernetes 凭据投射 | External Secrets Operator | ExternalSecret 由本 controller 管理 |
| PostgreSQL 管理凭据 | OpenBao KV v2 | 由 `PostgreSQLInstance` 引用 | | PostgreSQL 管理凭据 | controller namespace 的 Kubernetes Secret | 管理员维护 ExternalSecret,由 ESO 同步;Instance 只引用 Secret |
平台管理员管理 `PostgreSQLInstance`、controller 部署配置、OpenBao policy 和 平台管理员管理 `PostgreSQLInstance`、controller 部署配置、OpenBao policy 和
PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管理 PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管理
@@ -93,12 +93,27 @@ PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管
- PostgreSQL host、port 和管理连接使用的 database; - PostgreSQL host、port 和管理连接使用的 database;
- PostgreSQL host address,供无法解析 DNS 的消费者使用; - PostgreSQL host address,供无法解析 DNS 的消费者使用;
- TLS mode; - TLS mode;
- PostgreSQL 管理凭据在 OpenBao 中的位置和字段名; - controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名。
- 租户允许申请的 extension 集合。
可安装的 extension 集合由应用层从目标 PostgreSQL 查询,不由管理员在 Instance
中声明。v1alpha1 不实现 allowlist;该概念保留为后续可选策略。实际可用不代表安装
权限及前置条件已满足,安装仍需执行并回读;查询失败不得被解释为扩展不支持。
实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全 实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全
建立管理连接、读取 server metadata、访问 controller registry 并使用所需管理能力。 建立管理连接、读取 server metadata、访问 controller registry 并使用所需管理能力。
Instance 身份和 endpoint 以管理员声明为准。修改 endpoint 不验证是否仍是原物理
服务器或原 registry,不增加服务器/安装身份绑定检查;但旧配置观察失效,必须按
新配置重新检查连接和管理能力。新 CR 按新 Instance 处理,不自动接管旧 UID 的租户
资源。controller 不迁移旧服务器上的数据,也不清理旧目标,影响由管理员负责评估。
Instance 开始受管时即添加 finalizer,成功保存后才参与供应,不等发现 Tenant 后
再补加。删除期间停止新供应;仍有引用它的 Tenant(包括正在删除的 Tenant)时保留
finalizer,无引用后才移除。不级联删除 Tenant 或任何外部数据库、角色、凭据。
引用检查失败不得当作无引用。首版不引入跨对象锁或准入控制:finalizer 不禁止同时
创建 Tenant CR,新 Tenant 遇到正在删除或已不存在的 Instance 时不得开始供应。
这不保证列表检查、CR 创建和在途外部操作之间的原子性;不是严格的跨对象事务。
### 5.2 PostgreSQLTenant ### 5.2 PostgreSQLTenant
`PostgreSQLTenant` 是 namespaced 资源。v1alpha1 中,一个 Tenant 精确对应: `PostgreSQLTenant` 是 namespaced 资源。v1alpha1 中,一个 Tenant 精确对应:
@@ -174,8 +189,20 @@ Token 禁止写入 Deployment、CR 或镜像。
### 8.2 管理凭据 ### 8.2 管理凭据
`PostgreSQLInstance` 只引用 PostgreSQL 管理用户名和密码所在的 OpenBao KV v2 `PostgreSQLInstance` 只引用 controller 自身 namespace 中 Kubernetes Secret 的名称及
mount-relative path。controller 对该路径只需要读取权限。 用户名、密码字段名,不允许指定 namespace 或 Bao path。endpoint 仍由 Instance 声明。
平台管理员维护 ExternalSecret,将 OpenBao 管理凭据同步到该 Secret;controller 只读
Secret,不创建或修改管理 Secret、其 ExternalSecret 或上游管理凭据。
Instance 管理连接不直接访问 Bao,不负责管理密码轮换。已装配的凭据仍可访问
PostgreSQL 且满足 registry/权限要求时,Bao 或 ESO 暂时不可用不使 Instance NotReady。
首次装配无法取得有效 Secret 时不能 Ready。检测到所引用 Secret 的有效用户名或密码
变化时,controller 使用新值重建管理连接池并重新检查管理能力;仅 metadata 或无关
字段变化不触发重建。刷新不依赖 Instance generation 变化,新连接验证失败按实际
故障报告,不能用旧连接的成功结果证明新凭据可用。
controller 不修改 PostgreSQL 密码、不回写 Secret,也不修改 Bao 管理凭据;数据库侧
凭据变更由管理员负责。这是跟随已提供凭据的连接刷新,不是自动密码轮换。
Tenant 凭据管理仍直接依赖 Bao。
### 8.3 租户凭据 ### 8.3 租户凭据
@@ -410,8 +437,9 @@ v1alpha1 不接管现有 database 或 role,但必须提供可重复、可回
明文连接。 明文连接。
3. PostgreSQL 管理 role 应使用满足本规格的最小权限,不应使用 PostgreSQL 3. PostgreSQL 管理 role 应使用满足本规格的最小权限,不应使用 PostgreSQL
superuser;若 extension 安装需要额外权限,必须单独记录例外。 superuser;若 extension 安装需要额外权限,必须单独记录例外。
4. OpenBao policy 必须限制为:读取已登记的管理凭据范围,以及创建/读取本 controller 4. controller 的 OpenBao policy 仅覆盖受管租户 KV 操作,不授予管理凭据路径权限。
管理的租户 KV 范围。 管理凭据的 ESO 同步身份与应用凭据的 ESO 读取身份隔离。controller 只在自身
namespace 获得管理 Secret 读取权限,不因此扩大跨 namespace Secret data 访问范围。
5. namespace 用户不得修改 cluster-scoped Instance。 5. namespace 用户不得修改 cluster-scoped Instance。
6. 所有 identifier、extension name 和引用字段必须在发起外部调用前校验。 6. 所有 identifier、extension name 和引用字段必须在发起外部调用前校验。
7. controller 不得通过 shell 或 `psql` 子进程执行用户输入。 7. controller 不得通过 shell 或 `psql` 子进程执行用户输入。
@@ -435,7 +463,9 @@ v1alpha1 至少必须提供:
实现 v1alpha1 第一条完整纵向切片前,测试必须覆盖: 实现 v1alpha1 第一条完整纵向切片前,测试必须覆盖:
1. 有效 Instance 可以建立 TLS 管理连接并变为 Ready。 1. 有效 Instance 可以建立 TLS 管理连接并变为 Ready。
2. PostgreSQL 或 OpenBao 暂时不可用时 Ready=False,恢复后自动变为 Ready。 2. PostgreSQL 管理能力不可用时 Instance Ready=False,恢复后自动变为 Ready;已有
管理凭据可正常使用时,Bao/ESO 故障不单独影响 Instance Ready。首次装配缺少有效
管理 Secret 时不能 Ready;Tenant 的 Bao 操作失败按其自身依赖故障报告。
3. 有效 Tenant 创建 database、作为 owner 的 login、grant、extension 和 OpenBao 3. 有效 Tenant 创建 database、作为 owner 的 login、grant、extension 和 OpenBao
记录。 记录。
4. 应用凭据可以实际连接且不能创建其他 database/role。 4. 应用凭据可以实际连接且不能创建其他 database/role。
@@ -443,7 +473,8 @@ v1alpha1 至少必须提供:
6. controller 在每个外部写入步骤后中断,重启后都能继续并得到相同最终状态。 6. controller 在每个外部写入步骤后中断,重启后都能继续并得到相同最终状态。
7. 预先存在且不属于当前 Tenant UID 的 database、role 或 OpenBao path 导致 7. 预先存在且不属于当前 Tenant UID 的 database、role 或 OpenBao path 导致
Conflict,且不修改已有资源。 Conflict,且不修改已有资源。
8. 未在 allowlist 的 extension 在任何外部写入前被拒绝。 8. 目标实例实际不支持的 extension 在供应外部写入前被拒绝;扩展列表查询失败时
按依赖故障处理,不报告为不支持。安装结果仍须回读验证。
9. status 被清空后可以从两个外部事实来源重建。 9. status 被清空后可以从两个外部事实来源重建。
10. 删除 Retain Tenant 后外部资源仍存在且不再受管;重新创建同名 Tenant 报告 10. 删除 Retain Tenant 后外部资源仍存在且不再受管;重新创建同名 Tenant 报告
Conflict。 Conflict。
@@ -496,6 +527,13 @@ v1alpha1 至少必须提供:
## 17. 批准状态 ## 17. 批准状态
2026-09-14 确认 extension 判定修订:v1alpha1 使用实例实际可安装列表,不实现管理员
allowlist;后续可按需引入策略。现有 allowedExtensions 字段尚待 API 实现移除。
2026-09-13 已确认管理连接修订:Instance 引用 controller namespace 内的管理 Secret,
管理员维护 ExternalSecret,由 ESO 同步;controller 不再从 Bao 直接读取管理凭据。
此项是已批准行为,现有 API types 与实现尚待后续修改。
具体设计决策和本文整体已于 2026-09-10 获得批准,可以进入 API reference、测试和 具体设计决策和本文整体已于 2026-09-10 获得批准,可以进入 API reference、测试和
实现阶段。同日确认状态机修订:两个 CR 的 `status.phase` 是 controller 工作流的权威 实现阶段。同日确认状态机修订:两个 CR 的 `status.phase` 是 controller 工作流的权威
checkpoint;PostgreSQL registry 只承担所有权、安装身份和保留状态。 checkpoint;PostgreSQL registry 只承担所有权、安装身份和保留状态。
@@ -18,7 +18,6 @@ package controller
import ( import (
"context" "context"
"fmt"
"reflect" "reflect"
"time" "time"
@@ -34,16 +33,15 @@ import (
// PostgreSQLInstanceReconciler reconciles a PostgreSQLInstance object // PostgreSQLInstanceReconciler reconciles a PostgreSQLInstance object
type PostgreSQLInstanceReconciler struct { type PostgreSQLInstanceReconciler struct {
client.Client client.Client
Scheme *runtime.Scheme Scheme *runtime.Scheme
Instances PostgreSQLInstances Initializer PostgreSQLInstanceInitializer
Timeout time.Duration Timeout time.Duration
} }
// PostgreSQLInstances is the external dependency boundary used by the Instance state machine. // PostgreSQLInstanceInitializer is the external dependency boundary used by the Instance state machine.
type PostgreSQLInstances interface { type PostgreSQLInstanceInitializer interface {
Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error)
InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error)
Forget(string)
} }
// +kubebuilder:rbac:groups=database.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=database.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;create;update;patch;delete
@@ -63,20 +61,16 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R
logger := logf.FromContext(ctx) logger := logf.FromContext(ctx)
instance := &databasev1alpha1.PostgreSQLInstance{} instance := &databasev1alpha1.PostgreSQLInstance{}
if err := r.Get(ctx, req.NamespacedName, instance); err != nil { if err := r.Get(ctx, req.NamespacedName, instance); err != nil {
if apierrors.IsNotFound(err) {
r.Instances.Forget(req.Name)
}
return ctrl.Result{}, client.IgnoreNotFound(err) return ctrl.Result{}, client.IgnoreNotFound(err)
} }
before := instance.DeepCopy() before := instance.DeepCopy()
operationCtx := ctx
if r.Timeout > 0 { if r.Timeout > 0 {
var cancel context.CancelFunc var cancel context.CancelFunc
operationCtx, cancel = context.WithTimeout(ctx, r.Timeout) ctx, cancel = context.WithTimeout(ctx, r.Timeout)
defer cancel() defer cancel()
} }
result, reconcileErr := newInstanceStateMachine(r.Instances).reconcile(operationCtx, instance) result, reconcileErr := newInstanceStateMachine(r.Initializer).reconcile(ctx, instance)
if !reflect.DeepEqual(before.Status, instance.Status) { if !reflect.DeepEqual(before.Status, instance.Status) {
if err := r.Status().Patch(ctx, instance, client.MergeFrom(before)); err != nil { if err := r.Status().Patch(ctx, instance, client.MergeFrom(before)); err != nil {
@@ -92,9 +86,6 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R
// SetupWithManager sets up the controller with the Manager. // SetupWithManager sets up the controller with the Manager.
func (r *PostgreSQLInstanceReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *PostgreSQLInstanceReconciler) SetupWithManager(mgr ctrl.Manager) error {
if r.Instances == nil {
return fmt.Errorf("instance service must be injected before controller setup")
}
return ctrl.NewControllerManagedBy(mgr). return ctrl.NewControllerManagedBy(mgr).
For(&databasev1alpha1.PostgreSQLInstance{}). For(&databasev1alpha1.PostgreSQLInstance{}).
Named("postgresqlinstance"). Named("postgresqlinstance").
@@ -77,9 +77,8 @@ var _ = Describe("PostgreSQLInstance Controller", func() {
It("should successfully reconcile the resource", func() { It("should successfully reconcile the resource", func() {
By("Reconciling the created resource") By("Reconciling the created resource")
controllerReconciler := &PostgreSQLInstanceReconciler{ controllerReconciler := &PostgreSQLInstanceReconciler{
Client: k8sClient, Client: k8sClient,
Scheme: k8sClient.Scheme(), Scheme: k8sClient.Scheme(),
Instances: fakeInstances{validateVersion: testPostgreSQLVersion, registryVersion: testPostgreSQLVersion},
} }
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
@@ -96,14 +95,6 @@ var _ = Describe("PostgreSQLInstance Controller", func() {
HaveField("ObservedGeneration", actual.Generation), HaveField("ObservedGeneration", actual.Generation),
))) )))
// Advance to Ready using an injected fake, then prove an unchanged
// readiness observation does not patch status.
for range 2 {
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
Expect(err).NotTo(HaveOccurred())
}
Expect(k8sClient.Get(ctx, typeNamespacedName, actual)).To(Succeed())
Expect(actual.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseReady))
resourceVersion := actual.ResourceVersion resourceVersion := actual.ResourceVersion
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
@@ -28,12 +28,12 @@ import (
type instancePhaseHandler func(context.Context, *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) type instancePhaseHandler func(context.Context, *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error)
type instanceStateMachine struct { type instanceStateMachine struct {
instances PostgreSQLInstances initializer PostgreSQLInstanceInitializer
handlers map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler handlers map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler
} }
func newInstanceStateMachine(instances PostgreSQLInstances) *instanceStateMachine { func newInstanceStateMachine(initializer PostgreSQLInstanceInitializer) *instanceStateMachine {
m := &instanceStateMachine{instances: instances} m := &instanceStateMachine{initializer: initializer}
m.handlers = map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler{ m.handlers = map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler{
databasev1alpha1.PostgreSQLInstancePhasePending: m.pending, databasev1alpha1.PostgreSQLInstancePhasePending: m.pending,
databasev1alpha1.PostgreSQLInstancePhaseValidating: m.validate, databasev1alpha1.PostgreSQLInstancePhaseValidating: m.validate,
@@ -45,8 +45,6 @@ func newInstanceStateMachine(instances PostgreSQLInstances) *instanceStateMachin
} }
func (m *instanceStateMachine) reconcile(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { func (m *instanceStateMachine) reconcile(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
// Dispatch exactly one handler from the persisted checkpoint. A handler performs
// its state action and returns the next checkpoint for the Reconciler to save.
phase := instance.Status.Phase phase := instance.Status.Phase
if !instance.DeletionTimestamp.IsZero() { if !instance.DeletionTimestamp.IsZero() {
phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting
@@ -71,13 +69,15 @@ func (m *instanceStateMachine) reconcile(ctx context.Context, instance *database
} }
func (m *instanceStateMachine) pending(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { func (m *instanceStateMachine) pending(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
// Persist intent before the next reconcile opens external connections.
return advanceInstance(instance, databasev1alpha1.PostgreSQLInstancePhaseValidating, return advanceInstance(instance, databasev1alpha1.PostgreSQLInstancePhaseValidating,
"instance dependencies are being validated"), nil "instance dependencies are being validated"), nil
} }
func (m *instanceStateMachine) validate(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { func (m *instanceStateMachine) validate(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
version, err := m.instances.Validate(ctx, instance) if m.initializer == nil {
return ctrl.Result{}, nil
}
version, err := m.initializer.Validate(ctx, instance)
if err != nil { if err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
} }
@@ -87,7 +87,10 @@ func (m *instanceStateMachine) validate(ctx context.Context, instance *databasev
} }
func (m *instanceStateMachine) initializeRegistry(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { func (m *instanceStateMachine) initializeRegistry(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
version, err := m.instances.InitializeRegistry(ctx, instance) if m.initializer == nil {
return ctrl.Result{}, nil
}
version, err := m.initializer.InitializeRegistry(ctx, instance)
if err != nil { if err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
} }
@@ -102,7 +105,10 @@ func (m *instanceStateMachine) ready(ctx context.Context, instance *databasev1al
if instance.Status.ObservedGeneration != instance.Generation { if instance.Status.ObservedGeneration != instance.Generation {
return m.pending(ctx, instance) return m.pending(ctx, instance)
} }
version, err := m.instances.Validate(ctx, instance) if m.initializer == nil {
return ctrl.Result{}, nil
}
version, err := m.initializer.Validate(ctx, instance)
if err != nil { if err != nil {
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating
return ctrl.Result{}, err return ctrl.Result{}, err
@@ -112,7 +118,6 @@ func (m *instanceStateMachine) ready(ctx context.Context, instance *databasev1al
} }
func (m *instanceStateMachine) deleting(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { func (m *instanceStateMachine) deleting(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) {
m.instances.Forget(instance.Name)
instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting
setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance deletion is reconciling") setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance deletion is reconciling")
return ctrl.Result{}, nil return ctrl.Result{}, nil
+13 -15
View File
@@ -28,7 +28,7 @@ import (
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
) )
type fakeInstances struct { type fakeInstanceInitializer struct {
validateVersion string validateVersion string
registryVersion string registryVersion string
err error err error
@@ -36,20 +36,18 @@ type fakeInstances struct {
const testPostgreSQLVersion = "17.6" const testPostgreSQLVersion = "17.6"
func (f fakeInstances) Forget(string) {} func (f fakeInstanceInitializer) Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) {
func (f fakeInstances) Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) {
return f.validateVersion, f.err return f.validateVersion, f.err
} }
func (f fakeInstances) InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) { func (f fakeInstanceInitializer) InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) {
return f.registryVersion, f.err return f.registryVersion, f.err
} }
var _ = Describe("phase handler state machines", func() { var _ = Describe("phase handler state machines", func() {
It("persists validation intent before touching unavailable dependencies", func() { It("persists validation intent before touching unavailable dependencies", func() {
instance := &databasev1alpha1.PostgreSQLInstance{} instance := &databasev1alpha1.PostgreSQLInstance{}
machine := newInstanceStateMachine(fakeInstances{err: errors.New("must not be called")}) machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("must not be called")})
result, err := machine.reconcile(context.Background(), instance) result, err := machine.reconcile(context.Background(), instance)
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
Expect(result.RequeueAfter).To(BeNumerically(">", 0)) Expect(result.RequeueAfter).To(BeNumerically(">", 0))
@@ -64,7 +62,7 @@ var _ = Describe("phase handler state machines", func() {
Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3, Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3,
}, },
} }
machine := newInstanceStateMachine(fakeInstances{err: errors.New("must not be called")}) machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("must not be called")})
_, err := machine.reconcile(context.Background(), instance) _, err := machine.reconcile(context.Background(), instance)
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
@@ -78,12 +76,12 @@ var _ = Describe("phase handler state machines", func() {
Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3, Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3,
}, },
} }
machine := newInstanceStateMachine(fakeInstances{err: errors.New("unavailable")}) machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("unavailable")})
_, err := machine.reconcile(context.Background(), instance) _, err := machine.reconcile(context.Background(), instance)
Expect(err).To(MatchError("unavailable")) Expect(err).To(MatchError("unavailable"))
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse))
machine.instances = fakeInstances{ machine.initializer = fakeInstanceInitializer{
validateVersion: testPostgreSQLVersion, validateVersion: testPostgreSQLVersion,
registryVersion: testPostgreSQLVersion, registryVersion: testPostgreSQLVersion,
} }
@@ -104,7 +102,7 @@ var _ = Describe("phase handler state machines", func() {
ObjectMeta: metav1.ObjectMeta{Generation: 3}, ObjectMeta: metav1.ObjectMeta{Generation: 3},
Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating}, Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating},
} }
machine := newInstanceStateMachine(fakeInstances{ machine := newInstanceStateMachine(fakeInstanceInitializer{
validateVersion: testPostgreSQLVersion, validateVersion: testPostgreSQLVersion,
registryVersion: testPostgreSQLVersion, registryVersion: testPostgreSQLVersion,
}) })
@@ -126,7 +124,7 @@ var _ = Describe("phase handler state machines", func() {
ObjectMeta: metav1.ObjectMeta{Generation: 2}, ObjectMeta: metav1.ObjectMeta{Generation: 2},
Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating}, Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating},
} }
machine := newInstanceStateMachine(fakeInstances{err: errors.New("unavailable")}) machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("unavailable")})
_, err := machine.reconcile(context.Background(), instance) _, err := machine.reconcile(context.Background(), instance)
Expect(err).To(MatchError("unavailable")) Expect(err).To(MatchError("unavailable"))
Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating))
@@ -137,7 +135,7 @@ var _ = Describe("phase handler state machines", func() {
DescribeTable("dispatches Instance phases", DescribeTable("dispatches Instance phases",
func(instance *databasev1alpha1.PostgreSQLInstance, expected databasev1alpha1.PostgreSQLInstancePhase, hasMessage bool) { func(instance *databasev1alpha1.PostgreSQLInstance, expected databasev1alpha1.PostgreSQLInstancePhase, hasMessage bool) {
_, err := newInstanceStateMachine(fakeInstances{}).reconcile(context.Background(), instance) _, err := newInstanceStateMachine(nil).reconcile(context.Background(), instance)
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
Expect(instance.Status.Phase).To(Equal(expected)) Expect(instance.Status.Phase).To(Equal(expected))
if hasMessage { if hasMessage {
@@ -151,12 +149,12 @@ var _ = Describe("phase handler state machines", func() {
databasev1alpha1.PostgreSQLInstancePhaseValidating, databasev1alpha1.PostgreSQLInstancePhaseValidating,
true, true,
), ),
Entry("initializes the registry and reaches Ready", Entry("keeps an active phase until its handler can observe dependencies",
&databasev1alpha1.PostgreSQLInstance{Status: databasev1alpha1.PostgreSQLInstanceStatus{ &databasev1alpha1.PostgreSQLInstance{Status: databasev1alpha1.PostgreSQLInstanceStatus{
Phase: databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry, Phase: databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry,
}}, }},
databasev1alpha1.PostgreSQLInstancePhaseReady, databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry,
true, false,
), ),
Entry("routes deletion independently of the previous phase", Entry("routes deletion independently of the previous phase",
deletingInstance(), deletingInstance(),
@@ -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 instance
import (
"errors"
"regexp"
)
// CredentialReferenceValues contains effective field mappings, not secret data.
// The application supplies defaults and fixes the namespace to the controller's.
// Namespace and provider-specific paths are deliberately not selectable here.
type CredentialReferenceValues struct {
Name string
UsernameKey string
PasswordKey string
}
// CredentialReference is an immutable reference to a management Secret.
// Its zero value is invalid; aggregate construction must Validate incoming values.
type CredentialReference struct {
values CredentialReferenceValues
}
// Instance and Secret names share the DNS subdomain syntax and 253-character limit.
var dnsSubdomainName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
func NewCredentialReference(values CredentialReferenceValues) (CredentialReference, error) {
reference := CredentialReference{values: values}
if err := reference.Validate(); err != nil {
return CredentialReference{}, err
}
return reference, nil
}
// Values returns a copy of the reference, never secret contents.
func (r CredentialReference) Values() CredentialReferenceValues { return r.values }
// Validate enforces reference invariants without accessing Kubernetes or OpenBao.
// Checking that the referenced Secret contains nonempty credentials is an application
// responsibility. Errors omit input values and no implicit defaults are applied.
func (r CredentialReference) Validate() error {
if len(r.values.Name) > 253 || !dnsSubdomainName.MatchString(r.values.Name) {
return errors.New("management Secret name must be a valid DNS subdomain of at most 253 characters")
}
if r.values.UsernameKey == "" {
return errors.New("management Secret username field is required")
}
if r.values.PasswordKey == "" {
return errors.New("management Secret password field is required")
}
return nil
}
@@ -0,0 +1,119 @@
/*
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_test
import (
"strings"
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
func validCredentialReference() instance.CredentialReferenceValues {
return instance.CredentialReferenceValues{
Name: "shared-postgresql-admin", UsernameKey: "username", PasswordKey: "password",
}
}
// Acceptance: docs/domain-instance.md §2. References carry names, never credentials or IO.
func TestCredentialReferenceRejectsInvalidValues(t *testing.T) {
cases := []struct {
name string
change func(*instance.CredentialReferenceValues)
}{
{"empty name", func(v *instance.CredentialReferenceValues) { v.Name = "" }},
{"uppercase", func(v *instance.CredentialReferenceValues) { v.Name = "Admin" }},
{"underscore", func(v *instance.CredentialReferenceValues) { v.Name = "pg_admin" }},
{"leading hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "-admin" }},
{"trailing hyphen", func(v *instance.CredentialReferenceValues) { v.Name = "admin-" }},
{"empty label", func(v *instance.CredentialReferenceValues) { v.Name = "pg..admin" }},
{"trailing dot", func(v *instance.CredentialReferenceValues) { v.Name = "pg." }},
{"namespace or path", func(v *instance.CredentialReferenceValues) { v.Name = "system/admin" }},
{"whitespace", func(v *instance.CredentialReferenceValues) { v.Name = " admin" }},
{"too long", func(v *instance.CredentialReferenceValues) { v.Name = strings.Repeat("a", 254) }},
{"empty username key", func(v *instance.CredentialReferenceValues) { v.UsernameKey = "" }},
{"empty password key", func(v *instance.CredentialReferenceValues) { v.PasswordKey = "" }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values := validCredentialReference()
tc.change(&values)
reference, err := instance.NewCredentialReference(values)
if err == nil {
t.Fatal("invalid credential reference accepted")
}
if reference != (instance.CredentialReference{}) {
t.Fatal("constructor returned a partial reference on failure")
}
})
}
}
func TestCredentialReferencePreservesExplicitValues(t *testing.T) {
for _, name := range []string{"a", "1", "pg.admin-1", strings.Repeat("a", 253)} {
values := validCredentialReference()
values.Name = name
values.UsernameKey = "PG_USER"
values.PasswordKey = "pg.password"
reference, err := instance.NewCredentialReference(values)
if err != nil {
t.Fatal(err)
}
if reference.Values() != values {
t.Fatal("constructor changed the explicit field mapping")
}
if err := reference.Validate(); err != nil {
t.Fatal(err)
}
}
}
func TestCredentialReferenceIsAnImmutableComparableValue(t *testing.T) {
values := validCredentialReference()
reference, err := instance.NewCredentialReference(values)
if err != nil {
t.Fatal(err)
}
same, err := instance.NewCredentialReference(values)
if err != nil {
t.Fatal(err)
}
if reference != same {
t.Fatal("identical references must compare equal")
}
values.Name = "different"
snapshot := reference.Values()
snapshot.PasswordKey = "different-key"
if reference.Values() != validCredentialReference() {
t.Fatal("caller mutated reference through a copy")
}
if err := (instance.CredentialReference{}).Validate(); err == nil {
t.Fatal("zero reference must be invalid")
}
}
func TestCredentialReferenceErrorOmitsInput(t *testing.T) {
values := validCredentialReference()
values.Name = "canary-sensitive/input"
_, err := instance.NewCredentialReference(values)
if err == nil {
t.Fatal("invalid reference accepted")
}
if strings.Contains(err.Error(), "canary") {
t.Fatal("error included input")
}
}
+89
View File
@@ -0,0 +1,89 @@
/*
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 contains the pure domain model of a registered PostgreSQL instance.
// It does not depend on Kubernetes types, database drivers or credential providers.
package instance
import (
"errors"
"net/netip"
"regexp"
)
// TLSMode is an explicit transport policy, not a driver-specific default.
type TLSMode string
const (
TLSDisable TLSMode = "disable"
TLSRequire TLSMode = "require"
TLSVerifyCA TLSMode = "verify-ca"
TLSVerifyFull TLSMode = "verify-full"
)
// EndpointValues carries explicit, effective values across the application boundary.
// Defaults are supplied by the API/application mapping, never silently by the domain.
type EndpointValues struct {
Host string
HostAddr string
Port int
ManagementDatabase string
TLSMode TLSMode
}
// Endpoint is an immutable connection target. Equality compares its declared values,
// not physical server identity. Its zero value is invalid; aggregate construction
// must Validate incoming endpoints, even if callers bypass NewEndpoint.
type Endpoint struct {
values EndpointValues
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
func NewEndpoint(values EndpointValues) (Endpoint, error) {
endpoint := Endpoint{values: values}
if err := endpoint.Validate(); err != nil {
return Endpoint{}, err
}
return endpoint, nil
}
// Values returns a copy, without exposing mutable state.
func (e Endpoint) Values() EndpointValues { return e.values }
// Validate checks local invariants only; it does not resolve DNS or perform IO.
// Errors intentionally omit input values.
func (e Endpoint) Validate() error {
if e.values.Host == "" {
return errors.New("endpoint host is required")
}
address, err := netip.ParseAddr(e.values.HostAddr)
if err != nil || address.Zone() != "" {
return errors.New("endpoint host address must be an IPv4 or IPv6 address")
}
if e.values.Port < 1 || e.values.Port > 65535 {
return errors.New("endpoint port must be between 1 and 65535")
}
if !identifier.MatchString(e.values.ManagementDatabase) {
return errors.New("endpoint management database must be a valid PostgreSQL identifier")
}
switch e.values.TLSMode {
case TLSDisable, TLSRequire, TLSVerifyCA, TLSVerifyFull:
return nil
default:
return errors.New("endpoint TLS mode must be explicitly supported")
}
}
+118
View File
@@ -0,0 +1,118 @@
/*
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_test
import (
"strings"
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
func validEndpoint() instance.EndpointValues {
return instance.EndpointValues{
Host: "postgres.home.arpa", HostAddr: "192.0.2.10", Port: 5432,
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
}
}
// Acceptance: docs/domain-instance.md §2, explicit values and no implicit TLS downgrade.
func TestEndpointRejectsInvalidValues(t *testing.T) {
cases := []struct {
name string
change func(*instance.EndpointValues)
}{
{"empty host", func(v *instance.EndpointValues) { v.Host = "" }},
{"missing address", func(v *instance.EndpointValues) { v.HostAddr = "" }},
{"DNS instead of IP", func(v *instance.EndpointValues) { v.HostAddr = "postgres.home.arpa" }},
{"invalid IP", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.999" }},
{"address with port", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.10:5432" }},
{"scoped address", func(v *instance.EndpointValues) { v.HostAddr = "fe80::1%eth0" }},
{"zero port", func(v *instance.EndpointValues) { v.Port = 0 }},
{"negative port", func(v *instance.EndpointValues) { v.Port = -1 }},
{"large port", func(v *instance.EndpointValues) { v.Port = 65536 }},
{"empty database", func(v *instance.EndpointValues) { v.ManagementDatabase = "" }},
{"uppercase database", func(v *instance.EndpointValues) { v.ManagementDatabase = "Postgres" }},
{"leading digit", func(v *instance.EndpointValues) { v.ManagementDatabase = "1postgres" }},
{"punctuation", func(v *instance.EndpointValues) { v.ManagementDatabase = "post-gres" }},
{"NUL", func(v *instance.EndpointValues) { v.ManagementDatabase = "post\x00gres" }},
{"long identifier", func(v *instance.EndpointValues) { v.ManagementDatabase = strings.Repeat("a", 64) }},
{"missing TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "" }},
{"unsupported TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "prefer" }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values := validEndpoint()
tc.change(&values)
endpoint, err := instance.NewEndpoint(values)
if err == nil {
t.Fatal("invalid endpoint accepted")
}
if endpoint != (instance.Endpoint{}) {
t.Fatal("constructor returned a partial endpoint on failure")
}
})
}
}
func TestEndpointPreservesValidValues(t *testing.T) {
for _, mode := range []instance.TLSMode{
instance.TLSDisable, instance.TLSRequire, instance.TLSVerifyCA, instance.TLSVerifyFull,
} {
for _, address := range []string{"192.0.2.10", "2001:db8::10"} {
for _, port := range []int{1, 65535} {
values := validEndpoint()
values.TLSMode, values.HostAddr, values.Port = mode, address, port
values.ManagementDatabase = "a" + strings.Repeat("_", 62)
endpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
if endpoint.Values() != values {
t.Fatal("constructor changed explicit values")
}
if err := endpoint.Validate(); err != nil {
t.Fatal(err)
}
}
}
}
}
func TestEndpointIsAnImmutableComparableValue(t *testing.T) {
values := validEndpoint()
endpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
same, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
if endpoint != same {
t.Fatal("identical endpoint values must compare equal")
}
values.Host = "changed.example"
snapshot := endpoint.Values()
snapshot.Host = values.Host
if endpoint.Values().Host == snapshot.Host {
t.Fatal("caller mutated endpoint through a copy")
}
if err := (instance.Endpoint{}).Validate(); err == nil {
t.Fatal("zero endpoint must not be valid")
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
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 "slices"
// ExtensionSet is an immutable set of exact names. Zero represents the empty set.
// It does not impose identifier syntax or claim that a server supports any name.
type ExtensionSet struct {
names []string
}
func NewExtensionSet(names []string) ExtensionSet {
copied := slices.Clone(names)
slices.Sort(copied)
return ExtensionSet{names: slices.Compact(copied)}
}
// Names returns a sorted, deduplicated copy.
func (s ExtensionSet) Names() []string { return slices.Clone(s.names) }
type ExtensionDecision string
const (
ExtensionsAccepted ExtensionDecision = "Accepted"
ExtensionsUnsupported ExtensionDecision = "ExtensionsUnsupported"
ExtensionSupportUnobserved ExtensionDecision = "ExtensionSupportUnobserved"
)
// ExtensionCheck reports support only, not readiness or permission to install.
// Unsupported is a detached, sorted list and is populated only for known support.
type ExtensionCheck struct {
Decision ExtensionDecision
Unsupported []string
}
// ExtensionSupport is the extension-list component of an Instance observation.
// Zero means unobserved, not an observed empty list. Target/revision binding and
// invalidation belong to the containing Instance observation, not this set value.
type ExtensionSupport struct {
observed bool
available ExtensionSet
}
// ObserveExtensionSupport records a successfully read list, including an empty one.
// A failed query must not call this constructor with an empty list: the application
// must propagate the dependency failure and leave support unobserved.
func ObserveExtensionSupport(available []string) ExtensionSupport {
return ExtensionSupport{observed: true, available: NewExtensionSet(available)}
}
// Check performs no IO and cannot install or remove extensions.
func (s ExtensionSupport) Check(requested ExtensionSet) ExtensionCheck {
if len(requested.names) == 0 {
return ExtensionCheck{Decision: ExtensionsAccepted}
}
if !s.observed {
return ExtensionCheck{Decision: ExtensionSupportUnobserved}
}
var unsupported []string
for _, name := range requested.names {
if _, found := slices.BinarySearch(s.available.names, name); !found {
unsupported = append(unsupported, name)
}
}
if len(unsupported) != 0 {
return ExtensionCheck{Decision: ExtensionsUnsupported, Unsupported: unsupported}
}
return ExtensionCheck{Decision: ExtensionsAccepted}
}
+107
View File
@@ -0,0 +1,107 @@
/*
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_test
import (
"slices"
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
const (
testUUID = "uuid-ossp"
testTrigram = "pg_trgm"
testVector = "vector"
testChanged = "changed"
)
// Acceptance: docs/domain-instance.md, extension support is based on observations,
// not a name regexp or an administrator allowlist.
func TestExtensionSupportDecisions(t *testing.T) {
available := instance.ObserveExtensionSupport([]string{testTrigram, testUUID})
cases := []struct {
name string
support instance.ExtensionSupport
requested []string
want instance.ExtensionDecision
unsupported []string
}{
{"unobserved", instance.ExtensionSupport{}, []string{testTrigram}, instance.ExtensionSupportUnobserved, nil},
{"observed empty", instance.ObserveExtensionSupport(nil), []string{testTrigram}, instance.ExtensionsUnsupported, []string{testTrigram}},
{"empty request", instance.ExtensionSupport{}, nil, instance.ExtensionsAccepted, nil},
{"supported", available, []string{testUUID, testTrigram, testTrigram}, instance.ExtensionsAccepted, nil},
{"unsupported", available, []string{testVector, "hstore", testVector, testTrigram},
instance.ExtensionsUnsupported, []string{"hstore", testVector}},
{"exact names", available, []string{"PG_TRGM"}, instance.ExtensionsUnsupported, []string{"PG_TRGM"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := tc.support.Check(instance.NewExtensionSet(tc.requested))
if result.Decision != tc.want || !slices.Equal(result.Unsupported, tc.unsupported) {
t.Fatalf("Check() = %v, want %v / %v", result, tc.want, tc.unsupported)
}
})
}
}
func TestExtensionSetCopiesAndCanonicalizesNames(t *testing.T) {
input := []string{testUUID, testTrigram, testUUID}
set := instance.NewExtensionSet(input)
input[0] = testChanged
names := set.Names()
want := []string{testTrigram, testUUID}
if !slices.Equal(names, want) {
t.Fatalf("Names() = %v, want %v", names, want)
}
names[0] = testChanged
if !slices.Equal(set.Names(), want) {
t.Fatal("returned slice mutated set")
}
if len((instance.ExtensionSet{}).Names()) != 0 {
t.Fatal("zero set must be empty")
}
// Names are preserved exactly; actual server support, not a local regexp, is decisive.
unusual := []string{"Vendor.Extension", testUUID}
if result := instance.ObserveExtensionSupport(unusual).Check(instance.NewExtensionSet(unusual)); result.Decision != instance.ExtensionsAccepted {
t.Fatal("imposed a local name restriction")
}
}
func TestExtensionSupportCopiesObservationAndResults(t *testing.T) {
input := []string{testTrigram}
support := instance.ObserveExtensionSupport(input)
input[0] = testVector
requested := instance.NewExtensionSet([]string{testTrigram, testVector})
result := support.Check(requested)
if !slices.Equal(result.Unsupported, []string{testVector}) {
t.Fatal("input mutation changed observation")
}
result.Unsupported[0] = testChanged
again := support.Check(requested)
if !slices.Equal(again.Unsupported, []string{testVector}) {
t.Fatal("result mutation changed subsequent decision")
}
// Replacing an observation does not mutate the old value or produce uninstall actions.
empty := instance.ObserveExtensionSupport(nil)
if empty.Check(requested).Decision != instance.ExtensionsUnsupported {
t.Fatal("empty observation ignored")
}
if support.Check(instance.NewExtensionSet([]string{testTrigram})).Decision != instance.ExtensionsAccepted {
t.Fatal("new observation mutated old value")
}
}
+73
View File
@@ -0,0 +1,73 @@
/*
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 "errors"
// Identity identifies one registration, not a physical PostgreSQL server.
// UID is opaque; a recreated resource with the same name has a different identity.
// Its zero value is invalid and must be rejected when constructing an aggregate.
type Identity struct {
uid string
name string
}
func NewIdentity(uid, name string) (Identity, error) {
identity := Identity{uid: uid, name: name}
if err := identity.Validate(); err != nil {
return Identity{}, err
}
return identity, nil
}
func (i Identity) UID() string { return i.uid }
func (i Identity) Name() string { return i.name }
// Validate checks registration values without looking up any external identity.
func (i Identity) Validate() error {
if i.uid == "" {
return errors.New("instance UID is required")
}
if len(i.name) > 253 || !dnsSubdomainName.MatchString(i.name) {
return errors.New("instance name must be a valid DNS subdomain of at most 253 characters")
}
return nil
}
// Revision is a positive configuration generation, separate from Identity.
// Zero is invalid for desired configuration; an unobserved status generation of
// zero must be represented separately when the aggregate is implemented.
type Revision struct {
value int64
}
func NewRevision(value int64) (Revision, error) {
revision := Revision{value: value}
if err := revision.Validate(); err != nil {
return Revision{}, err
}
return revision, nil
}
func (r Revision) Value() int64 { return r.value }
func (r Revision) Validate() error {
if r.value <= 0 {
return errors.New("instance revision must be positive")
}
return nil
}
+102
View File
@@ -0,0 +1,102 @@
/*
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_test
import (
"math"
"strings"
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
// Acceptance: docs/domain-instance.md §2: registration identity is UID + name,
// independent of the configuration revision or physical PostgreSQL server.
func TestIdentityDistinguishesRecreatedInstances(t *testing.T) {
first, err := instance.NewIdentity("opaque-uid-1", "shared")
if err != nil {
t.Fatal(err)
}
same, err := instance.NewIdentity("opaque-uid-1", "shared")
if err != nil {
t.Fatal(err)
}
recreated, err := instance.NewIdentity("opaque-uid-2", "shared")
if err != nil {
t.Fatal(err)
}
if first != same || first == recreated {
t.Fatal("identity must distinguish same-name registrations by UID")
}
if first.UID() != "opaque-uid-1" || first.Name() != "shared" {
t.Fatal("identity changed declared values")
}
if err := first.Validate(); err != nil {
t.Fatal(err)
}
}
func TestIdentityValidation(t *testing.T) {
for _, name := range []string{"", "Shared", "shared_name", "ns/shared", "-shared", "pg..shared", strings.Repeat("a", 254)} {
identity, err := instance.NewIdentity("uid", name)
if err == nil || identity != (instance.Identity{}) {
t.Fatal("invalid name accepted or partial identity returned")
}
}
if _, err := instance.NewIdentity("", "shared"); err == nil {
t.Fatal("empty UID accepted")
}
if err := (instance.Identity{}).Validate(); err == nil {
t.Fatal("zero identity accepted")
}
for _, name := range []string{"a", "1", "pg.shared-1", strings.Repeat("a", 253)} {
if _, err := instance.NewIdentity("opaque-not-a-uuid", name); err != nil {
t.Fatal(err)
}
}
}
func TestRevisionRequiresPositiveValue(t *testing.T) {
for _, value := range []int64{math.MinInt64, -1, 0} {
revision, err := instance.NewRevision(value)
if err == nil || revision != (instance.Revision{}) {
t.Fatal("invalid revision accepted or partial value returned")
}
}
for _, value := range []int64{1, 2, math.MaxInt64} {
revision, err := instance.NewRevision(value)
if err != nil {
t.Fatal(err)
}
if revision.Value() != value {
t.Fatal("revision changed declared value")
}
if err := revision.Validate(); err != nil {
t.Fatal(err)
}
same, err := instance.NewRevision(value)
if err != nil {
t.Fatal(err)
}
if revision != same {
t.Fatal("identical revisions must compare equal")
}
}
if err := (instance.Revision{}).Validate(); err == nil {
t.Fatal("zero revision accepted")
}
}
+101
View File
@@ -0,0 +1,101 @@
/*
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 "errors"
// Phase is a workflow checkpoint, never evidence of external resource state.
type Phase string
const (
PhasePending Phase = "Pending"
PhaseValidating Phase = "Validating"
PhaseInitializingRegistry Phase = "InitializingRegistry"
PhaseReady Phase = "Ready"
PhaseDeleting Phase = "Deleting"
)
type Readiness string
const (
Unknown Readiness = "Unknown"
Ready Readiness = "Ready"
NotReady Readiness = "NotReady"
)
// Snapshot contains persisted observations only, without credentials or live evidence.
// Failure detail mapping will be added with capability assessment, not intent transitions.
type Snapshot struct {
Phase Phase
ObservedRevision int64
Readiness Readiness
ReportedVersion string
}
// Instance protects registration state and pure lifecycle transitions.
// Reconstitution does not establish live capability evidence, even for a Ready snapshot.
// This initial slice deliberately exposes no operation that authorizes provisioning.
type Instance struct {
target ObservationTarget
snapshot Snapshot
deleting bool
}
func Reconstitute(target ObservationTarget, snapshot Snapshot, deleting bool) (*Instance, error) {
if err := target.Validate(); err != nil {
return nil, err
}
switch snapshot.Phase {
case PhasePending, PhaseValidating, PhaseInitializingRegistry, PhaseReady, PhaseDeleting:
default:
snapshot.Phase = PhasePending
snapshot.Readiness = Unknown
}
return &Instance{target: target, snapshot: snapshot, deleting: deleting}, nil
}
func (i *Instance) Target() ObservationTarget { return i.target }
// Snapshot returns a detached value. Persisting it remains the application's job.
func (i *Instance) Snapshot() Snapshot { return i.snapshot }
// BeginValidation records intent only; it does not claim a concluded observation.
func (i *Instance) BeginValidation() error {
if err := i.target.Validate(); err != nil {
return err
}
if i.deleting {
return errors.New("cannot begin validation after deletion was requested")
}
i.snapshot.Phase = PhaseValidating
i.snapshot.Readiness = Unknown
return nil
}
// BeginDeletion stops the lifecycle from accepting validation. It does not delete
// resources, inspect Tenant references, close connections or modify finalizers.
func (i *Instance) BeginDeletion() error {
if err := i.target.Validate(); err != nil {
return err
}
if !i.deleting {
return errors.New("cannot begin deletion without a deletion request")
}
i.snapshot.Phase = PhaseDeleting
i.snapshot.Readiness = Unknown
return nil
}
+134
View File
@@ -0,0 +1,134 @@
/*
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_test
import (
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
func lifecycleInstance(t *testing.T, snapshot instance.Snapshot, deleting bool) *instance.Instance {
t.Helper()
identity, revision, definition := targetParts(t)
target, err := instance.NewObservationTarget(identity, revision, definition)
if err != nil {
t.Fatal(err)
}
value, err := instance.Reconstitute(target, snapshot, deleting)
if err != nil {
t.Fatal(err)
}
return value
}
// Acceptance: docs/domain-instance.md §3, checkpoint reconstruction and intent-only transitions.
func TestReconstituteCheckpoints(t *testing.T) {
for _, phase := range []instance.Phase{
instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry,
instance.PhaseReady, instance.PhaseDeleting,
} {
snapshot := instance.Snapshot{Phase: phase, ObservedRevision: 1, Readiness: instance.Ready, ReportedVersion: "17"}
value := lifecycleInstance(t, snapshot, false)
if value.Snapshot() != snapshot {
t.Fatal("known checkpoint was not preserved")
}
// A snapshot is detached; it is not a setter on the aggregate.
copy := value.Snapshot()
copy.Phase = instance.PhasePending
copy.ReportedVersion = "changed"
if value.Snapshot() != snapshot {
t.Fatal("snapshot mutation changed aggregate")
}
}
for _, phase := range []instance.Phase{"", "unknown"} {
value := lifecycleInstance(t, instance.Snapshot{Phase: phase, Readiness: instance.Ready}, false)
if value.Snapshot().Phase != instance.PhasePending || value.Snapshot().Readiness != instance.Unknown {
t.Fatal("missing or unknown checkpoint did not restart conservatively")
}
}
if value, err := instance.Reconstitute(instance.ObservationTarget{}, instance.Snapshot{}, false); err == nil || value != nil {
t.Fatal("invalid target reconstructed an aggregate")
}
}
func TestBeginValidationPreservesObservedRevision(t *testing.T) {
snapshot := instance.Snapshot{
Phase: instance.PhaseReady, ObservedRevision: 0, Readiness: instance.Ready, ReportedVersion: "17",
}
value := lifecycleInstance(t, snapshot, false)
target := value.Target()
for range 2 {
if err := value.BeginValidation(); err != nil {
t.Fatal(err)
}
got := value.Snapshot()
if got.Phase != instance.PhaseValidating || got.Readiness != instance.Unknown ||
got.ObservedRevision != snapshot.ObservedRevision || got.ReportedVersion != snapshot.ReportedVersion {
t.Fatal("recording validation intent claimed a completed observation or erased diagnostic version")
}
}
if value.Target() != target {
t.Fatal("lifecycle action mutated identity or configuration")
}
}
func TestDeletionRequiresRequestAndPreventsValidation(t *testing.T) {
snapshot := instance.Snapshot{Phase: instance.PhaseReady, Readiness: instance.Ready, ObservedRevision: 1}
active := lifecycleInstance(t, snapshot, false)
if err := active.BeginDeletion(); err == nil {
t.Fatal("deletion without a request accepted")
}
if active.Snapshot() != snapshot {
t.Fatal("rejected deletion mutated state")
}
for _, phase := range []instance.Phase{
instance.PhasePending, instance.PhaseValidating, instance.PhaseInitializingRegistry,
instance.PhaseReady, instance.PhaseDeleting,
} {
snapshot.Phase = phase
value := lifecycleInstance(t, snapshot, true)
if err := value.BeginValidation(); err == nil {
t.Fatal("validation accepted after deletion request")
}
if value.Snapshot() != snapshot {
t.Fatal("rejected validation mutated state")
}
for range 2 {
if err := value.BeginDeletion(); err != nil {
t.Fatal(err)
}
if got := value.Snapshot(); got.Phase != instance.PhaseDeleting || got.Readiness != instance.Unknown ||
got.ObservedRevision != snapshot.ObservedRevision {
t.Fatal("incorrect deletion checkpoint")
}
}
}
}
func TestZeroInstanceCannotTransition(t *testing.T) {
var value instance.Instance
if err := value.BeginValidation(); err == nil {
t.Fatal("zero instance started validation")
}
if err := value.BeginDeletion(); err == nil {
t.Fatal("zero instance started deletion")
}
if value.Snapshot() != (instance.Snapshot{}) {
t.Fatal("invalid transition changed zero instance")
}
}
@@ -0,0 +1,81 @@
/*
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
// Definition is the immutable effective configuration of an Instance.
// Available extensions are observations, not part of the declared configuration.
type Definition struct {
endpoint Endpoint
adminCredential CredentialReference
}
func NewDefinition(endpoint Endpoint, adminCredential CredentialReference) (Definition, error) {
definition := Definition{endpoint: endpoint, adminCredential: adminCredential}
if err := definition.Validate(); err != nil {
return Definition{}, err
}
return definition, nil
}
func (d Definition) Endpoint() Endpoint { return d.endpoint }
func (d Definition) AdminCredential() CredentialReference { return d.adminCredential }
// Validate rejects invalid zero-value components even when constructors were bypassed.
func (d Definition) Validate() error {
if err := d.endpoint.Validate(); err != nil {
return err
}
return d.adminCredential.Validate()
}
// ObservationTarget binds facts to a registration and its declared configuration.
// It does not identify a physical PostgreSQL server or prove observation freshness.
// Secret content refresh and same-target observation freshness remain application
// responsibilities; no credentials or Secret contents are carried by this value.
type ObservationTarget struct {
identity Identity
revision Revision
definition Definition
}
func NewObservationTarget(identity Identity, revision Revision, definition Definition) (ObservationTarget, error) {
target := ObservationTarget{identity: identity, revision: revision, definition: definition}
if err := target.Validate(); err != nil {
return ObservationTarget{}, err
}
return target, nil
}
func (t ObservationTarget) Identity() Identity { return t.identity }
func (t ObservationTarget) Revision() Revision { return t.revision }
func (t ObservationTarget) Definition() Definition { return t.definition }
func (t ObservationTarget) Validate() error {
if err := t.identity.Validate(); err != nil {
return err
}
if err := t.revision.Validate(); err != nil {
return err
}
return t.definition.Validate()
}
// Matches rejects invalid targets before comparing values. Matching is necessary,
// but not sufficient, for the aggregate to accept a fresh capability observation.
func (t ObservationTarget) Matches(other ObservationTarget) bool {
return t.Validate() == nil && other.Validate() == nil && t == other
}
@@ -0,0 +1,165 @@
/*
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_test
import (
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
func targetParts(t *testing.T) (instance.Identity, instance.Revision, instance.Definition) {
t.Helper()
identity, err := instance.NewIdentity("uid-1", "shared")
if err != nil {
t.Fatal(err)
}
revision, err := instance.NewRevision(1)
if err != nil {
t.Fatal(err)
}
endpoint, err := instance.NewEndpoint(validEndpoint())
if err != nil {
t.Fatal(err)
}
credential, err := instance.NewCredentialReference(validCredentialReference())
if err != nil {
t.Fatal(err)
}
definition, err := instance.NewDefinition(endpoint, credential)
if err != nil {
t.Fatal(err)
}
return identity, revision, definition
}
func TestDefinitionRejectsInvalidComponents(t *testing.T) {
_, _, definition := targetParts(t)
cases := []struct {
endpoint instance.Endpoint
credential instance.CredentialReference
}{
{instance.Endpoint{}, definition.AdminCredential()},
{definition.Endpoint(), instance.CredentialReference{}},
}
for _, tc := range cases {
value, err := instance.NewDefinition(tc.endpoint, tc.credential)
if err == nil || value != (instance.Definition{}) {
t.Fatal("invalid component accepted or partial value returned")
}
}
if err := (instance.Definition{}).Validate(); err == nil {
t.Fatal("zero definition accepted")
}
}
func TestObservationTargetRejectsInvalidComponents(t *testing.T) {
identity, revision, definition := targetParts(t)
cases := []struct {
identity instance.Identity
revision instance.Revision
definition instance.Definition
}{
{instance.Identity{}, revision, definition},
{identity, instance.Revision{}, definition},
{identity, revision, instance.Definition{}},
}
for _, tc := range cases {
value, err := instance.NewObservationTarget(tc.identity, tc.revision, tc.definition)
if err == nil || value != (instance.ObservationTarget{}) {
t.Fatal("invalid component accepted or partial target returned")
}
}
zero := instance.ObservationTarget{}
if err := zero.Validate(); err == nil {
t.Fatal("zero target accepted")
}
if zero.Matches(zero) {
t.Fatal("two invalid targets must not authorize observation reuse")
}
}
// Acceptance: docs/domain-instance.md §2/§6, observations cannot cross target bindings.
func TestObservationTargetMatchesOnlySameBinding(t *testing.T) {
identity, revision, definition := targetParts(t)
original, err := instance.NewObservationTarget(identity, revision, definition)
if err != nil {
t.Fatal(err)
}
same, err := instance.NewObservationTarget(identity, revision, definition)
if err != nil {
t.Fatal(err)
}
if !original.Matches(same) || original.Identity() != identity ||
original.Revision() != revision || original.Definition() != definition {
t.Fatal("target did not preserve its declared binding")
}
changedIdentity, err := instance.NewIdentity("uid-2", identity.Name())
if err != nil {
t.Fatal(err)
}
changedRevision, err := instance.NewRevision(2)
if err != nil {
t.Fatal(err)
}
for _, parts := range []struct {
identity instance.Identity
revision instance.Revision
}{{changedIdentity, revision}, {identity, changedRevision}} {
changed, err := instance.NewObservationTarget(parts.identity, parts.revision, definition)
if err != nil {
t.Fatal(err)
}
if original.Matches(changed) || changed.Matches(original) {
t.Fatal("different registration or revision matched")
}
}
endpointValues := definition.Endpoint().Values()
endpointValues.Host = "other.example"
endpoint, err := instance.NewEndpoint(endpointValues)
if err != nil {
t.Fatal(err)
}
credentialValues := definition.AdminCredential().Values()
credentialValues.PasswordKey = "replacement"
credential, err := instance.NewCredentialReference(credentialValues)
if err != nil {
t.Fatal(err)
}
for _, components := range []struct {
endpoint instance.Endpoint
credential instance.CredentialReference
}{{endpoint, definition.AdminCredential()}, {definition.Endpoint(), credential}} {
changedDefinition, err := instance.NewDefinition(components.endpoint, components.credential)
if err != nil {
t.Fatal(err)
}
changed, err := instance.NewObservationTarget(identity, revision, changedDefinition)
if err != nil {
t.Fatal(err)
}
if original.Matches(changed) {
t.Fatal("changed definition matched even with the same revision")
}
}
if original.Matches(instance.ObservationTarget{}) {
t.Fatal("valid target matched zero target")
}
if !original.Matches(same) {
t.Fatal("constructing changed targets mutated the original")
}
}
+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")
}
}
-133
View File
@@ -1,133 +0,0 @@
/*
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 supplies credentials using one shared client and Kubernetes identity.
package openbao
import (
"context"
"errors"
"sync"
"time"
api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance"
kubernetesauth "github.com/openbao/openbao/api/auth/kubernetes/v2"
bao "github.com/openbao/openbao/api/v2"
)
// Credentials owns authentication; database connections never depend on this client.
type Credentials struct {
client *bao.Client
mount string
authenticate func(context.Context) (*bao.Secret, error)
mu sync.Mutex
expires time.Time
}
func NewCredentials(client *bao.Client, mount, authMount, role string) *Credentials {
source := &Credentials{client: client, mount: mount}
source.authenticate = func(ctx context.Context) (*bao.Secret, error) {
// Constructing auth here rereads the projected JWT after Kubernetes rotates it.
auth, err := kubernetesauth.NewKubernetesAuth(role, kubernetesauth.WithMountPath(authMount))
if err != nil {
return nil, instance.Failure{Reason: api.ReasonAuthenticationFailed, Operation: "read Kubernetes identity"}
}
return client.Auth().Login(ctx, auth)
}
return source
}
func (s *Credentials) login(ctx context.Context) error {
secret, err := s.authenticate(ctx)
if err != nil {
return classify("authenticate to OpenBao", err, true)
}
if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" || secret.Auth.LeaseDuration <= 0 {
return instance.Failure{Reason: api.ReasonAuthenticationFailed, Operation: "OpenBao returned no leased identity"}
}
s.client.SetToken(secret.Auth.ClientToken)
ttl := time.Duration(secret.Auth.LeaseDuration) * time.Second
s.expires = time.Now().Add(ttl - ttl/10)
return nil
}
func (s *Credentials) Read(ctx context.Context, reference api.OpenBaoSecretReference) (instance.Credentials, error) {
// Login and reads share a lock so concurrent first-use requests cannot race token replacement.
s.mu.Lock()
defer s.mu.Unlock()
if !time.Now().Before(s.expires) {
if err := s.login(ctx); err != nil {
return instance.Credentials{}, err
}
}
secret, err := s.client.KVv2(s.mount).Get(ctx, reference.Path)
var response *bao.ResponseError
if errors.As(err, &response) && response.StatusCode == 403 {
// A token may be revoked before its TTL. Reauthenticate once, never loop on denied policies.
s.expires = time.Time{}
if err := s.login(ctx); err != nil {
return instance.Credentials{}, err
}
secret, err = s.client.KVv2(s.mount).Get(ctx, reference.Path)
}
if err != nil {
return instance.Credentials{}, classify("read administrative credential", err, false)
}
usernameKey, passwordKey := reference.UsernameKey, reference.PasswordKey
if usernameKey == "" {
usernameKey = "username"
}
if passwordKey == "" {
passwordKey = "password"
}
if secret == nil {
return instance.Credentials{}, invalidCredential()
}
username, usernameOK := secret.Data[usernameKey].(string)
password, passwordOK := secret.Data[passwordKey].(string)
if !usernameOK || !passwordOK || username == "" || password == "" {
return instance.Credentials{}, invalidCredential()
}
return instance.Credentials{Username: username, Password: password}, nil
}
func invalidCredential() error {
return instance.Failure{Reason: api.ReasonAuthenticationFailed, Operation: "administrative credential fields are missing"}
}
func classify(operation string, err error, login bool) error {
reason := api.ReasonDependencyUnavailable
var safe instance.Failure
if errors.As(err, &safe) {
return safe
}
var response *bao.ResponseError
if errors.As(err, &response) {
switch response.StatusCode {
case 403:
reason = api.ReasonInsufficientPrivileges
if login {
reason = api.ReasonAuthenticationFailed
}
case 400, 401:
reason = api.ReasonAuthenticationFailed
case 404:
reason = api.ReasonInvalidSpec
}
}
return instance.Failure{Reason: reason, Operation: operation}
}
-125
View File
@@ -1,125 +0,0 @@
/*
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
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
bao "github.com/openbao/openbao/api/v2"
)
const testToken = "test-token"
const testAdmin = "admin"
func TestSharedIdentityReauthenticatesOnExpiryAndRevocation(t *testing.T) {
var logins atomic.Int32
var revoked atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/secret/data/admin" {
http.NotFound(w, r)
return
}
if revoked.Swap(false) {
w.WriteHeader(http.StatusForbidden)
return
}
if r.Header.Get("X-Vault-Token") != testToken {
w.WriteHeader(http.StatusForbidden)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"data": map[string]any{"username": testAdmin, "password": "test-password"},
"metadata": map[string]any{"version": 1},
},
})
}))
defer server.Close()
config := bao.NewConfig()
config.Address = server.URL
config.MaxRetries = 0
client, err := bao.NewClient(config)
if err != nil {
t.Fatal(err)
}
source := NewCredentials(client, "secret", "kubernetes", "controller")
source.authenticate = func(context.Context) (*bao.Secret, error) {
logins.Add(1)
return &bao.Secret{Auth: &bao.SecretAuth{ClientToken: testToken, LeaseDuration: 300}}, nil
}
reference := api.OpenBaoSecretReference{Path: testAdmin}
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
if _, err := source.Read(context.Background(), reference); err != nil {
t.Error(err)
}
})
}
wg.Wait()
if logins.Load() != 1 {
t.Fatal("concurrent reads repeatedly authenticated")
}
source.expires = time.Time{}
if _, err := source.Read(context.Background(), reference); err != nil {
t.Fatal(err)
}
if logins.Load() != 2 {
t.Fatal("expired identity was not refreshed")
}
revoked.Store(true)
if _, err := source.Read(context.Background(), reference); err != nil {
t.Fatal(err)
}
if logins.Load() != 3 {
t.Fatal("revoked identity was not refreshed")
}
}
func TestDeniedPolicyRetriesAuthenticationOnlyOnce(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer server.Close()
config := bao.NewConfig()
config.Address = server.URL
config.MaxRetries = 0
client, err := bao.NewClient(config)
if err != nil {
t.Fatal(err)
}
source := NewCredentials(client, "secret", "kubernetes", "controller")
logins := 0
source.authenticate = func(context.Context) (*bao.Secret, error) {
logins++
return &bao.Secret{Auth: &bao.SecretAuth{ClientToken: testToken, LeaseDuration: 300}}, nil
}
if _, err := source.Read(context.Background(), api.OpenBaoSecretReference{Path: testAdmin}); err == nil {
t.Fatal("denied policy was accepted")
}
if logins != 2 {
t.Fatal("authentication did not stop after one retry")
}
}
-105
View File
@@ -1,105 +0,0 @@
/*
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 postgresql
import (
"context"
"crypto/x509"
"errors"
"net"
"net/url"
"strconv"
api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/postgresql/registry"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Connector is configured once with the deployment trust bundle.
type Connector struct{ CABundlePath string }
func (c Connector) Connect(ctx context.Context, endpoint api.PostgreSQLEndpoint, credentials instance.Credentials) (instance.Database, error) {
if endpoint.SSLMode != api.PostgreSQLSSLModeDisable && c.CABundlePath == "" {
return nil, instance.Failure{Reason: api.ReasonInvalidSpec, Operation: "PostgreSQL TLS requires a CA bundle"}
}
address := &url.URL{
Scheme: "postgresql",
Host: net.JoinHostPort(endpoint.Host, strconv.Itoa(int(endpoint.Port))),
Path: endpoint.Database,
}
query := address.Query()
query.Set("sslmode", string(endpoint.SSLMode))
if endpoint.SSLMode != api.PostgreSQLSSLModeDisable {
query.Set("sslrootcert", c.CABundlePath)
}
address.RawQuery = query.Encode()
config, err := pgxpool.ParseConfig(address.String())
if err != nil {
return nil, classify("configure PostgreSQL connection", err)
}
// Avoid embedding credentials in the driver's original connection string.
config.ConnConfig.User = credentials.Username
config.ConnConfig.Password = credentials.Password
config.MaxConns = 2
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, classify("create PostgreSQL pool", err)
}
// Pool construction is lazy. Version performs the first authenticated query.
return &Database{pool: pool}, nil
}
type Database struct{ pool *pgxpool.Pool }
func (d *Database) Version(ctx context.Context) (string, error) {
var version string
if err := d.pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
return "", classify("read PostgreSQL server version", err)
}
return version, nil
}
func (d *Database) EnsureRegistry(ctx context.Context) error {
if err := registry.NewStore(d.pool).Bootstrap(ctx); err != nil {
return classify("initialize PostgreSQL registry", err)
}
return nil
}
func (d *Database) Close() { d.pool.Close() }
func classify(operation string, err error) error {
reason := api.ReasonDependencyUnavailable
var pgError *pgconn.PgError
var unknownCA x509.UnknownAuthorityError
var hostname x509.HostnameError
var invalidCertificate x509.CertificateInvalidError
switch {
case errors.As(err, &pgError):
switch pgError.Code {
case "28P01", "28000":
reason = api.ReasonAuthenticationFailed
case "42501":
reason = api.ReasonInsufficientPrivileges
}
case errors.As(err, &unknownCA), errors.As(err, &hostname), errors.As(err, &invalidCertificate):
reason = api.ReasonAuthenticationFailed
}
return instance.Failure{Reason: reason, Operation: operation}
}
-53
View File
@@ -1,53 +0,0 @@
/*
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 postgresql
import (
"context"
"errors"
"fmt"
"strings"
"testing"
api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance"
"github.com/jackc/pgx/v5/pgconn"
)
const testCanary = "canary-secret"
func TestDatabaseFailuresDoNotLeakDriverMessages(t *testing.T) {
tests := []struct {
err error
reason string
}{
{context.DeadlineExceeded, api.ReasonDependencyUnavailable},
{&pgconn.PgError{Code: "28P01", Message: testCanary}, api.ReasonAuthenticationFailed},
{&pgconn.PgError{Code: "42501", Message: testCanary}, api.ReasonInsufficientPrivileges},
{&pgconn.PgError{Code: "53300", Message: testCanary}, api.ReasonDependencyUnavailable},
}
for _, tt := range tests {
err := classify("database operation", fmt.Errorf("driver canary-secret: %w", tt.err))
var failure instance.Failure
if !errors.As(err, &failure) || failure.Reason != tt.reason {
t.Fatal("wrong error category")
}
if strings.Contains(fmt.Sprintf("%+v %#v", err, err), "canary") {
t.Fatal("driver message escaped")
}
}
}