From 3dabf6c379823d5069b8877790af012cb1247fce Mon Sep 17 00:00:00 2001 From: Mitch Murphy Date: Fri, 5 Jun 2026 14:18:41 -0400 Subject: [PATCH] feat(pooler): render pgbouncer.ini, checksum, and config map --- pkg/cluster/pgbouncer_config.go | 169 +++++++++++++++++++++++++++ pkg/cluster/pgbouncer_config_test.go | 129 ++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 pkg/cluster/pgbouncer_config.go create mode 100644 pkg/cluster/pgbouncer_config_test.go diff --git a/pkg/cluster/pgbouncer_config.go b/pkg/cluster/pgbouncer_config.go new file mode 100644 index 000000000..d9566ad5a --- /dev/null +++ b/pkg/cluster/pgbouncer_config.go @@ -0,0 +1,169 @@ +package cluster + +import ( + "bytes" + "crypto/sha256" + "fmt" + "sort" + "strings" + "text/template" + + acidv1 "github.com/zalando/postgres-operator/pkg/apis/acid.zalan.do/v1" + "github.com/zalando/postgres-operator/pkg/util" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + pgBouncerConfigFileName = "pgbouncer.ini" + poolerConfigChecksumAnnotation = "postgres-operator.zalando.org/pgbouncer-config-checksum" +) + +// FIPS-friendly pgbouncer.ini. Differs from the stock Zalando image template: +// no logfile/pidfile (distroless logs to stdout), auth_type is configurable, +// and TLS cert lines are emitted only when spec.TLS is set (the FIPS image does +// not run the openssl cert-generation step that the Zalando entrypoint does). +const pgBouncerConfigTemplateText = `# Generated by postgres-operator. Do not edit. +[databases] +* = host={{ .DBHost }} port={{ .DBPort }} auth_user={{ .User }} +postgres = host={{ .DBHost }} port={{ .DBPort }} auth_user={{ .User }} + +[pgbouncer] +pool_mode = {{ .Mode }} +listen_port = {{ .ListenPort }} +listen_addr = * +admin_users = {{ .User }} +{{- if .StatsUsers }} +stats_users = {{ .StatsUsers }} +{{- end }} +auth_dbname = postgres +auth_file = /etc/pgbouncer/userlist.txt +auth_query = SELECT * FROM {{ .Schema }}.user_lookup($1) +auth_type = {{ .AuthType }} +server_tls_sslmode = require +{{- if .TLS }} +{{- if .TLSCAFile }} +server_tls_ca_file = {{ .TLSCAFile }} +{{- end }} +client_tls_sslmode = require +client_tls_key_file = {{ .TLSKeyFile }} +client_tls_cert_file = {{ .TLSCertFile }} +{{- end }} +log_connections = 0 +log_disconnections = 0 +max_prepared_statements = 200 +default_pool_size = {{ .DefaultPoolSize }} +reserve_pool_size = {{ .ReservePoolSize }} +max_client_conn = {{ .MaxClientConn }} +max_db_connections = {{ .MaxDBConnections }} +idle_transaction_timeout = 600 +server_login_retry = 5 +ignore_startup_parameters = extra_float_digits,options +` + +var pgBouncerConfigTemplate = template.Must( + template.New(pgBouncerConfigFileName).Parse(pgBouncerConfigTemplateText)) + +type pgBouncerConfigParams struct { + DBHost string + DBPort int32 + ListenPort int32 + User string + Schema string + Mode string + AuthType string + StatsUsers string + DefaultPoolSize int32 + ReservePoolSize int32 + MaxClientConn int32 + MaxDBConnections int32 + TLS bool + TLSCAFile string + TLSKeyFile string + TLSCertFile string +} + +// generatePgBouncerIni renders the pgbouncer.ini for the given role from the +// cluster spec and operator config. +func (c *Cluster) generatePgBouncerIni(role PostgresRole) (string, error) { + spec := &c.Spec + connectionPoolerSpec := spec.ConnectionPooler + if connectionPoolerSpec == nil { + connectionPoolerSpec = &acidv1.ConnectionPooler{} + } + + sizes := c.connectionPoolerSizes() + + infraRolesList := make([]string, 0) + for infraRoleName := range c.InfrastructureRoles { + infraRolesList = append(infraRolesList, infraRoleName) + } + sort.Strings(infraRolesList) // deterministic output for stable checksums + + params := pgBouncerConfigParams{ + DBHost: c.serviceAddress(role), + DBPort: c.servicePort(role), + ListenPort: pgPort, + User: util.Coalesce(connectionPoolerSpec.User, c.OpConfig.ConnectionPooler.User), + Schema: util.Coalesce(connectionPoolerSpec.Schema, c.OpConfig.ConnectionPooler.Schema), + Mode: util.Coalesce(connectionPoolerSpec.Mode, c.OpConfig.ConnectionPooler.Mode), + AuthType: c.OpConfig.ConnectionPooler.AuthType, + StatsUsers: strings.Join(infraRolesList, ","), + DefaultPoolSize: sizes.defaultSize, + ReservePoolSize: sizes.reserveSize, + MaxClientConn: sizes.maxClientConn, + MaxDBConnections: sizes.maxDBConn, + } + + if spec.TLS != nil && spec.TLS.SecretName != "" { + mountPath := "/tls" + params.TLS = true + params.TLSCertFile = ensurePath(spec.TLS.CertificateFile, mountPath, "tls.crt") + params.TLSKeyFile = ensurePath(spec.TLS.PrivateKeyFile, mountPath, "tls.key") + if spec.TLS.CAFile != "" { + mountPathCA := mountPath + if spec.TLS.CASecretName != "" { + mountPathCA = mountPath + "ca" + } + params.TLSCAFile = ensurePath(spec.TLS.CAFile, mountPathCA, "") + } + } + + var buf bytes.Buffer + if err := pgBouncerConfigTemplate.Execute(&buf, params); err != nil { + return "", fmt.Errorf("could not render pgbouncer config: %v", err) + } + return buf.String(), nil +} + +// connectionPoolerConfigChecksum returns the sha256 of the rendered config, +// used as a pod annotation so config changes roll the pooler pods. +func (c *Cluster) connectionPoolerConfigChecksum(role PostgresRole) (string, error) { + ini, err := c.generatePgBouncerIni(role) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(ini)) + return fmt.Sprintf("%x", sum), nil +} + +// generateConnectionPoolerConfigMap builds the operator-owned ConfigMap holding +// the rendered pgbouncer.ini for the given role. +func (c *Cluster) generateConnectionPoolerConfigMap(role PostgresRole) (*v1.ConfigMap, error) { + ini, err := c.generatePgBouncerIni(role) + if err != nil { + return nil, err + } + return &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-config", c.connectionPoolerName(role)), + Namespace: c.Namespace, + Labels: c.connectionPoolerLabels(role, true).MatchLabels, + Annotations: c.annotationsSet(nil), + OwnerReferences: c.ownerReferences(), + }, + Data: map[string]string{ + pgBouncerConfigFileName: ini, + }, + }, nil +} diff --git a/pkg/cluster/pgbouncer_config_test.go b/pkg/cluster/pgbouncer_config_test.go new file mode 100644 index 000000000..fd68c9a5b --- /dev/null +++ b/pkg/cluster/pgbouncer_config_test.go @@ -0,0 +1,129 @@ +package cluster + +import ( + "strings" + "testing" + + acidv1 "github.com/zalando/postgres-operator/pkg/apis/acid.zalan.do/v1" + "github.com/zalando/postgres-operator/pkg/util" + "github.com/zalando/postgres-operator/pkg/util/config" + "github.com/zalando/postgres-operator/pkg/util/k8sutil" +) + +func newGenerateConfigCluster() *Cluster { + maxDB := int32(60) + instances := int32(2) + cluster := New( + Config{OpConfig: config.Config{ + ConnectionPooler: config.ConnectionPooler{ + User: "pooler", + Schema: "pooler", + Mode: "transaction", + MaxDBConnections: &maxDB, + NumberOfInstances: &instances, + GenerateConfig: true, + AuthType: "scram-sha-256", + ConfigPath: "/etc/pgbouncer/pgbouncer.ini", + Args: []string{"/etc/pgbouncer/pgbouncer.ini"}, + }, + Resources: config.Resources{ + EnableOwnerReferences: util.True(), + }, + }}, + k8sutil.NewMockKubernetesClient(), acidv1.Postgresql{}, logger, eventRecorder) + cluster.Name = "acid-test" + cluster.Namespace = "default" + cluster.Spec = acidv1.PostgresSpec{ConnectionPooler: &acidv1.ConnectionPooler{}} + return cluster +} + +func TestGeneratePgBouncerIni(t *testing.T) { + cluster := newGenerateConfigCluster() + + ini, err := cluster.generatePgBouncerIni(Master) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, want := range []string{ + "[databases]", + "[pgbouncer]", + "pool_mode = transaction", + "auth_type = scram-sha-256", + "auth_file = /etc/pgbouncer/userlist.txt", + "auth_query = SELECT * FROM pooler.user_lookup($1)", + "server_tls_sslmode = require", + "default_pool_size = 15", + "max_db_connections = 30", + } { + if !strings.Contains(ini, want) { + t.Errorf("rendered ini missing %q\n---\n%s", want, ini) + } + } + + if strings.Contains(ini, "client_tls_cert_file") { + t.Errorf("did not expect client_tls_cert_file without spec.TLS\n%s", ini) + } +} + +func TestGeneratePgBouncerIniWithTLS(t *testing.T) { + cluster := newGenerateConfigCluster() + cluster.Spec.TLS = &acidv1.TLSDescription{SecretName: "pg-tls"} + + ini, err := cluster.generatePgBouncerIni(Master) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{ + "client_tls_sslmode = require", + "client_tls_key_file = /tls/tls.key", + "client_tls_cert_file = /tls/tls.crt", + } { + if !strings.Contains(ini, want) { + t.Errorf("rendered ini missing %q\n---\n%s", want, ini) + } + } +} + +func TestConnectionPoolerConfigChecksumStability(t *testing.T) { + cluster := newGenerateConfigCluster() + + sum1, err := cluster.connectionPoolerConfigChecksum(Master) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sum2, err := cluster.connectionPoolerConfigChecksum(Master) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sum1 != sum2 { + t.Errorf("checksum not stable: %q != %q", sum1, sum2) + } + + cluster.OpConfig.ConnectionPooler.AuthType = "md5" + sum3, err := cluster.connectionPoolerConfigChecksum(Master) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sum1 == sum3 { + t.Errorf("checksum should change when config changes") + } +} + +func TestGenerateConnectionPoolerConfigMap(t *testing.T) { + cluster := newGenerateConfigCluster() + + cm, err := cluster.generateConnectionPoolerConfigMap(Master) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cm.Name != cluster.connectionPoolerName(Master)+"-config" { + t.Errorf("unexpected config map name %q", cm.Name) + } + if _, ok := cm.Data["pgbouncer.ini"]; !ok { + t.Errorf("config map missing pgbouncer.ini key, got %#v", cm.Data) + } + if len(cm.OwnerReferences) == 0 { + t.Errorf("config map should have owner references") + } +}