This commit is contained in:
Mitch Murphy 2026-08-12 14:04:13 +03:00 committed by GitHub
commit 7963b4b671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 304 additions and 23 deletions

View File

@ -76,6 +76,7 @@ $(GENERATED_CRDS): $(GENERATED)
@$(SED) -i -e 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml
@hack/adjust_postgresql_crd.sh
@mv manifests/acid.zalan.do_operatorconfigurations.yaml manifests/operatorconfiguration.crd.yaml
@hack/adjust_operatorconfiguration_crd.sh
@mv manifests/acid.zalan.do_postgresteams.yaml manifests/postgresteam.crd.yaml
@cp manifests/postgresql.crd.yaml pkg/apis/acid.zalan.do/v1/postgresql.crd.yaml
@cp manifests/postgresql.crd.yaml charts/postgres-operator/crds/postgresqls.yaml

View File

@ -114,9 +114,15 @@ spec:
format: int32
minimum: 1
type: integer
connection_pooler_pod_security_context:
type: object
x-kubernetes-preserve-unknown-fields: true
connection_pooler_schema:
default: pooler
type: string
connection_pooler_security_context:
type: object
x-kubernetes-preserve-unknown-fields: true
connection_pooler_user:
default: pooler
type: string
@ -664,8 +670,7 @@ spec:
type: object
maintenance_windows:
items:
pattern: ^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\
*$
pattern: '^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\ *$'
type: string
type: array
major_version_upgrade:

View File

@ -475,6 +475,24 @@ configConnectionPooler:
connection_pooler_default_memory_request: 100Mi
connection_pooler_default_cpu_limit: "1"
connection_pooler_default_memory_limit: 100Mi
# Override the pooler pod- and container-level securityContext. Useful for hardened
# images whose pgbouncer user/UID differs from the operator default (100/101) and which
# own /etc/pgbouncer, or to satisfy restricted Pod Security Standards. When unset the
# operator keeps its defaults (pod RunAsUser/RunAsGroup 100/101, container
# allowPrivilegeEscalation=false).
# connection_pooler_pod_security_context:
# runAsUser: 100
# runAsGroup: 101
# runAsNonRoot: true
# fsGroup: 101
# seccompProfile:
# type: RuntimeDefault
# connection_pooler_security_context:
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
configPatroni:
# enable Patroni DCS failsafe_mode feature

View File

@ -1107,3 +1107,48 @@ operator being able to provide some reasonable defaults.
**connection_pooler_default_cpu_limit**
**connection_pooler_default_memory_limit**
Default resource configuration for connection pooler deployment.
* **connection_pooler_pod_security_context**
Pod-level `securityContext` applied to the connection pooler deployment. The
value is a standard Kubernetes
[PodSecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#podsecuritycontext-v1-core)
object. Only fields you set are overridden; unset fields keep the operator
defaults (`runAsUser: 100`, `runAsGroup: 101`, and the cluster's `fsGroup`).
Settable **only** via the `OperatorConfiguration` CRD (not the ConfigMap).
Defaults to not set.
* **connection_pooler_security_context**
Container-level `securityContext` applied to the pgbouncer container. The
value is a standard Kubernetes
[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#securitycontext-v1-core)
object. Only fields you set are overridden; when `allowPrivilegeEscalation`
is unset it defaults to `false`. Settable **only** via the
`OperatorConfiguration` CRD (not the ConfigMap). Defaults to not set.
These two parameters are useful when running a hardened pooler image whose
pgbouncer user/UID differs from the operator default (100/101), or to satisfy
restricted [Pod Security Standards](https://kubernetes.io/docs/concepts/security/pod-security-standards/).
Because they are object-valued they cannot be expressed in the flat operator
ConfigMap; configure them through the `OperatorConfiguration` CRD:
```yaml
apiVersion: "acid.zalan.do/v1"
kind: OperatorConfiguration
metadata:
name: postgresql-operator-default-configuration
configuration:
connection_pooler:
connection_pooler_pod_security_context:
runAsUser: 100
runAsGroup: 101
runAsNonRoot: true
fsGroup: 101
seccompProfile:
type: RuntimeDefault
connection_pooler_security_context:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
```

View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Hack to adjust the generated operatorconfiguration CRD YAML file and add
# missing field settings which can not be expressed via kubebuilder markers.
#
# Injections:
#
# * type: string and pattern for the maintenance_windows items.
# The MaintenanceWindow type marshals to/from a string (see marshal.go) but
# the field is declared `+kubebuilder:validation:Schemaless` /
# `+kubebuilder:validation:Type=array`, so controller-gen emits a bare
# `type: array` without the required `items` schema. A structural CRD must
# specify `items` for every array, so the generated CRD is rejected with:
# spec.validation.openAPIV3Schema.properties[configuration]
# .properties[maintenance_windows].items: Required value: must be specified
file="${1:-"manifests/operatorconfiguration.crd.yaml"}"
sed -i '/^[[:space:]]*maintenance_windows:$/{
# Capture the indentation
s/^\([[:space:]]*\)maintenance_windows:$/\1maintenance_windows:\n\1 items:\n\1 pattern: '\''^\\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))-((2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))\\ *$'\''\n\1 type: string/
}' "$file"

View File

@ -664,8 +664,7 @@ spec:
type: object
maintenance_windows:
items:
pattern: ^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\
*$
pattern: '^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\ *$'
type: string
type: array
major_version_upgrade:

View File

@ -345,6 +345,14 @@ type ConnectionPoolerConfiguration struct {
DefaultCPULimit string `json:"connection_pooler_default_cpu_limit,omitempty"`
// +kubebuilder:validation:Pattern=`^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$`
DefaultMemoryLimit string `json:"connection_pooler_default_memory_limit,omitempty"`
// +kubebuilder:validation:XPreserveUnknownFields
// +kubebuilder:validation:Type=object
// +kubebuilder:validation:Schemaless
PodSecurityContext *v1.PodSecurityContext `json:"connection_pooler_pod_security_context,omitempty"`
// +kubebuilder:validation:XPreserveUnknownFields
// +kubebuilder:validation:Type=object
// +kubebuilder:validation:Schemaless
SecurityContext *v1.SecurityContext `json:"connection_pooler_security_context,omitempty"`
}
// OperatorLogicalBackupConfiguration defines configuration for logical backup

View File

@ -664,8 +664,7 @@ spec:
type: object
maintenance_windows:
items:
pattern: ^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\
*$
pattern: '^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\ *$'
type: string
type: array
major_version_upgrade:

View File

@ -142,6 +142,16 @@ func (in *ConnectionPoolerConfiguration) DeepCopyInto(out *ConnectionPoolerConfi
*out = new(int32)
**out = **in
}
if in.PodSecurityContext != nil {
in, out := &in.PodSecurityContext, &out.PodSecurityContext
*out = new(corev1.PodSecurityContext)
(*in).DeepCopyInto(*out)
}
if in.SecurityContext != nil {
in, out := &in.SecurityContext, &out.SecurityContext
*out = new(corev1.SecurityContext)
(*in).DeepCopyInto(*out)
}
return
}

View File

@ -29,6 +29,51 @@ import (
var poolerRunAsUser = int64(100)
var poolerRunAsGroup = int64(101)
// generateConnectionPoolerPodSecurityContext returns the pod-level securityContext for the
// connection pooler. When the operator configuration provides one it is used as the base
// (deep-copied); otherwise an empty context is used. The historical defaults (RunAsUser/RunAsGroup
// 100/101) and the spilo FSGroup are applied only for fields the configuration leaves unset, so
// behavior is unchanged when no override is configured.
func (c *Cluster) generateConnectionPoolerPodSecurityContext(spec *acidv1.PostgresSpec) *v1.PodSecurityContext {
var securityContext v1.PodSecurityContext
if c.OpConfig.ConnectionPooler.PodSecurityContext != nil {
securityContext = *c.OpConfig.ConnectionPooler.PodSecurityContext.DeepCopy()
}
if securityContext.RunAsUser == nil {
securityContext.RunAsUser = &poolerRunAsUser
}
if securityContext.RunAsGroup == nil {
securityContext.RunAsGroup = &poolerRunAsGroup
}
if securityContext.FSGroup == nil {
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
if spec.SpiloFSGroup != nil {
effectiveFSGroup = spec.SpiloFSGroup
}
if effectiveFSGroup != nil {
securityContext.FSGroup = effectiveFSGroup
}
}
return &securityContext
}
// generateConnectionPoolerContainerSecurityContext returns the container-level securityContext for
// the pooler. A configured context is used as the base (deep-copied); AllowPrivilegeEscalation
// defaults to false when left unset, preserving prior behavior.
func (c *Cluster) generateConnectionPoolerContainerSecurityContext() *v1.SecurityContext {
var securityContext v1.SecurityContext
if c.OpConfig.ConnectionPooler.SecurityContext != nil {
securityContext = *c.OpConfig.ConnectionPooler.SecurityContext.DeepCopy()
}
if securityContext.AllowPrivilegeEscalation == nil {
securityContext.AllowPrivilegeEscalation = util.False()
}
return &securityContext
}
// ConnectionPoolerObjects K8s objects that are belong to connection pooler
type ConnectionPoolerObjects struct {
AuthSecret *v1.Secret
@ -383,9 +428,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
},
},
},
SecurityContext: &v1.SecurityContext{
AllowPrivilegeEscalation: util.False(),
},
SecurityContext: c.generateConnectionPoolerContainerSecurityContext(),
}
var poolerVolumes []v1.Volume
@ -444,19 +487,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
poolerContainer.Env = envVars
poolerContainer.VolumeMounts = volumeMounts
tolerationsSpec := tolerations(&spec.Tolerations, c.OpConfig.PodToleration)
securityContext := v1.PodSecurityContext{}
// determine the User, Group and FSGroup for the pooler pod
securityContext.RunAsUser = &poolerRunAsUser
securityContext.RunAsGroup = &poolerRunAsGroup
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
if spec.SpiloFSGroup != nil {
effectiveFSGroup = spec.SpiloFSGroup
}
if effectiveFSGroup != nil {
securityContext.FSGroup = effectiveFSGroup
}
securityContext := c.generateConnectionPoolerPodSecurityContext(spec)
podTemplate := &v1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
@ -469,7 +500,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
Containers: []v1.Container{poolerContainer},
Tolerations: tolerationsSpec,
Volumes: poolerVolumes,
SecurityContext: &securityContext,
SecurityContext: securityContext,
ServiceAccountName: c.OpConfig.PodServiceAccountName,
},
}

View File

@ -1064,6 +1064,137 @@ func TestPoolerTLS(t *testing.T) {
assert.Contains(t, poolerContainer.Env, v1.EnvVar{Name: "CONNECTION_POOLER_CLIENT_CA_FILE", Value: "/tls/ca.crt"})
}
// poolerSecurityContextOpConfig builds a minimal OpConfig usable for exercising the
// connection pooler securityContext behavior. Callers tweak the returned config's
// ConnectionPooler / Resources before passing it to syncedPoolerDeployment.
func poolerSecurityContextOpConfig() config.Config {
return config.Config{
PodManagementPolicy: "ordered_ready",
ProtectedRoles: []string{"admin"},
Auth: config.Auth{
SuperUsername: superUserName,
ReplicationUsername: replicationUserName,
},
Resources: config.Resources{
ClusterLabels: map[string]string{"application": "spilo"},
ClusterNameLabel: "cluster-name",
DefaultCPURequest: "300m",
DefaultCPULimit: "300m",
DefaultMemoryRequest: "300Mi",
DefaultMemoryLimit: "300Mi",
PodRoleLabel: "spilo-role",
},
ConnectionPooler: config.ConnectionPooler{
ConnectionPoolerDefaultCPURequest: "100m",
ConnectionPoolerDefaultCPULimit: "100m",
ConnectionPoolerDefaultMemoryRequest: "100Mi",
ConnectionPoolerDefaultMemoryLimit: "100Mi",
},
PodServiceAccountName: "postgres-pod",
}
}
// syncedPoolerDeployment provisions a pooler with the given OpConfig and returns the
// resulting master pooler Deployment.
func syncedPoolerDeployment(t *testing.T, opConfig config.Config) *appsv1.Deployment {
t.Helper()
client, _ := newFakeK8sPoolerTestClient()
clusterName := "acid-test-cluster"
namespace := "default"
pg := acidv1.Postgresql{
ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: namespace},
Spec: acidv1.PostgresSpec{
TeamID: "myapp", NumberOfInstances: 1,
EnableConnectionPooler: util.True(),
Resources: &acidv1.Resources{
ResourceRequests: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
ResourceLimits: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
},
Volume: acidv1.Volume{Size: "1G"},
},
}
cluster := New(Config{OpConfig: opConfig}, client, pg, logger, eventRecorder)
_, err := cluster.createStatefulSet()
assert.NoError(t, err)
cluster.ConnectionPooler = map[PostgresRole]*ConnectionPoolerObjects{
Master: {
Name: cluster.connectionPoolerName(Master),
ClusterName: clusterName,
Namespace: namespace,
Role: Master,
},
}
_, err = cluster.syncConnectionPoolerWorker(nil, &pg, Master)
assert.NoError(t, err)
deploy, err := client.Deployments(namespace).Get(context.TODO(), cluster.connectionPoolerName(Master), metav1.GetOptions{})
assert.NoError(t, err)
return deploy
}
func TestConnectionPoolerDefaultSecurityContext(t *testing.T) {
deploy := syncedPoolerDeployment(t, poolerSecurityContextOpConfig())
podSc := deploy.Spec.Template.Spec.SecurityContext
assert.NotNil(t, podSc, "pod securityContext should be set")
assert.Equal(t, int64(100), *podSc.RunAsUser, "defaults to RunAsUser 100 for backward compatibility")
assert.Equal(t, int64(101), *podSc.RunAsGroup, "defaults to RunAsGroup 101 for backward compatibility")
containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext
assert.NotNil(t, containerSc, "container securityContext should be set")
assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "defaults AllowPrivilegeEscalation to false")
}
func TestConnectionPoolerPodSecurityContextOverride(t *testing.T) {
runAsUser := int64(1000001)
runAsGroup := int64(1000001)
spiloFSGroup := int64(103)
opConfig := poolerSecurityContextOpConfig()
// FSGroup comes from the spilo config and must still apply when the override leaves it nil.
opConfig.Resources.SpiloFSGroup = &spiloFSGroup
opConfig.ConnectionPooler.PodSecurityContext = &v1.PodSecurityContext{
RunAsUser: &runAsUser,
RunAsGroup: &runAsGroup,
RunAsNonRoot: util.True(),
SeccompProfile: &v1.SeccompProfile{
Type: v1.SeccompProfileTypeRuntimeDefault,
},
}
deploy := syncedPoolerDeployment(t, opConfig)
podSc := deploy.Spec.Template.Spec.SecurityContext
assert.Equal(t, runAsUser, *podSc.RunAsUser, "honors configured RunAsUser")
assert.Equal(t, runAsGroup, *podSc.RunAsGroup, "honors configured RunAsGroup")
assert.Equal(t, true, *podSc.RunAsNonRoot, "honors configured RunAsNonRoot")
assert.Equal(t, v1.SeccompProfileTypeRuntimeDefault, podSc.SeccompProfile.Type, "honors configured SeccompProfile")
assert.Equal(t, spiloFSGroup, *podSc.FSGroup, "still applies spilo FSGroup when override leaves it nil")
}
func TestConnectionPoolerContainerSecurityContextOverride(t *testing.T) {
opConfig := poolerSecurityContextOpConfig()
opConfig.ConnectionPooler.SecurityContext = &v1.SecurityContext{
AllowPrivilegeEscalation: util.False(),
ReadOnlyRootFilesystem: util.True(),
Capabilities: &v1.Capabilities{
Drop: []v1.Capability{"ALL"},
},
}
deploy := syncedPoolerDeployment(t, opConfig)
containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext
assert.Equal(t, true, *containerSc.ReadOnlyRootFilesystem, "honors configured ReadOnlyRootFilesystem")
assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "keeps AllowPrivilegeEscalation false")
assert.Equal(t, []v1.Capability{"ALL"}, containerSc.Capabilities.Drop, "honors dropped capabilities")
}
func TestConnectionPoolerServiceSpec(t *testing.T) {
testName := "Test connection pooler service spec generation"
var cluster = New(

View File

@ -285,5 +285,10 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
fromCRD.ConnectionPooler.MaxDBConnections,
k8sutil.Int32ToPointer(constants.ConnectionPoolerMaxDBConnections))
// Security contexts are nil unless explicitly configured; the cluster package falls back
// to the historical pooler defaults when they are not set.
result.ConnectionPooler.PodSecurityContext = fromCRD.ConnectionPooler.PodSecurityContext
result.ConnectionPooler.SecurityContext = fromCRD.ConnectionPooler.SecurityContext
return result
}

View File

@ -165,6 +165,13 @@ type ConnectionPooler struct {
ConnectionPoolerDefaultMemoryRequest string `name:"connection_pooler_default_memory_request"`
ConnectionPoolerDefaultCPULimit string `name:"connection_pooler_default_cpu_limit"`
ConnectionPoolerDefaultMemoryLimit string `name:"connection_pooler_default_memory_limit"`
// PodSecurityContext and SecurityContext let operators override the pooler
// pod- and container-level security contexts. They are populated only from the
// OperatorConfiguration CRD (name:"-" excludes them from the ConfigMap decoder,
// mirroring LivenessProbe). When nil, the operator falls back to the historical
// defaults (pod RunAsUser/RunAsGroup 100/101, container AllowPrivilegeEscalation=false).
PodSecurityContext *v1.PodSecurityContext `name:"-"`
SecurityContext *v1.SecurityContext `name:"-"`
}
// Config describes operator config