Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions charts/postgres-operator/crds/operatorconfigurations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions charts/postgres-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,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
Expand Down
45 changes: 45 additions & 0 deletions docs/reference/operator_parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1098,3 +1098,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
```
8 changes: 8 additions & 0 deletions pkg/apis/acid.zalan.do/v1/operator_configuration_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,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
Expand Down
10 changes: 10 additions & 0 deletions pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 48 additions & 17 deletions pkg/cluster/connection_pooler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -383,9 +428,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
},
},
},
SecurityContext: &v1.SecurityContext{
AllowPrivilegeEscalation: util.False(),
},
SecurityContext: c.generateConnectionPoolerContainerSecurityContext(),
}

var poolerVolumes []v1.Volume
Expand Down Expand Up @@ -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{
Expand All @@ -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,
},
}
Expand Down
131 changes: 131 additions & 0 deletions pkg/cluster/connection_pooler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions pkg/controller/operator_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,5 +297,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
}
7 changes: 7 additions & 0 deletions pkg/util/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading