feat(pooler): make connection pooler security contexts configurable
Allow operators to override the connection pooler pod- and container-level securityContext via the OperatorConfiguration CRD (connection_pooler_pod_security_context and connection_pooler_security_context). When unset, the operator preserves its historical defaults (pod RunAsUser/RunAsGroup 100/101 and spilo FSGroup; container AllowPrivilegeEscalation=false), so existing deployments are unaffected. Useful for hardened images whose pgbouncer UID/GID differs from the default and for satisfying restricted Pod Security Standards.
This commit is contained in:
parent
40b6c68443
commit
8ee421f9ff
|
|
@ -851,6 +851,12 @@ spec:
|
|||
connection_pooler_default_memory_request:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
connection_pooler_pod_security_context:
|
||||
type: object
|
||||
x-kubernetes-preserve-unknown-fields: true
|
||||
connection_pooler_security_context:
|
||||
type: object
|
||||
x-kubernetes-preserve-unknown-fields: true
|
||||
patroni:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -471,6 +471,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
|
||||
|
|
|
|||
|
|
@ -1052,6 +1052,14 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
|
|||
"connection_pooler_user": {
|
||||
Type: "string",
|
||||
},
|
||||
"connection_pooler_pod_security_context": {
|
||||
Type: "object",
|
||||
XPreserveUnknownFields: util.True(),
|
||||
},
|
||||
"connection_pooler_security_context": {
|
||||
Type: "object",
|
||||
XPreserveUnknownFields: util.True(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -220,6 +220,11 @@ type ConnectionPoolerConfiguration struct {
|
|||
DefaultMemoryRequest string `json:"connection_pooler_default_memory_request,omitempty"`
|
||||
DefaultCPULimit string `json:"connection_pooler_default_cpu_limit,omitempty"`
|
||||
DefaultMemoryLimit string `json:"connection_pooler_default_memory_limit,omitempty"`
|
||||
// PodSecurityContext and SecurityContext allow overriding the pooler pod- and
|
||||
// container-level security contexts (e.g. to match a hardened image's UID/GID or to
|
||||
// satisfy restricted Pod Security Standards). When unset the operator keeps its defaults.
|
||||
PodSecurityContext *v1.PodSecurityContext `json:"connection_pooler_pod_security_context,omitempty"`
|
||||
SecurityContext *v1.SecurityContext `json:"connection_pooler_security_context,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorLogicalBackupConfiguration defines configuration for logical backup
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -291,5 +291,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,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
|
||||
|
|
|
|||
Loading…
Reference in New Issue