From 97f66669a64492d36931ce1b1764c80e84e11ba3 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Thu, 10 Sep 2026 18:15:50 +0000 Subject: [PATCH] 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") + } +}