From 0101de3ffa8fdfcc0bba2d27787b63f8eba1c6d4 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Thu, 10 Sep 2026 18:03:05 +0000 Subject: [PATCH 1/8] docs: define controller connection flags --- docs/deployment.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 055af16..c4de169 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -25,19 +25,26 @@ ## Controller 配置合同 -具体 CLI flag/env 名称将在实现时按下表确定;语义和作用域已经固定: +controller 使用以下 CLI flags。必填项缺失、路径无效或 duration 不为正数时,进程必须 +在启动 manager 前失败;不得等到 reconcile 时才逐个资源报告配置错误。 -| 配置 | 必填/默认 | 说明 | +| CLI flag | 必填/默认 | 说明 | | --- | --- | --- | -| OpenBao internal API address | 必填 | controller 可访问的 HTTPS 地址 | -| OpenBao consumer API address | 默认同 internal | 写入 Tenant status,必须能被预期外部消费者解析 | -| OpenBao auth mount | 默认 `kubernetes` | Kubernetes auth mount 名称 | -| OpenBao auth role | 必填 | controller ServiceAccount 对应 role | -| OpenBao KV mount | 默认 `kv` | KV v2 mount;开发可显式用 `secret` | +| `--openbao-address` | 必填 | controller 可访问的 OpenBao API address | +| `--openbao-consumer-address` | 默认同 `--openbao-address` | 写入 Tenant status,必须能被预期外部消费者解析 | +| `--openbao-auth-mount` | `kubernetes` | Kubernetes auth mount 名称 | +| `--openbao-auth-role` | 必填 | controller ServiceAccount 对应 role | +| `--openbao-kv-mount` | `kv` | KV v2 mount;开发可显式用 `secret` | +| `--openbao-service-account-token-path` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | Kubernetes auth 使用的投射 token 文件 | | `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 | -| ESO ClusterSecretStore name | 必填 | controller 创建的 ExternalSecret 固定引用 | -| CA bundle path | 必填(TLS) | 只读 PEM trust bundle,不含私钥 | -| reconcile timeout | 有安全默认 | 单轮外部操作的总期限 | +| `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 | +| `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 | +| `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 | + +address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/` +在规范化后移除。mount、auth mount 和 base path 都使用 mount-relative path 语义,不以 +`/` 开头,不含空段、`.` 或 `..`;base path 还不得编码 KV v2 的 `data`/`metadata` +API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用于明确的开发 fixture。 Tenant 路径固定推导为 `//`。namespace/name 都已通过 Kubernetes 名称校验,因此不再允许 CR 提供任意路径。KV v2 API URL 使用 consumer -- 2.54.0 From 97f66669a64492d36931ce1b1764c80e84e11ba3 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Thu, 10 Sep 2026 18:15:50 +0000 Subject: [PATCH 2/8] feat: reconcile PostgreSQLInstance dependencies --- cmd/main.go | 35 ++- config/manager/manager.yaml | 3 + go.mod | 18 +- go.sum | 47 +++- .../postgresqlinstance_controller.go | 69 +++++- internal/controller/state_machine_test.go | 48 ++++ internal/controller/status.go | 14 ++ internal/instance/initializer.go | 216 ++++++++++++++++++ internal/instance/initializer_test.go | 44 ++++ 9 files changed, 484 insertions(+), 10 deletions(-) create mode 100644 internal/instance/initializer.go create mode 100644 internal/instance/initializer_test.go diff --git a/cmd/main.go b/cmd/main.go index 640795e..81416db 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,6 +20,7 @@ import ( "crypto/tls" "flag" "os" + "time" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -37,6 +38,7 @@ import ( databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/controller" + instanceinitializer "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance" // +kubebuilder:scaffold:imports ) @@ -61,6 +63,10 @@ func main() { var probeAddr string var secureMetrics bool var enableHTTP2 bool + var openBaoAddress, openBaoConsumerAddress, openBaoAuthMount, openBaoAuthRole string + var openBaoKVMount, openBaoTenantBasePath, openBaoServiceAccountTokenPath string + var externalSecretStoreName, postgreSQLCABundlePath string + var reconcileTimeout time.Duration var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -79,6 +85,20 @@ func main() { flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.StringVar(&openBaoAddress, "openbao-address", "", "OpenBao API address used by the controller.") + flag.StringVar(&openBaoConsumerAddress, "openbao-consumer-address", "", "OpenBao API address exposed to consumers.") + flag.StringVar(&openBaoAuthMount, "openbao-auth-mount", "kubernetes", "OpenBao Kubernetes auth mount.") + flag.StringVar(&openBaoAuthRole, "openbao-auth-role", "", "OpenBao Kubernetes auth role.") + flag.StringVar(&openBaoKVMount, "openbao-kv-mount", "kv", "OpenBao KV v2 mount.") + flag.StringVar(&openBaoServiceAccountTokenPath, "openbao-service-account-token-path", + "/var/run/secrets/kubernetes.io/serviceaccount/token", + "Projected service account token used for OpenBao authentication.") + flag.StringVar(&openBaoTenantBasePath, "openbao-tenant-base-path", "postgresql-tenants", + "Tenant credential base path.") + flag.StringVar(&externalSecretStoreName, "external-secret-store-name", "", "ESO ClusterSecretStore name.") + flag.StringVar(&postgreSQLCABundlePath, "postgresql-ca-bundle-path", "", "PostgreSQL CA bundle path.") + flag.DurationVar(&reconcileTimeout, "reconcile-timeout", 30*time.Second, + "Deadline for external operations in one reconcile.") opts := zap.Options{ Development: true, } @@ -87,6 +107,18 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + instanceInitializer, err := instanceinitializer.New(instanceinitializer.Config{ + OpenBaoAddress: openBaoAddress, OpenBaoConsumerAddress: openBaoConsumerAddress, + OpenBaoAuthMount: openBaoAuthMount, OpenBaoAuthRole: openBaoAuthRole, + ServiceAccountTokenPath: openBaoServiceAccountTokenPath, OpenBaoKVMount: openBaoKVMount, + OpenBaoTenantBasePath: openBaoTenantBasePath, ExternalSecretStoreName: externalSecretStoreName, + PostgreSQLCABundlePath: postgreSQLCABundlePath, Timeout: reconcileTimeout, + }) + if err != nil { + setupLog.Error(err, "Invalid controller dependency configuration") + os.Exit(1) + } + // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will // prevent from being vulnerable to the HTTP/2 Stream Cancellation and @@ -179,8 +211,7 @@ func main() { } if err := (&controller.PostgreSQLInstanceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Initializer: instanceInitializer, Timeout: reconcileTimeout, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "postgresqlinstance") os.Exit(1) diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 139304b..602c828 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -63,6 +63,9 @@ spec: args: - --leader-elect - --health-probe-bind-address=:8081 + - --openbao-address=https://openbao.openbao.svc:8200 + - --openbao-auth-role=postgresql-tenant-operator + - --external-secret-store-name=openbao image: controller:latest name: manager ports: diff --git a/go.mod b/go.mod index 9e7e1b6..4074732 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/jackc/tern/v2 v2.4.3 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 + github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0 + github.com/openbao/openbao/api/v2 v2.7.0 k8s.io/apimachinery v0.36.0 k8s.io/client-go v0.36.0 sigs.k8s.io/controller-runtime v0.24.1 @@ -29,6 +31,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect @@ -36,12 +39,21 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -51,6 +63,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect @@ -60,6 +73,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect @@ -82,13 +96,13 @@ require ( golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/mod v0.38.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.48.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index ceb715e..457d696 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8 github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -44,6 +46,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -61,6 +65,10 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -80,6 +88,25 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -115,10 +142,16 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -133,6 +166,10 @@ github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0 h1:Fw/pJRMpMTH83pMByCyikRHhxuBDYcnyiNSiK8OqJW0= +github.com/openbao/openbao/api/auth/kubernetes/v2 v2.7.0/go.mod h1:LkXPq4+8aLyQ+qoNBHcJF7nZFx0PYt2FOu+m7sdpAXU= +github.com/openbao/openbao/api/v2 v2.7.0 h1:3CD1l3tr39nQraCgFGAWA5vYvPFzZoZrt3NL7DMQKAc= +github.com/openbao/openbao/api/v2 v2.7.0/go.mod h1:uXbMoyH2pjSvNyTepinUvLde8pOJB82EuhUCfOKnKbo= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -149,6 +186,8 @@ github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05Zp github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -219,8 +258,8 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -231,8 +270,8 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= diff --git a/internal/controller/postgresqlinstance_controller.go b/internal/controller/postgresqlinstance_controller.go index c0f2ac0..aaccc23 100644 --- a/internal/controller/postgresqlinstance_controller.go +++ b/internal/controller/postgresqlinstance_controller.go @@ -18,7 +18,9 @@ package controller import ( "context" + "errors" "reflect" + "time" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -32,7 +34,15 @@ import ( // PostgreSQLInstanceReconciler reconciles a PostgreSQLInstance object type PostgreSQLInstanceReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + Initializer PostgreSQLInstanceInitializer + Timeout time.Duration +} + +// PostgreSQLInstanceInitializer is the external dependency boundary used by the Instance state machine. +type PostgreSQLInstanceInitializer interface { + Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) + InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) } // +kubebuilder:rbac:groups=database.ddupan.top,resources=postgresqlinstances,verbs=get;list;watch;create;update;patch;delete @@ -62,6 +72,12 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R setReconcilingCondition(&instance.Status.Conditions, instance.Generation, phaseResult.reconcilingMessage) } + var reconcileErr error + if r.Initializer != nil && instance.DeletionTimestamp.IsZero() && + before.Status.Phase != "" && before.Status.Phase != databasev1alpha1.PostgreSQLInstancePhasePending { + reconcileErr = r.reconcileDependencies(ctx, instance) + } + if !reflect.DeepEqual(before.Status, instance.Status) { if err := r.Status().Patch(ctx, instance, client.MergeFrom(before)); err != nil { if apierrors.IsConflict(err) { @@ -71,7 +87,56 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R } } - return ctrl.Result{}, nil + return ctrl.Result{}, reconcileErr +} + +func (r *PostgreSQLInstanceReconciler) reconcileDependencies( + ctx context.Context, + instance *databasev1alpha1.PostgreSQLInstance, +) error { + if r.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.Timeout) + defer cancel() + } + + if instance.Status.Phase == databasev1alpha1.PostgreSQLInstancePhaseReady && + instance.Status.ObservedGeneration != instance.Generation { + instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating + setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are being validated") + return nil + } + + var version string + var err error + switch instance.Status.Phase { + case databasev1alpha1.PostgreSQLInstancePhaseValidating: + version, err = r.Initializer.Validate(ctx, instance) + if err == nil { + instance.Status.PostgreSQLVersion = version + instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry + setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "PostgreSQL registry is being initialized") + } + case databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry: + version, err = r.Initializer.InitializeRegistry(ctx, instance) + if err == nil { + instance.Status.PostgreSQLVersion = version + instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseReady + instance.Status.ObservedGeneration = instance.Generation + setReadyCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are ready") + } + } + if err != nil { + instance.Status.ObservedGeneration = instance.Generation + reason := databasev1alpha1.ReasonDependencyUnavailable + var categorized interface{ ConditionReason() string } + if errors.As(err, &categorized) { + reason = categorized.ConditionReason() + } + setFailedCondition(&instance.Status.Conditions, instance.Generation, reason, + "instance dependency validation failed") + } + return err } // SetupWithManager sets up the controller with the Manager. diff --git a/internal/controller/state_machine_test.go b/internal/controller/state_machine_test.go index ba1c3a6..ca58648 100644 --- a/internal/controller/state_machine_test.go +++ b/internal/controller/state_machine_test.go @@ -17,6 +17,8 @@ limitations under the License. package controller import ( + "context" + "errors" "time" . "github.com/onsi/ginkgo/v2" @@ -26,7 +28,53 @@ import ( databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" ) +type fakeInstanceInitializer struct { + validateVersion string + registryVersion string + err error +} + +func (f fakeInstanceInitializer) Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) { + return f.validateVersion, f.err +} + +func (f fakeInstanceInitializer) InitializeRegistry(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) { + return f.registryVersion, f.err +} + var _ = Describe("phase handler state machines", func() { + It("advances an Instance through external validation and registry initialization", func() { + instance := &databasev1alpha1.PostgreSQLInstance{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating}, + } + reconciler := &PostgreSQLInstanceReconciler{Initializer: fakeInstanceInitializer{ + validateVersion: "17.6", registryVersion: "17.6", + }} + Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(Succeed()) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry)) + Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(Succeed()) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseReady)) + Expect(instance.Status.PostgreSQLVersion).To(Equal("17.6")) + Expect(instance.Status.ObservedGeneration).To(Equal(int64(3))) + Expect(instance.Status.Conditions).To(ConsistOf(And( + HaveField("Status", metav1.ConditionTrue), HaveField("Reason", databasev1alpha1.ReasonReady), + ))) + }) + + It("keeps the safe phase when a dependency is unavailable", func() { + instance := &databasev1alpha1.PostgreSQLInstance{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating}, + } + reconciler := &PostgreSQLInstanceReconciler{Initializer: fakeInstanceInitializer{err: errors.New("unavailable")}} + Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(MatchError("unavailable")) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) + Expect(instance.Status.Conditions).To(ConsistOf(And( + HaveField("Status", metav1.ConditionFalse), HaveField("Reason", databasev1alpha1.ReasonDependencyUnavailable), + ))) + }) + DescribeTable("dispatches Instance phases", func(instance *databasev1alpha1.PostgreSQLInstance, expected databasev1alpha1.PostgreSQLInstancePhase, hasMessage bool) { result := newInstanceStateMachine().reconcile(instance) diff --git a/internal/controller/status.go b/internal/controller/status.go index a2d6456..04ff9e4 100644 --- a/internal/controller/status.go +++ b/internal/controller/status.go @@ -32,3 +32,17 @@ func setReconcilingCondition(conditions *[]metav1.Condition, generation int64, m Message: message, }) } + +func setReadyCondition(conditions *[]metav1.Condition, generation int64, message string) { + apiMeta.SetStatusCondition(conditions, metav1.Condition{ + Type: databasev1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue, + ObservedGeneration: generation, Reason: databasev1alpha1.ReasonReady, Message: message, + }) +} + +func setFailedCondition(conditions *[]metav1.Condition, generation int64, reason, message string) { + apiMeta.SetStatusCondition(conditions, metav1.Condition{ + Type: databasev1alpha1.ConditionTypeReady, Status: metav1.ConditionFalse, + ObservedGeneration: generation, Reason: reason, Message: message, + }) +} diff --git a/internal/instance/initializer.go b/internal/instance/initializer.go new file mode 100644 index 0000000..076a285 --- /dev/null +++ b/internal/instance/initializer.go @@ -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 +} diff --git a/internal/instance/initializer_test.go b/internal/instance/initializer_test.go new file mode 100644 index 0000000..e09d6a6 --- /dev/null +++ b/internal/instance/initializer_test.go @@ -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") + } +} -- 2.54.0 From 212ca4be997373b7177d2a2071c3c2341dca89a5 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Thu, 10 Sep 2026 18:15:50 +0000 Subject: [PATCH 3/8] test: verify Instance readiness end to end --- docs/development.md | 7 +- test/e2e/e2e_test.go | 87 ++++++++++++++++- test/e2e/fixtures/instance-dependencies.yaml | 98 ++++++++++++++++++++ 3 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 test/e2e/fixtures/instance-dependencies.yaml diff --git a/docs/development.md b/docs/development.md index cd02f0b..2fb5eda 100644 --- a/docs/development.md +++ b/docs/development.md @@ -171,9 +171,10 @@ E2E fixture 必须把测试 PostgreSQL、OpenBao 和 ESO 部署进 Kind,并等 -> 删除 Kind ``` -当前 controller 只实现 PostgreSQL registry adapter,尚未实现完整 PostgreSQL/OpenBao -provisioning;现有 E2E 只能验证 CRD、manager Deployment 和 metrics endpoint。上述真实依赖 fixture 应与第一个完整 -reconcile 纵向切片一起实现,不能在文档中声称已经通过。 +当前 controller 已实现第一条 Instance Ready 纵向链路:E2E fixture 在 Kind 内启动 +PostgreSQL/OpenBao,配置 Kubernetes auth,验证管理凭据读取、PostgreSQL 登录、registry +migration 和 Instance Ready。Tenant provisioning、ESO、TLS DNS/IP SAN 与删除路径仍需 +后续纵向切片覆盖,不能从 Instance Ready 推断这些合同已经通过。 ## 测试数据与泄漏检查 diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index b0d359d..27a46e0 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -36,6 +36,8 @@ import ( // namespace where the project is deployed in const namespace = "postgresql-tenant-operator-system" +const dependencyNamespace = "postgresql-tenant-operator-e2e" + // serviceAccountName created for the project const serviceAccountName = "postgresql-tenant-operator-controller-manager" @@ -52,9 +54,19 @@ var _ = Describe("Manager", Ordered, func() { // enforce the restricted security policy to the namespace, installing CRDs, // and deploying the controller. BeforeAll(func() { - By("creating manager namespace") - cmd := exec.Command("kubectl", "create", "ns", namespace) + By("deploying disposable PostgreSQL and OpenBao dependencies") + cmd := exec.Command("kubectl", "apply", "-f", "test/e2e/fixtures/instance-dependencies.yaml") _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to deploy Instance dependencies") + cmd = exec.Command("kubectl", "wait", "deployment/postgres", "deployment/openbao", "--for=condition=Available", + "-n", dependencyNamespace, "--timeout=3m") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Instance dependencies did not become available") + configureOpenBao() + + By("creating manager namespace") + cmd = exec.Command("kubectl", "create", "ns", namespace) + _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") By("labeling the namespace to enforce the restricted security policy") @@ -72,6 +84,13 @@ var _ = Describe("Manager", Ordered, func() { cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", managerImage)) _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") + + By("configuring the controller for disposable dependencies") + argsPatch := `{"spec":{"template":{"spec":{"containers":[{"name":"manager","args":["--leader-elect","--health-probe-bind-address=:8081","--openbao-address=http://openbao.postgresql-tenant-operator-e2e.svc:8200","--openbao-auth-role=postgresql-tenant-operator","--openbao-kv-mount=secret","--external-secret-store-name=openbao"]}]}}}}` + cmd = exec.Command("kubectl", "patch", "deployment", "postgresql-tenant-operator-controller-manager", + "-n", namespace, "--type=strategic", "-p", argsPatch) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to configure controller dependency flags") }) // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs, @@ -92,6 +111,10 @@ var _ = Describe("Manager", Ordered, func() { By("removing manager namespace") cmd = exec.Command("kubectl", "delete", "ns", namespace) _, _ = utils.Run(cmd) + + By("removing disposable Instance dependencies") + cmd = exec.Command("kubectl", "delete", "-f", "test/e2e/fixtures/instance-dependencies.yaml", "--ignore-not-found") + _, _ = utils.Run(cmd) }) // After each test, check for failures and collect logs, events, @@ -268,6 +291,47 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed()) }) + It("should make an Instance Ready after validating OpenBao and initializing PostgreSQL", func() { + serviceIP, err := utils.Run(exec.Command("kubectl", "get", "service", "postgres", "-n", dependencyNamespace, + "-o", "jsonpath={.spec.clusterIP}")) + Expect(err).NotTo(HaveOccurred()) + manifest := fmt.Sprintf(`apiVersion: database.ddupan.top/v1alpha1 +kind: PostgreSQLInstance +metadata: + name: e2e +spec: + endpoint: + host: postgres.%s.svc + hostaddr: %s + port: 5432 + database: postgres + sslMode: disable + adminCredentialRef: + path: infrastructure/postgresql/admin +`, dependencyNamespace, serviceIP) + manifestPath := filepath.Join(GinkgoT().TempDir(), "instance.yaml") + Expect(os.WriteFile(manifestPath, []byte(manifest), 0o600)).To(Succeed()) + _, err = utils.Run(exec.Command("kubectl", "apply", "-f", manifestPath)) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func(g Gomega) { + phase, err := utils.Run(exec.Command("kubectl", "get", "postgresqlinstance", "e2e", + "-o", "jsonpath={.status.phase}")) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(phase).To(Equal("Ready")) + ready, err := utils.Run(exec.Command("kubectl", "get", "postgresqlinstance", "e2e", + "-o", `jsonpath={.status.conditions[?(@.type=="Ready")].status}`)) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ready).To(Equal("True")) + }).Should(Succeed()) + + output, err := utils.Run(exec.Command("kubectl", "exec", "deployment/postgres", "-n", dependencyNamespace, + "--", "psql", "-U", "postgres", "-d", "postgres", "-Atc", + "SELECT to_regclass('postgresql_tenant_operator.tenant_ownership') IS NOT NULL")) + Expect(err).NotTo(HaveOccurred()) + Expect(output).To(Equal("t\n")) + }) + // +kubebuilder:scaffold:e2e-webhooks-checks // TODO: Customize the e2e test suite with scenarios specific to your project. @@ -282,6 +346,25 @@ var _ = Describe("Manager", Ordered, func() { }) }) +func configureOpenBao() { + By("configuring OpenBao Kubernetes auth and the PostgreSQL administrative credential") + script := `set -eu +bao auth enable kubernetes +bao write auth/kubernetes/config kubernetes_host=https://kubernetes.default.svc \ + token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt +printf 'path "secret/data/infrastructure/postgresql/admin" { capabilities = ["read"] }\n' | bao policy write postgresql-tenant-operator - +bao write auth/kubernetes/role/postgresql-tenant-operator \ + bound_service_account_names=postgresql-tenant-operator-controller-manager \ + bound_service_account_namespaces=postgresql-tenant-operator-system \ + policies=postgresql-tenant-operator ttl=5m +bao kv put secret/infrastructure/postgresql/admin username=postgres password=postgres-e2e-only +` + cmd := exec.Command("kubectl", "exec", "deployment/openbao", "-n", dependencyNamespace, "--", "sh", "-ec", script) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to configure OpenBao") +} + // serviceAccountToken returns a token for the specified service account in the given namespace. // It uses the Kubernetes TokenRequest API to generate a token by directly sending a request // and parsing the resulting token from the API response. diff --git a/test/e2e/fixtures/instance-dependencies.yaml b/test/e2e/fixtures/instance-dependencies.yaml new file mode 100644 index 0000000..88a2fad --- /dev/null +++ b/test/e2e/fixtures/instance-dependencies.yaml @@ -0,0 +1,98 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: postgresql-tenant-operator-e2e +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: openbao + namespace: postgresql-tenant-operator-e2e +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: postgresql-tenant-operator-e2e-openbao-token-reviewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: openbao + namespace: postgresql-tenant-operator-e2e +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: postgresql-tenant-operator-e2e +spec: + replicas: 1 + selector: + matchLabels: {app: postgres} + template: + metadata: + labels: {app: postgres} + spec: + containers: + - name: postgres + image: postgres:17-alpine + env: + - {name: POSTGRES_USER, value: postgres} + - {name: POSTGRES_PASSWORD, value: postgres-e2e-only} + ports: + - {name: postgresql, containerPort: 5432} + readinessProbe: + exec: {command: [pg_isready, -U, postgres]} + initialDelaySeconds: 2 + periodSeconds: 2 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: postgresql-tenant-operator-e2e +spec: + selector: {app: postgres} + ports: + - {name: postgresql, port: 5432, targetPort: postgresql} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openbao + namespace: postgresql-tenant-operator-e2e +spec: + replicas: 1 + selector: + matchLabels: {app: openbao} + template: + metadata: + labels: {app: openbao} + spec: + serviceAccountName: openbao + containers: + - name: openbao + image: openbao/openbao:2.6.1 + args: [server, -dev] + env: + - {name: BAO_DEV_LISTEN_ADDRESS, value: "0.0.0.0:8200"} + - {name: BAO_DEV_ROOT_TOKEN_ID, value: dev-only-root-token} + - {name: BAO_ADDR, value: "http://127.0.0.1:8200"} + ports: + - {name: http, containerPort: 8200} + readinessProbe: + exec: {command: [bao, status]} + initialDelaySeconds: 2 + periodSeconds: 2 +--- +apiVersion: v1 +kind: Service +metadata: + name: openbao + namespace: postgresql-tenant-operator-e2e +spec: + selector: {app: openbao} + ports: + - {name: http, port: 8200, targetPort: http} -- 2.54.0 From 89d9e2316bfbfc3761b5322554150484f6436b3e Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Fri, 11 Sep 2026 15:04:15 +0000 Subject: [PATCH 4/8] refactor: consolidate Instance phase handlers --- .../postgresqlinstance_controller.go | 67 +--------- .../postgresqlinstance_state_machine.go | 117 +++++++++++++----- internal/controller/state_machine_test.go | 83 +++++++++++-- 3 files changed, 164 insertions(+), 103 deletions(-) diff --git a/internal/controller/postgresqlinstance_controller.go b/internal/controller/postgresqlinstance_controller.go index aaccc23..7c4dd9f 100644 --- a/internal/controller/postgresqlinstance_controller.go +++ b/internal/controller/postgresqlinstance_controller.go @@ -18,7 +18,6 @@ package controller import ( "context" - "errors" "reflect" "time" @@ -66,17 +65,12 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R } before := instance.DeepCopy() - phaseResult := newInstanceStateMachine().reconcile(instance) - instance.Status.Phase = phaseResult.phase - if phaseResult.reconcilingMessage != "" { - setReconcilingCondition(&instance.Status.Conditions, instance.Generation, phaseResult.reconcilingMessage) - } - - var reconcileErr error - if r.Initializer != nil && instance.DeletionTimestamp.IsZero() && - before.Status.Phase != "" && before.Status.Phase != databasev1alpha1.PostgreSQLInstancePhasePending { - reconcileErr = r.reconcileDependencies(ctx, instance) + if r.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.Timeout) + defer cancel() } + result, reconcileErr := newInstanceStateMachine(r.Initializer).reconcile(ctx, instance) if !reflect.DeepEqual(before.Status, instance.Status) { if err := r.Status().Patch(ctx, instance, client.MergeFrom(before)); err != nil { @@ -87,56 +81,7 @@ func (r *PostgreSQLInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R } } - return ctrl.Result{}, reconcileErr -} - -func (r *PostgreSQLInstanceReconciler) reconcileDependencies( - ctx context.Context, - instance *databasev1alpha1.PostgreSQLInstance, -) error { - if r.Timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, r.Timeout) - defer cancel() - } - - if instance.Status.Phase == databasev1alpha1.PostgreSQLInstancePhaseReady && - instance.Status.ObservedGeneration != instance.Generation { - instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating - setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are being validated") - return nil - } - - var version string - var err error - switch instance.Status.Phase { - case databasev1alpha1.PostgreSQLInstancePhaseValidating: - version, err = r.Initializer.Validate(ctx, instance) - if err == nil { - instance.Status.PostgreSQLVersion = version - instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry - setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "PostgreSQL registry is being initialized") - } - case databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry: - version, err = r.Initializer.InitializeRegistry(ctx, instance) - if err == nil { - instance.Status.PostgreSQLVersion = version - instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseReady - instance.Status.ObservedGeneration = instance.Generation - setReadyCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are ready") - } - } - if err != nil { - instance.Status.ObservedGeneration = instance.Generation - reason := databasev1alpha1.ReasonDependencyUnavailable - var categorized interface{ ConditionReason() string } - if errors.As(err, &categorized) { - reason = categorized.ConditionReason() - } - setFailedCondition(&instance.Status.Conditions, instance.Generation, reason, - "instance dependency validation failed") - } - return err + return result, reconcileErr } // SetupWithManager sets up the controller with the Manager. diff --git a/internal/controller/postgresqlinstance_state_machine.go b/internal/controller/postgresqlinstance_state_machine.go index ddad1c1..4e08995 100644 --- a/internal/controller/postgresqlinstance_state_machine.go +++ b/internal/controller/postgresqlinstance_state_machine.go @@ -16,58 +16,115 @@ limitations under the License. package controller -import databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" +import ( + "context" + "errors" + "time" -type instancePhaseResult struct { - phase databasev1alpha1.PostgreSQLInstancePhase - reconcilingMessage string -} + databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" + ctrl "sigs.k8s.io/controller-runtime" +) -type instancePhaseHandler func(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult +type instancePhaseHandler func(context.Context, *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) type instanceStateMachine struct { - handlers map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler + initializer PostgreSQLInstanceInitializer + handlers map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler } -func newInstanceStateMachine() instanceStateMachine { - return instanceStateMachine{handlers: map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler{ - databasev1alpha1.PostgreSQLInstancePhasePending: reconcileInstancePending, - databasev1alpha1.PostgreSQLInstancePhaseValidating: keepInstancePhase, - databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry: keepInstancePhase, - databasev1alpha1.PostgreSQLInstancePhaseReady: keepInstancePhase, - databasev1alpha1.PostgreSQLInstancePhaseDeleting: reconcileInstanceDeleting, - }} +func newInstanceStateMachine(initializer PostgreSQLInstanceInitializer) *instanceStateMachine { + m := &instanceStateMachine{initializer: initializer} + m.handlers = map[databasev1alpha1.PostgreSQLInstancePhase]instancePhaseHandler{ + databasev1alpha1.PostgreSQLInstancePhasePending: m.pending, + databasev1alpha1.PostgreSQLInstancePhaseValidating: m.validate, + databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry: m.initializeRegistry, + databasev1alpha1.PostgreSQLInstancePhaseReady: m.ready, + databasev1alpha1.PostgreSQLInstancePhaseDeleting: m.deleting, + } + return m } -func (m instanceStateMachine) reconcile(instance *databasev1alpha1.PostgreSQLInstance) instancePhaseResult { +func (m *instanceStateMachine) reconcile(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { phase := instance.Status.Phase if !instance.DeletionTimestamp.IsZero() { phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting } else if phase == "" { phase = databasev1alpha1.PostgreSQLInstancePhasePending } - handler, found := m.handlers[phase] if !found { - return instancePhaseResult{phase: phase} + handler = m.pending } - return handler(instance) + result, err := handler(ctx, instance) + if err != nil { + instance.Status.ObservedGeneration = instance.Generation + reason := databasev1alpha1.ReasonDependencyUnavailable + var categorized interface{ ConditionReason() string } + if errors.As(err, &categorized) { + reason = categorized.ConditionReason() + } + setFailedCondition(&instance.Status.Conditions, instance.Generation, reason, "instance dependency validation failed") + } + return result, err } -func reconcileInstancePending(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult { - return instancePhaseResult{ - phase: databasev1alpha1.PostgreSQLInstancePhaseValidating, - reconcilingMessage: "instance dependencies are being validated", - } +func (m *instanceStateMachine) pending(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { + return advanceInstance(instance, databasev1alpha1.PostgreSQLInstancePhaseValidating, + "instance dependencies are being validated"), nil } -func reconcileInstanceDeleting(*databasev1alpha1.PostgreSQLInstance) instancePhaseResult { - return instancePhaseResult{ - phase: databasev1alpha1.PostgreSQLInstancePhaseDeleting, - reconcilingMessage: "instance deletion is reconciling", +func (m *instanceStateMachine) validate(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { + if m.initializer == nil { + return ctrl.Result{}, nil } + version, err := m.initializer.Validate(ctx, instance) + if err != nil { + return ctrl.Result{}, err + } + instance.Status.PostgreSQLVersion = version + return advanceInstance(instance, databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry, + "PostgreSQL registry is being initialized"), nil } -func keepInstancePhase(instance *databasev1alpha1.PostgreSQLInstance) instancePhaseResult { - return instancePhaseResult{phase: instance.Status.Phase} +func (m *instanceStateMachine) initializeRegistry(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { + if m.initializer == nil { + return ctrl.Result{}, nil + } + version, err := m.initializer.InitializeRegistry(ctx, instance) + if err != nil { + return ctrl.Result{}, err + } + instance.Status.PostgreSQLVersion = version + instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseReady + instance.Status.ObservedGeneration = instance.Generation + setReadyCondition(&instance.Status.Conditions, instance.Generation, "instance dependencies are ready") + return ctrl.Result{RequeueAfter: time.Minute}, nil +} + +func (m *instanceStateMachine) ready(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { + if instance.Status.ObservedGeneration != instance.Generation { + return m.pending(ctx, instance) + } + if m.initializer == nil { + return ctrl.Result{}, nil + } + version, err := m.initializer.Validate(ctx, instance) + if err != nil { + instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseValidating + return ctrl.Result{}, err + } + instance.Status.PostgreSQLVersion = version + return ctrl.Result{RequeueAfter: time.Minute}, nil +} + +func (m *instanceStateMachine) deleting(_ context.Context, instance *databasev1alpha1.PostgreSQLInstance) (ctrl.Result, error) { + instance.Status.Phase = databasev1alpha1.PostgreSQLInstancePhaseDeleting + setReconcilingCondition(&instance.Status.Conditions, instance.Generation, "instance deletion is reconciling") + return ctrl.Result{}, nil +} + +func advanceInstance(instance *databasev1alpha1.PostgreSQLInstance, phase databasev1alpha1.PostgreSQLInstancePhase, message string) ctrl.Result { + instance.Status.Phase = phase + setReconcilingCondition(&instance.Status.Conditions, instance.Generation, message) + return ctrl.Result{RequeueAfter: time.Millisecond} } diff --git a/internal/controller/state_machine_test.go b/internal/controller/state_machine_test.go index ca58648..4b54995 100644 --- a/internal/controller/state_machine_test.go +++ b/internal/controller/state_machine_test.go @@ -34,6 +34,8 @@ type fakeInstanceInitializer struct { err error } +const testPostgreSQLVersion = "17.6" + func (f fakeInstanceInitializer) Validate(context.Context, *databasev1alpha1.PostgreSQLInstance) (string, error) { return f.validateVersion, f.err } @@ -43,19 +45,74 @@ func (f fakeInstanceInitializer) InitializeRegistry(context.Context, *databasev1 } var _ = Describe("phase handler state machines", func() { + It("persists validation intent before touching unavailable dependencies", func() { + instance := &databasev1alpha1.PostgreSQLInstance{} + machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("must not be called")}) + result, err := machine.reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) + Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionUnknown)) + }) + + It("persists a new validation checkpoint when a Ready Instance generation changes", func() { + instance := &databasev1alpha1.PostgreSQLInstance{ + ObjectMeta: metav1.ObjectMeta{Generation: 4}, + Status: databasev1alpha1.PostgreSQLInstanceStatus{ + Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3, + }, + } + machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("must not be called")}) + _, err := machine.reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) + Expect(instance.Status.ObservedGeneration).To(Equal(int64(3))) + }) + + It("detects a dependency outage after Ready and recovers through registry initialization", func() { + instance := &databasev1alpha1.PostgreSQLInstance{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Status: databasev1alpha1.PostgreSQLInstanceStatus{ + Phase: databasev1alpha1.PostgreSQLInstancePhaseReady, ObservedGeneration: 3, + }, + } + machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("unavailable")}) + _, err := machine.reconcile(context.Background(), instance) + Expect(err).To(MatchError("unavailable")) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) + Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + machine.initializer = fakeInstanceInitializer{ + validateVersion: testPostgreSQLVersion, + registryVersion: testPostgreSQLVersion, + } + _, err = machine.reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry)) + _, err = machine.reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) + Expect(instance.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + result, err := machine.reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) + Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseReady)) + Expect(result.RequeueAfter).To(Equal(time.Minute)) + }) + It("advances an Instance through external validation and registry initialization", func() { instance := &databasev1alpha1.PostgreSQLInstance{ ObjectMeta: metav1.ObjectMeta{Generation: 3}, Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating}, } - reconciler := &PostgreSQLInstanceReconciler{Initializer: fakeInstanceInitializer{ - validateVersion: "17.6", registryVersion: "17.6", - }} - Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(Succeed()) + machine := newInstanceStateMachine(fakeInstanceInitializer{ + validateVersion: testPostgreSQLVersion, + registryVersion: testPostgreSQLVersion, + }) + _, err := machine.reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseInitializingRegistry)) - Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(Succeed()) + _, err = machine.reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseReady)) - Expect(instance.Status.PostgreSQLVersion).To(Equal("17.6")) + Expect(instance.Status.PostgreSQLVersion).To(Equal(testPostgreSQLVersion)) Expect(instance.Status.ObservedGeneration).To(Equal(int64(3))) Expect(instance.Status.Conditions).To(ConsistOf(And( HaveField("Status", metav1.ConditionTrue), HaveField("Reason", databasev1alpha1.ReasonReady), @@ -67,8 +124,9 @@ var _ = Describe("phase handler state machines", func() { ObjectMeta: metav1.ObjectMeta{Generation: 2}, Status: databasev1alpha1.PostgreSQLInstanceStatus{Phase: databasev1alpha1.PostgreSQLInstancePhaseValidating}, } - reconciler := &PostgreSQLInstanceReconciler{Initializer: fakeInstanceInitializer{err: errors.New("unavailable")}} - Expect(reconciler.reconcileDependencies(context.Background(), instance)).To(MatchError("unavailable")) + machine := newInstanceStateMachine(fakeInstanceInitializer{err: errors.New("unavailable")}) + _, err := machine.reconcile(context.Background(), instance) + Expect(err).To(MatchError("unavailable")) Expect(instance.Status.Phase).To(Equal(databasev1alpha1.PostgreSQLInstancePhaseValidating)) Expect(instance.Status.Conditions).To(ConsistOf(And( HaveField("Status", metav1.ConditionFalse), HaveField("Reason", databasev1alpha1.ReasonDependencyUnavailable), @@ -77,12 +135,13 @@ var _ = Describe("phase handler state machines", func() { DescribeTable("dispatches Instance phases", func(instance *databasev1alpha1.PostgreSQLInstance, expected databasev1alpha1.PostgreSQLInstancePhase, hasMessage bool) { - result := newInstanceStateMachine().reconcile(instance) - Expect(result.phase).To(Equal(expected)) + _, err := newInstanceStateMachine(nil).reconcile(context.Background(), instance) + Expect(err).NotTo(HaveOccurred()) + Expect(instance.Status.Phase).To(Equal(expected)) if hasMessage { - Expect(result.reconcilingMessage).NotTo(BeEmpty()) + Expect(instance.Status.Conditions).NotTo(BeEmpty()) } else { - Expect(result.reconcilingMessage).To(BeEmpty()) + Expect(instance.Status.Conditions).To(BeEmpty()) } }, Entry("starts validation from an empty checkpoint", -- 2.54.0 From 59303f527693ee2572cf2d96e2bd85ab30d67989 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Fri, 11 Sep 2026 16:10:16 +0000 Subject: [PATCH 5/8] refactor: introduce Instance connection lifecycle service --- internal/instance/errors.go | 27 ++++++ internal/instance/service.go | 152 +++++++++++++++++++++++++++++ internal/instance/service_test.go | 156 ++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 internal/instance/errors.go create mode 100644 internal/instance/service.go create mode 100644 internal/instance/service_test.go diff --git a/internal/instance/errors.go b/internal/instance/errors.go new file mode 100644 index 0000000..8a82d25 --- /dev/null +++ b/internal/instance/errors.go @@ -0,0 +1,27 @@ +/* +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 + +// Failure contains only a safe operation message and a Condition reason. +// Raw driver errors can contain credentials and must not escape through wrapping. +type Failure struct { + Reason string + Operation string +} + +func (e Failure) Error() string { return e.Operation } +func (e Failure) ConditionReason() string { return e.Reason } diff --git a/internal/instance/service.go b/internal/instance/service.go new file mode 100644 index 0000000..ba435a7 --- /dev/null +++ b/internal/instance/service.go @@ -0,0 +1,152 @@ +/* +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 manages the lifetime and capabilities of external PostgreSQL instances. +package instance + +import ( + "context" + "errors" + "sync" + + api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/types" +) + +// Credentials are supplied by the credential source, never by the database adapter. +type Credentials struct{ Username, Password string } + +func (Credentials) String() string { return "[redacted credentials]" } +func (Credentials) GoString() string { return "[redacted credentials]" } + +// CredentialSource resolves administrative credentials when an Instance is assembled. +type CredentialSource interface { + Read(context.Context, api.OpenBaoSecretReference) (Credentials, error) +} + +// Database exposes the capabilities of an assembled Instance. +type Database interface { + Version(context.Context) (string, error) + EnsureRegistry(context.Context) error + Close() +} + +// Connector creates a database without knowing where credentials came from. +type Connector interface { + Connect(context.Context, api.PostgreSQLEndpoint, Credentials) (Database, error) +} + +type identity struct { + uid types.UID + endpoint api.PostgreSQLEndpoint + reference api.OpenBaoSecretReference +} + +type entry struct { + identity identity + credentials *Credentials + database Database +} + +// Service owns Instance connections. Reconciles reuse credentials and pools until +// the UID, endpoint or credential reference changes; allowlist changes do not rotate them. +// Operations are serialized to prevent Close racing with an active database operation. +type Service struct { + mu sync.Mutex + source CredentialSource + connector Connector + entries map[string]*entry + closed bool +} + +func NewService(source CredentialSource, connector Connector) *Service { + return &Service{source: source, connector: connector, entries: make(map[string]*entry)} +} + +func (s *Service) Validate(ctx context.Context, resource *api.PostgreSQLInstance) (string, error) { + return s.observe(ctx, resource, false) +} + +func (s *Service) InitializeRegistry(ctx context.Context, resource *api.PostgreSQLInstance) (string, error) { + return s.observe(ctx, resource, true) +} + +func (s *Service) observe(ctx context.Context, resource *api.PostgreSQLInstance, ensureRegistry bool) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return "", errors.New("instance service is closed") + } + db, err := s.database(ctx, resource) + if err != nil { + return "", err + } + if ensureRegistry { + if err := db.EnsureRegistry(ctx); err != nil { + return "", err + } + } + return db.Version(ctx) +} + +func (s *Service) database(ctx context.Context, resource *api.PostgreSQLInstance) (Database, error) { + key := identity{uid: resource.UID, endpoint: resource.Spec.Endpoint, reference: resource.Spec.AdminCredentialRef} + current := s.entries[resource.Name] + if current == nil || current.identity != key { + s.release(resource.Name) + current = &entry{identity: key} + s.entries[resource.Name] = current + } + if current.credentials == nil { + credentials, err := s.source.Read(ctx, key.reference) + if err != nil { + return nil, err + } + current.credentials = &credentials + } + if current.database == nil { + database, err := s.connector.Connect(ctx, key.endpoint, *current.credentials) + if err != nil { + return nil, err + } + current.database = database + } + return current.database, nil +} + +// Forget releases local resources only. It never deletes the external registry. +func (s *Service) Forget(name string) { + s.mu.Lock() + defer s.mu.Unlock() + s.release(name) +} + +func (s *Service) release(name string) { + if current := s.entries[name]; current != nil && current.database != nil { + current.database.Close() + } + delete(s.entries, name) +} + +// Close is called after manager workers stop, and is safe to repeat. +func (s *Service) Close() { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + for name := range s.entries { + s.release(name) + } +} diff --git a/internal/instance/service_test.go b/internal/instance/service_test.go new file mode 100644 index 0000000..022e10c --- /dev/null +++ b/internal/instance/service_test.go @@ -0,0 +1,156 @@ +/* +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 ( + "context" + "errors" + "fmt" + "strings" + "testing" + + api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type sourceStub struct { + reads int + err error +} + +func (s *sourceStub) Read(context.Context, api.OpenBaoSecretReference) (Credentials, error) { + s.reads++ + return Credentials{Username: "admin", Password: "canary-secret"}, s.err +} + +type databaseStub struct { + closes, migrations int + err error +} + +func (d *databaseStub) Version(context.Context) (string, error) { return "17", d.err } +func (d *databaseStub) EnsureRegistry(context.Context) error { d.migrations++; return d.err } +func (d *databaseStub) Close() { d.closes++ } + +type connectorStub struct { + databases []*databaseStub + err error +} + +func (c *connectorStub) Connect(context.Context, api.PostgreSQLEndpoint, Credentials) (Database, error) { + if c.err != nil { + return nil, c.err + } + database := &databaseStub{} + c.databases = append(c.databases, database) + return database, nil +} + +func TestInstanceConnectionLifetime(t *testing.T) { + source, connector := &sourceStub{}, &connectorStub{} + service := NewService(source, connector) + defer service.Close() + resource := &api.PostgreSQLInstance{ObjectMeta: metav1.ObjectMeta{Name: "shared", UID: "uid-1"}} + ctx := context.Background() + check := func() { + t.Helper() + if _, err := service.Validate(ctx, resource); err != nil { + t.Fatalf("validate: %v", err) + } + } + check() + if _, err := service.InitializeRegistry(ctx, resource); err != nil { + t.Fatal(err) + } + resource.Generation++ + resource.Spec.AllowedExtensions = []string{"pg_trgm"} + check() + if source.reads != 1 || len(connector.databases) != 1 || connector.databases[0].migrations != 1 { + t.Fatal("reconciliation or allowlist update unnecessarily reassembled dependencies") + } + connector.databases[0].err = errors.New("network outage") + if _, err := service.Validate(ctx, resource); err == nil { + t.Fatal("outage went unnoticed") + } + connector.databases[0].err = nil + check() + if source.reads != 1 { + t.Fatal("network recovery reread the password") + } + + changes := []func(){ + func() { resource.Spec.Endpoint.Host = "new.example" }, + func() { resource.Spec.AdminCredentialRef.Path = "new/admin" }, + func() { resource.UID = "uid-2" }, + } + for index, change := range changes { + change() + check() + if connector.databases[index].closes != 1 { + t.Fatal("replaced pool was not closed") + } + } + if source.reads != 4 { + t.Fatal("changed connection identity did not reload credentials") + } + service.Forget(resource.Name) + service.Forget(resource.Name) + if connector.databases[3].closes != 1 { + t.Fatal("deletion did not close pool exactly once") + } + check() + service.Close() + service.Close() + if connector.databases[4].closes != 1 { + t.Fatal("shutdown did not close pool exactly once") + } + if _, err := service.Validate(ctx, resource); err == nil { + t.Fatal("closed service accepted work") + } +} + +func TestAssemblyRetriesWithoutPasswordRotation(t *testing.T) { + source := &sourceStub{err: errors.New("bao unavailable")} + connector := &connectorStub{err: errors.New("connection unavailable")} + service := NewService(source, connector) + defer service.Close() + resource := &api.PostgreSQLInstance{ObjectMeta: metav1.ObjectMeta{Name: "shared"}} + ctx := context.Background() + if _, err := service.Validate(ctx, resource); err == nil { + t.Fatal("expected source failure") + } + source.err = nil + if _, err := service.Validate(ctx, resource); err == nil { + t.Fatal("expected connection failure") + } + connector.err = nil + if _, err := service.Validate(ctx, resource); err != nil { + t.Fatal(err) + } + if source.reads != 2 { + t.Fatal("retry reread successfully cached credentials") + } +} + +func TestCredentialsFormattingIsRedacted(t *testing.T) { + value := Credentials{Username: "canary-user", Password: "canary-secret"} + for _, format := range []string{"%v", "%+v", "%#v"} { + if strings.Contains(fmt.Sprintf(format, value), "canary") { + t.Fatal("credential formatting leaked data") + } + } +} -- 2.54.0 From 953878ac47d97868110f730b3bf28f67d3bae5fc Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 13 Sep 2026 12:17:03 +0000 Subject: [PATCH 6/8] docs: define Instance domain model and admin Secret boundary --- docs/api-reference.md | 17 ++-- docs/architecture.md | 6 +- docs/deployment.md | 21 +++-- docs/domain-instance.md | 198 ++++++++++++++++++++++++++++++++++++++++ docs/domain-model.md | 145 +++++++++++++++++++++++++++++ docs/security.md | 9 +- docs/specification.md | 30 ++++-- 7 files changed, 399 insertions(+), 27 deletions(-) create mode 100644 docs/domain-instance.md create mode 100644 docs/domain-model.md diff --git a/docs/api-reference.md b/docs/api-reference.md index 261b0c3..7112082 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -37,13 +37,15 @@ cluster-scoped,short name 为 `pginstance`。 | `spec.endpoint.port` | int32 | `5432` | 1–65535 | | `spec.endpoint.database` | string | `postgres` | 管理连接 database;合法 PostgreSQL identifier | | `spec.endpoint.sslMode` | enum | `verify-full` | `disable`、`require`、`verify-ca`、`verify-full` | -| `spec.adminCredentialRef.path` | string | 必填 | 部署级 KV mount 内的 mount-relative path | -| `spec.adminCredentialRef.usernameKey` | string | `username` | OpenBao record 中的键名 | -| `spec.adminCredentialRef.passwordKey` | string | `password` | OpenBao record 中的键名 | +| `spec.adminCredentialRef.name` | string | 必填 | controller namespace 内的管理 Secret 名称 | +| `spec.adminCredentialRef.usernameKey` | string | `username` | Secret data 中的键名 | +| `spec.adminCredentialRef.passwordKey` | string | `password` | Secret data 中的键名 | | `spec.allowedExtensions` | set[string] | 空集合 | 合法 extension 名称的 allowlist | -`adminCredentialRef.path` 不以 `/` 开头,不含空段、`.`、`..`,也不包含 KV v2 API 的 -`data`/`metadata` 层。它只定位既有管理凭据;controller 不创建或修改该记录。 +`adminCredentialRef` 不接受 namespace 或 Bao path。管理 Secret 固定在 controller +namespace,名称须合法,两个字段须存在且非空。管理员维护 ExternalSecret,由 ESO +同步;controller 只读管理 Secret,不创建或修改它。此为 2026-09-13 批准的修订, +现有 API types、生成 CRD 和 samples 尚未更新。 Instance endpoint、管理凭据引用和 allowlist 可以修改。修改后 controller 重新验证; 删除 allowlist 项目不会自动从已有 Tenant database 删除 extension。 @@ -62,6 +64,9 @@ print columns:`Endpoint=.spec.endpoint.host`、`Phase`、`Ready`、`Age`。 Instance `Ready=True` 要求管理凭据可读、TLS/认证成功、server metadata 可读、registry 可访问且权限预检成功。它不代表数据库已经备份或高可用。 +管理凭据从 Kubernetes Secret 装配;已有有效凭据可访问 PostgreSQL 时,Bao/ESO +暂时不可用不单独撤销 Instance Ready。Tenant 凭据操作仍依赖 Bao。 + ## PostgreSQLTenant namespaced,short name 为 `pgtenant`。 @@ -160,7 +165,7 @@ spec: database: postgres sslMode: verify-full adminCredentialRef: - path: infrastructure/postgresql/shared/admin + name: shared-postgresql-admin allowedExtensions: [pg_trgm] --- apiVersion: database.ddupan.top/v1alpha1 diff --git a/docs/architecture.md b/docs/architecture.md index 382e1b7..c6b816b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,9 +35,13 @@ controller 不运行 PostgreSQL/OpenBao,不管理 VM、存储、备份或 Open ## 资源模型 `PostgreSQLInstance` 是 cluster-scoped,由平台管理员创建,描述外部 PostgreSQL 的 -DNS host、IP host address、端口、管理 database、TLS 模式、OpenBao 管理凭据引用和 +DNS host、IP host address、端口、管理 database、TLS 模式、管理 Secret 引用和 extension allowlist。 +管理连接使用管理员维护的 ExternalSecret 经 ESO 同步到 controller namespace 的 +Secret;Instance 只选择 Secret 名称与字段,controller 只读,不直接从 Bao 获取 +管理凭据。Tenant 凭据的创建、读取与销毁仍由 controller 直接访问 Bao。 + `PostgreSQLTenant` 是 namespaced。一个 Tenant 对应一个 database、一个同时作为 owner 的 login role、一组只允许追加的 extension、一个由 controller 推导的 OpenBao KV 记录,以及同 namespace 的 ExternalSecret 和目标 Secret。 diff --git a/docs/deployment.md b/docs/deployment.md index c4de169..4c7605d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -17,7 +17,9 @@ 3. 创建 PostgreSQL controller 管理 role 和管理 database 连接权限。 4. 在 OpenBao KV v2 写入管理 role 凭据。 5. 配置 OpenBao Kubernetes auth、controller policy 和面向 ESO 的读取 policy。 -6. 在 Kubernetes 安装 ESO,创建可读取租户路径的 `ClusterSecretStore`。 +6. 安装 ESO,配置独立的管理凭据同步身份和租户凭据读取身份。管理员在 controller + namespace 创建管理 ExternalSecret,确认管理 Secret 已同步;另创建供租户使用的 + `ClusterSecretStore`。 7. 创建公开 CA bundle ConfigMap,并挂载到 controller 和需要直接验证数据库的应用。 8. 部署 controller,再创建 Instance;等待 Ready 后才创建 Tenant。 @@ -78,13 +80,12 @@ controller 权限。最终可执行 SQL grant 将随 PostgreSQL adapter 集成 ## OpenBao 与 ESO -controller policy 分成两个范围: +controller policy 仅允许在固定 tenant base path 下 create/read/update/delete KV v2 +data 和 metadata,Delete 必须能永久删除全部版本及 metadata;不读取管理凭据路径。 -- 只读 Instance 管理凭据路径; -- 在固定 tenant base path 下 create/read/update/delete KV v2 data 和 metadata,Delete - 必须能永久删除全部版本及 metadata。 - -ESO 使用独立身份,只需读取 tenant base path;它不应读取 PostgreSQL 管理凭据。 +管理凭据由管理员维护的 ExternalSecret 同步到 controller namespace;其 ESO 身份 +只读对应管理路径,不能供 Tenant 使用。租户 ESO 身份只读 tenant base path,不得 +读取 PostgreSQL 管理凭据。controller 不创建或修改管理 ExternalSecret/Secret。 `ClusterSecretStore` 由平台管理员创建,controller 只引用,不创建或修改 Store。 controller 创建的 ExternalSecret 与 Tenant 同 namespace,并设置 ownerReference;目标 Secret 包含固定七键:`username`、`password`、`database`、`host`、`hostaddr`、`port`、 @@ -97,8 +98,10 @@ Secret 包含固定七键:`username`、`password`、`database`、`host`、`hos 对应 Secret 是否完成投射。 - namespace 用户可以管理本 namespace Tenant,但不能管理 Instance、Store、controller 配置或其他 namespace 的 ExternalSecret。 -- controller 无需读取目标 Secret 的 data;验证登录使用从 OpenBao 读取的应用凭据, - 对 Secret 只检查存在性和 ESO 状态。 +- controller 只在自身 namespace 读取所引用管理 Secret 的 data,不获得跨 namespace + 的管理 Secret 读取权限。Instance 不允许自选 Secret namespace。 +- 对应用目标 Secret,controller 无需读取 data;验证登录使用从 OpenBao 读取的应用 + 凭据,只检查 Secret 存在性和 ESO 状态。 ## 升级与回滚 diff --git a/docs/domain-instance.md b/docs/domain-instance.md new file mode 100644 index 0000000..1471088 --- /dev/null +++ b/docs/domain-instance.md @@ -0,0 +1,198 @@ +# Instance 领域对象规格 + +状态:Draft,待批准。日期:2026-09-12。 + +上层边界见 [领域模型](domain-model.md)。本文只展开 Instance,不包含 Tenant 的供应 +实现,也不新增 CRD 字段。设计签名用于评审职责与行为,不是待复制的 Go 接口代码。 + +## 1. 对象职责与生命周期 + +Instance 表示一次登记的 PostgreSQL 管理对象,是聚合根。它负责字段与策略校验、 +根据观察结果判断能力是否满足要求、保护状态转换规则;不登录数据库,不读取 Bao, +不查询权限或初始化 registry。 + +本草案选择:**领域对象只接收数据并做业务决策,不直接或通过端口、回调访问外部。** +应用层调用适配器获取事实、执行被允许的操作,并将观察结果交回对象。Instance 不接收 +context、客户端或 IO 接口。领域行为不是公共 SetReady:调用方提供事实,不能指定结论。 + +每轮从 CR 重建一个 Instance;对象不跨 reconcile 缓存,也不是线程共享单例。 +管理连接可由装配层跨轮次复用,但连接复用不代表上次能力验证仍然成立。 + +## 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,不存明文 | +| definition.allowedExtensions | 去重的 ExtensionName 集合 | CR spec | 本轮不可变;不因 allowlist 缩小卸载扩展 | +| 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 留在应用层作为乐观并发保存的前提。 + +## 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 | ExtensionsDenied +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 | 一组规范化 extension 名称 | 检查请求是否为当前 allowlist 子集,返回不允许的名称;无 IO、无状态修改 | ExtensionsDenied;不卸载已存在 extension | +| 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,必须回读。 + +适配器由装配层绑定管理连接;凭据源、连接池释放及 Bao token 重新认证留在该边界 +之后。适配器不得自行把基础设施异常转换成 Ready。返回的失败至少区分依赖不可用、 +认证失败、权限不足和 registry 不兼容;不兼容属于不可安全继续,不自动覆写。 +registry 不兼容的具体 Condition 映射须在接口规格中确定,不能统一误报权限不足。 + +管理连接由应用层从 controller namespace 的 Secret 装配;管理员维护 ExternalSecret, +ESO 负责同步。Instance 路径不直接访问 Bao,也不以 Bao/ESO 当前可用性作为就绪条件。 +首次装配缺少有效 Secret 时失败;已有凭据可正常访问 PG 时继续按 PG 能力判定。 +Secret 更新后的连接刷新协议仍需细化,不在领域对象中实现监听或密码轮换。 + +## 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。Tenant 引用检查及 Instance 删除竞争协议仍需另行批准。 +- CheckExtensions 失败不能产生任何外部写入;修改 allowlist 不会自行卸载扩展。 +- Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。 +- 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入 + 得到相同决策。缺少检查项、目标不匹配和旧配置结果不得产生 Ready。 + +上述每条都对应领域或用例测试;真实权限检查、迁移与并发保障由适配器集成测试 +验证。本文为设计文档,未执行或宣称通过这些测试。 + +## 7. 本轮待评审与后续阻塞项 + +本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。 +管理 Secret 来源和 Bao 故障不单独撤销 Instance Ready 已确认;Secret 更新后的连接 +刷新协议仍需细化。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。 + +批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。 diff --git a/docs/domain-model.md b/docs/domain-model.md new file mode 100644 index 0000000..2034986 --- /dev/null +++ b/docs/domain-model.md @@ -0,0 +1,145 @@ +# 领域模型设计草案 + +状态:Draft,待评审。日期:2026-09-12。 + +本文定义领域职责、身份与一致性边界,并用对象规格细化字段和方法合同;方法使用 +设计签名,不固定 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、管理凭据引用、extension allowlist, +以及用于判断当前能力的观察结果。它不持有所有 Tenant 对象的集合。 + +其行为包括: + +- 判断租户申请是否符合本实例的 extension 策略。 +- 根据管理连接、服务器信息、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 变化,但同一个 Instance UID 指向 + 另一台服务器,或者多个 Instance 指向同一台服务器时,既有 Claim 怎么解释?不能 + 仅凭 CR UID 判断物理隔离,或将已有租户无声迁移。需决定限制还是引入可验证的 + 服务器/安装身份;本文不新增限制。 +2. **Retain 完成条件**:外部依赖不可用不能永久阻止 CR 删除,但 registry 又需标记 + unmanaged。需定义 CR 消失后的补偿/清扫入口及所需身份依据,不能承诺同时原子 + 完成两者,也不能在没有回读时声称已写入保留标记。 +3. **管理凭据来源与 Ready(已确认)**:Instance 引用 controller namespace 内的 + 管理 Secret 名称和字段;管理员维护 ExternalSecret,ESO 同步。controller 不直接 + 从 Bao 读取管理凭据。已有凭据仍可访问 PG 时,Bao/ESO 故障不撤销 Instance Ready; + 首次装配无有效 Secret 则失败。Secret 更新后的连接刷新协议待细化,非自动 PG 轮换。 +4. **绑定时机**:系统规格写“首次成功后不可变”,API 文档写“首次创建外部状态后 + 不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失 + 后恢复;否则供应中途改名称可能产生无人管理的资源。 + +本轮先评审第 1–3 节的对象与职责划分;上述问题记录在案,相关用例在决策批准前不 +进入实现。下一份小改动只细化一个用例,不同时实现整套模型。 diff --git a/docs/security.md b/docs/security.md index 35779e4..bb6004b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -24,7 +24,8 @@ VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 O ## 凭据处理 - 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 现值。 - controller 创建 ExternalSecret,不直接创建含 data/stringData 的 Secret。 - 日志字段允许 namespace/name、UID、generation、阶段和错误类别;禁止记录请求/响应体、 @@ -41,8 +42,10 @@ VM/磁盘备份会包含 PostgreSQL registry 和租户数据,但不应包含 O ## 最小权限 -OpenBao controller identity 只能读取管理凭据范围并管理固定 tenant base path;ESO -identity 只能读取 tenant base path。两者不得共用可访问管理凭据的 policy。 +OpenBao controller identity 只管理固定 tenant base path,不读取管理凭据。管理凭据 +ESO 身份只读管理路径,租户 ESO 身份只读 tenant base path,二者隔离,Tenant 不得 +使用管理凭据 Store。controller 对管理 Secret 的读取限于自身 namespace,Instance +不能指定其他 namespace;controller 不创建或修改管理 Secret/ExternalSecret。 PostgreSQL 管理 role 不应是 superuser。若平台选择 SECURITY DEFINER 函数承载创建或 删除操作,函数必须固定 `search_path`、严格校验 identifier、拒绝任意 SQL,并仅向 diff --git a/docs/specification.md b/docs/specification.md index fc731b8..685e2c9 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -4,7 +4,7 @@ | --- | --- | | 状态 | Approved | | 目标 API | `database.ddupan.top/v1alpha1` | -| 最后更新 | 2026-09-10 | +| 最后更新 | 2026-09-13 | | 批准日期 | 2026-09-10 | | 规范范围 | 首次注册外部 PostgreSQL 实例并创建一个应用租户 | @@ -75,7 +75,7 @@ v1alpha1 不负责: | controller 工作流阶段 | Kubernetes CR `status.phase` | 状态机 checkpoint;可由外部事实保守重建 | | 应用凭据 | OpenBao KV v2 | Kubernetes API 中不得出现明文 | | Kubernetes 凭据投射 | External Secrets Operator | ExternalSecret 由本 controller 管理 | -| PostgreSQL 管理凭据 | OpenBao KV v2 | 由 `PostgreSQLInstance` 引用 | +| PostgreSQL 管理凭据 | controller namespace 的 Kubernetes Secret | 管理员维护 ExternalSecret,由 ESO 同步;Instance 只引用 Secret | 平台管理员管理 `PostgreSQLInstance`、controller 部署配置、OpenBao policy 和 PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管理 @@ -93,7 +93,7 @@ PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管 - PostgreSQL host、port 和管理连接使用的 database; - PostgreSQL host address,供无法解析 DNS 的消费者使用; - TLS mode; -- PostgreSQL 管理凭据在 OpenBao 中的位置和字段名; +- controller namespace 中 PostgreSQL 管理 Secret 的名称和字段名; - 租户允许申请的 extension 集合。 实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全 @@ -174,8 +174,15 @@ Token 禁止写入 Deployment、CR 或镜像。 ### 8.2 管理凭据 -`PostgreSQLInstance` 只引用 PostgreSQL 管理用户名和密码所在的 OpenBao KV v2 -mount-relative path。controller 对该路径只需要读取权限。 +`PostgreSQLInstance` 只引用 controller 自身 namespace 中 Kubernetes Secret 的名称及 +用户名、密码字段名,不允许指定 namespace 或 Bao path。endpoint 仍由 Instance 声明。 +平台管理员维护 ExternalSecret,将 OpenBao 管理凭据同步到该 Secret;controller 只读 +Secret,不创建或修改管理 Secret、其 ExternalSecret 或上游管理凭据。 + +Instance 管理连接不直接访问 Bao,不负责管理密码轮换。已装配的凭据仍可访问 +PostgreSQL 且满足 registry/权限要求时,Bao 或 ESO 暂时不可用不使 Instance NotReady。 +首次装配无法取得有效 Secret 时不能 Ready。Secret 内容更新如何触发连接刷新另行 +定义,本次不引入自动 PostgreSQL 密码轮换。Tenant 凭据管理仍直接依赖 Bao。 ### 8.3 租户凭据 @@ -410,8 +417,9 @@ v1alpha1 不接管现有 database 或 role,但必须提供可重复、可回 明文连接。 3. PostgreSQL 管理 role 应使用满足本规格的最小权限,不应使用 PostgreSQL superuser;若 extension 安装需要额外权限,必须单独记录例外。 -4. OpenBao policy 必须限制为:读取已登记的管理凭据范围,以及创建/读取本 controller - 管理的租户 KV 范围。 +4. controller 的 OpenBao policy 仅覆盖受管租户 KV 操作,不授予管理凭据路径权限。 + 管理凭据的 ESO 同步身份与应用凭据的 ESO 读取身份隔离。controller 只在自身 + namespace 获得管理 Secret 读取权限,不因此扩大跨 namespace Secret data 访问范围。 5. namespace 用户不得修改 cluster-scoped Instance。 6. 所有 identifier、extension name 和引用字段必须在发起外部调用前校验。 7. controller 不得通过 shell 或 `psql` 子进程执行用户输入。 @@ -435,7 +443,9 @@ v1alpha1 至少必须提供: 实现 v1alpha1 第一条完整纵向切片前,测试必须覆盖: 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 记录。 4. 应用凭据可以实际连接且不能创建其他 database/role。 @@ -496,6 +506,10 @@ v1alpha1 至少必须提供: ## 17. 批准状态 +2026-09-13 已确认管理连接修订:Instance 引用 controller namespace 内的管理 Secret, +管理员维护 ExternalSecret,由 ESO 同步;controller 不再从 Bao 直接读取管理凭据。 +此项是已批准行为,现有 API types 与实现尚待后续修改。 + 具体设计决策和本文整体已于 2026-09-10 获得批准,可以进入 API reference、测试和 实现阶段。同日确认状态机修订:两个 CR 的 `status.phase` 是 controller 工作流的权威 checkpoint;PostgreSQL registry 只承担所有权、安装身份和保留状态。 -- 2.54.0 From a0afbf992cc8ed1df33a0558c970ed74dfe6dd7b Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 13 Sep 2026 15:05:54 +0000 Subject: [PATCH 7/8] docs: define management connection refresh on Secret changes --- docs/domain-instance.md | 10 +++++++--- docs/domain-model.md | 3 ++- docs/specification.md | 9 +++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/domain-instance.md b/docs/domain-instance.md index 1471088..ddd6a92 100644 --- a/docs/domain-instance.md +++ b/docs/domain-instance.md @@ -138,7 +138,11 @@ registry 不兼容的具体 Condition 映射须在接口规格中确定,不能 管理连接由应用层从 controller namespace 的 Secret 装配;管理员维护 ExternalSecret, ESO 负责同步。Instance 路径不直接访问 Bao,也不以 Bao/ESO 当前可用性作为就绪条件。 首次装配缺少有效 Secret 时失败;已有凭据可正常访问 PG 时继续按 PG 能力判定。 -Secret 更新后的连接刷新协议仍需细化,不在领域对象中实现监听或密码轮换。 +检测到所引用 Secret 的有效用户名或密码变化时,应用/基础设施层使用新值重建连接池 +并重新采集管理能力观察;metadata 或无关字段变化不重建。不要求 Instance generation +变化,也不能复用旧连接的成功观察来证明新凭据有效。Secret 变化监听、连接释放和 +刷新均不进入领域对象;应用层保证旧连接观察不混入刷新后的调用链。 +controller 不修改 PostgreSQL 密码、不回写 Secret 或 Bao 管理凭据。 ## 5. 状态转换与初始化走查 @@ -192,7 +196,7 @@ Ready --registry 需修复/保存--> InitializingRegistry ## 7. 本轮待评审与后续阻塞项 本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。 -管理 Secret 来源和 Bao 故障不单独撤销 Instance Ready 已确认;Secret 更新后的连接 -刷新协议仍需细化。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。 +管理 Secret 来源、Bao 故障不单独撤销 Instance Ready,以及管理用户名/密码变化时 +重建连接池已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。 批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。 diff --git a/docs/domain-model.md b/docs/domain-model.md index 2034986..bb789ca 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -136,7 +136,8 @@ phase 只决定候选步骤,外部证据决定该步骤是否允许执行、 3. **管理凭据来源与 Ready(已确认)**:Instance 引用 controller namespace 内的 管理 Secret 名称和字段;管理员维护 ExternalSecret,ESO 同步。controller 不直接 从 Bao 读取管理凭据。已有凭据仍可访问 PG 时,Bao/ESO 故障不撤销 Instance Ready; - 首次装配无有效 Secret 则失败。Secret 更新后的连接刷新协议待细化,非自动 PG 轮换。 + 首次装配无有效 Secret 则失败。Secret 的有效用户名/密码变化时重建管理连接池并 + 重验,不因无关字段变化重建;controller 不修改 PG 密码,不回写 Secret 或 Bao。 4. **绑定时机**:系统规格写“首次成功后不可变”,API 文档写“首次创建外部状态后 不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失 后恢复;否则供应中途改名称可能产生无人管理的资源。 diff --git a/docs/specification.md b/docs/specification.md index 685e2c9..698f5ff 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -181,8 +181,13 @@ Secret,不创建或修改管理 Secret、其 ExternalSecret 或上游管理凭 Instance 管理连接不直接访问 Bao,不负责管理密码轮换。已装配的凭据仍可访问 PostgreSQL 且满足 registry/权限要求时,Bao 或 ESO 暂时不可用不使 Instance NotReady。 -首次装配无法取得有效 Secret 时不能 Ready。Secret 内容更新如何触发连接刷新另行 -定义,本次不引入自动 PostgreSQL 密码轮换。Tenant 凭据管理仍直接依赖 Bao。 +首次装配无法取得有效 Secret 时不能 Ready。检测到所引用 Secret 的有效用户名或密码 +变化时,controller 使用新值重建管理连接池并重新检查管理能力;仅 metadata 或无关 +字段变化不触发重建。刷新不依赖 Instance generation 变化,新连接验证失败按实际 +故障报告,不能用旧连接的成功结果证明新凭据可用。 +controller 不修改 PostgreSQL 密码、不回写 Secret,也不修改 Bao 管理凭据;数据库侧 +凭据变更由管理员负责。这是跟随已提供凭据的连接刷新,不是自动密码轮换。 +Tenant 凭据管理仍直接依赖 Bao。 ### 8.3 租户凭据 -- 2.54.0 From bb87316773e08009bb8dc71c5e5c874046b9a4b8 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 13 Sep 2026 15:20:26 +0000 Subject: [PATCH 8/8] docs: clarify Instance identity and deletion semantics --- docs/api-reference.md | 10 ++++++++-- docs/domain-instance.md | 22 +++++++++++++++++----- docs/domain-model.md | 19 +++++++++++-------- docs/specification.md | 12 ++++++++++++ 4 files changed, 48 insertions(+), 15 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 7112082..2eb6ae4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -50,6 +50,9 @@ namespace,名称须合法,两个字段须存在且非空。管理员维护 E Instance endpoint、管理凭据引用和 allowlist 可以修改。修改后 controller 重新验证; 删除 allowlist 项目不会自动从已有 Tenant database 删除 extension。 +endpoint 由管理员负责,不校验变更前后是否同一物理服务器/registry,只重验新配置 +的连接与管理能力。新 UID 按新 Instance 处理,不授权接管旧 UID 的 Tenant 资源。 + ### Status | JSON path | 类型 | 含义 | @@ -147,8 +150,11 @@ phase 用于进度展示、恢复和排障。 - `Retain` 不需要等待外部依赖;删除 CR 后外部记录保留原 UID 并标记 unmanaged。 - `Delete` 添加 finalizer,严格按规格的所有权验证和清理顺序执行;失败保持 finalizer。 -- controller 不为 `PostgreSQLInstance` 级联删除 Tenant 或外部资源;存在引用时 Instance - 删除应被 finalizer 阻止,直到 Tenant 被删除或管理员使用运维逃生流程。 +- Instance 开始受管时即添加并保存 finalizer;删除时停止新供应,存在 Tenant 引用 + (包括正在删除的 Tenant)就保留 finalizer,无引用才移除。引用查询失败时继续等待。 + 不级联删除 Tenant 或外部资源;管理员可使用运维逃生流程。 +- finalizer 不禁止创建 Tenant CR;并发创建者遇到删除中或不存在的 Instance 不得 + 开始供应。首版不增加跨对象锁或准入控制,不承诺跨对象原子删除。 ## 示例 diff --git a/docs/domain-instance.md b/docs/domain-instance.md index ddd6a92..1824194 100644 --- a/docs/domain-instance.md +++ b/docs/domain-instance.md @@ -1,6 +1,6 @@ # Instance 领域对象规格 -状态:Draft,待批准。日期:2026-09-12。 +状态:Draft,含已确认决策。日期:2026-09-13。 上层边界见 [领域模型](domain-model.md)。本文只展开 Instance,不包含 Tenant 的供应 实现,也不新增 CRD 字段。设计签名用于评审职责与行为,不是待复制的 Go 接口代码。 @@ -18,6 +18,11 @@ context、客户端或 IO 接口。领域行为不是公共 SetReady:调用方 每轮从 CR 重建一个 Instance;对象不跨 reconcile 缓存,也不是线程共享单例。 管理连接可由装配层跨轮次复用,但连接复用不代表上次能力验证仍然成立。 +身份与 endpoint 以管理员声明为准。改变 endpoint 不验证是否同一物理服务器或 +registry,不增加安装身份连续性检查;只使旧观察失效,按新配置重验管理能力。 +新 CR 是新 Instance,不自动获得旧 UID 资源的所有权,也不迁移或清理旧目标。 +下文“观察绑定匹配”仅指结果属于本轮身份/配置,不是物理服务器身份认证协议。 + ## 2. 字段与值对象 所有可变状态封装在对象内部。构造后身份和本轮 definition 不可变;配置变更通过 @@ -130,8 +135,8 @@ EnsureRegistry(context, target) -> Completed | AccessFailure endpoint 的成功结果不得用于新 endpoint。Inspect 是只读;EnsureRegistry 是幂等初始化/迁移, 不能顺带建立 Tenant 数据库或接管未知 schema。Completed 不足以推进 Ready,必须回读。 -适配器由装配层绑定管理连接;凭据源、连接池释放及 Bao token 重新认证留在该边界 -之后。适配器不得自行把基础设施异常转换成 Ready。返回的失败至少区分依赖不可用、 +适配器由装配层绑定管理连接;Secret 读取与连接池释放留在该边界之后,Instance +管理连接不涉及 Bao token。适配器不得把基础设施异常转换成 Ready。失败区分依赖不可用、 认证失败、权限不足和 registry 不兼容;不兼容属于不可安全继续,不自动覆写。 registry 不兼容的具体 Condition 映射须在接口规格中确定,不能统一误报权限不足。 @@ -184,7 +189,13 @@ Ready --registry 需修复/保存--> InitializingRegistry - 外部 IO 超时:产生安全失败结果;保存 status 使用仍有效的外层上下文,不能复用 已超时的 IO 上下文而丢失失败状态。 - 已请求删除的 Instance 不允许新供应;BeginDeletion 不删除 PostgreSQL、Tenant 或 - Bao。Tenant 引用检查及 Instance 删除竞争协议仍需另行批准。 + Bao。应用层在开始受管时添加并保存 finalizer,而非出现 Tenant 后再添加。 + 删除时查询所有引用它的 Tenant(含删除中的对象);有引用或查询失败就保留 + finalizer,确认无引用才移除。引用查询、finalizer 写入和本地连接释放均不属于 + 领域 IO,Instance 只根据删除请求禁用供应能力。 +- 首版不为 Instance 删除增加跨对象锁或准入控制。并发创建的 Tenant CR 不被 + finalizer 拦截,但遇到删除中/不存在的 Instance 不得开始供应;不承诺取消 + 已在途的外部操作,也不声称引用查询与移除 finalizer 是跨对象原子事务。 - CheckExtensions 失败不能产生任何外部写入;修改 allowlist 不会自行卸载扩展。 - Snapshot、错误、日志和领域对象格式化不输出明文凭据或 token。 - 领域测试只提供观察值,无需数据库、网络、context 或 IO mock;相同状态和输入 @@ -197,6 +208,7 @@ Ready --registry 需修复/保存--> InitializingRegistry 本轮请先确认字段归属、应用层采集事实/Instance 纯决策的分工、方法与状态转换合同。 管理 Secret 来源、Bao 故障不单独撤销 Instance Ready,以及管理用户名/密码变化时 -重建连接池已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。 +重建连接池,以及管理员声明的 Instance 身份/endpoint 和简化 finalizer 删除规则 +均已确认。其他决策及未决项见总体草案,不增加后台清扫器或状态字段。 批准本对象结构不等于批准这些未决行为,也不意味着立刻实现完整供应链路。 diff --git a/docs/domain-model.md b/docs/domain-model.md index bb789ca..81cd951 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -1,6 +1,6 @@ # 领域模型设计草案 -状态:Draft,待评审。日期:2026-09-12。 +状态:Draft,含已确认决策。日期:2026-09-13。 本文定义领域职责、身份与一致性边界,并用对象规格细化字段和方法合同;方法使用 设计签名,不固定 Go 目录、SDK 或框架,也不批准实现。外部行为以 @@ -124,12 +124,11 @@ phase 只决定候选步骤,外部证据决定该步骤是否允许执行、 测试验证 checkpoint 持久化和重启恢复;E2E 验证最终合同。不能只验证一串 mock 调用 就声称实现了最终一致性。 -## 6. 批准前需明确的边界 +## 6. 决策记录与待细化边界 -1. **Instance 身份与物理目标**:规格允许 endpoint 变化,但同一个 Instance UID 指向 - 另一台服务器,或者多个 Instance 指向同一台服务器时,既有 Claim 怎么解释?不能 - 仅凭 CR UID 判断物理隔离,或将已有租户无声迁移。需决定限制还是引入可验证的 - 服务器/安装身份;本文不新增限制。 +1. **Instance 身份与物理目标(已确认)**:以管理员声明为准,endpoint 变更不验证 + 物理服务器/registry 连续性,不增加安装身份绑定检查;旧观察失效,重验新配置 + 的连接与管理能力。新 CR 视为新 Instance,不自动接管旧 UID 资源或迁移数据。 2. **Retain 完成条件**:外部依赖不可用不能永久阻止 CR 删除,但 registry 又需标记 unmanaged。需定义 CR 消失后的补偿/清扫入口及所需身份依据,不能承诺同时原子 完成两者,也不能在没有回读时声称已写入保留标记。 @@ -142,5 +141,9 @@ phase 只决定候选步骤,外部证据决定该步骤是否允许执行、 不可变”。应明确绑定在认领、首次外部写入还是 Ready 时固定,及如何在 status 丢失 后恢复;否则供应中途改名称可能产生无人管理的资源。 -本轮先评审第 1–3 节的对象与职责划分;上述问题记录在案,相关用例在决策批准前不 -进入实现。下一份小改动只细化一个用例,不同时实现整套模型。 +5. **Instance 删除(已确认)**:开始受管即添加 finalizer;删除期间停止新供应, + 有 Tenant 引用就等待,无引用才解除,不级联删除外部资源。首版采用 finalizer + 与引用检查,不引入跨对象锁/准入控制;不保证并发创建与删除的原子性。 + +Tenant 的 Retain 等待决行为留到 Tenant 设计,不属于本轮 Instance 设计范围。 +相关用例在决策批准前不进入实现,不同时实现整套模型。 diff --git a/docs/specification.md b/docs/specification.md index 698f5ff..bd8580c 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -99,6 +99,18 @@ PostgreSQL 管理 role。应用或 GitOps 流程在获得 namespace RBAC 后管 实例 Ready 不代表 PostgreSQL 数据有备份或高可用,只表示 controller 当前可以安全 建立管理连接、读取 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 `PostgreSQLTenant` 是 namespaced 资源。v1alpha1 中,一个 Tenant 精确对应: -- 2.54.0