From 5e573231889295e8d8ef91ffece00499930ecdc0 Mon Sep 17 00:00:00 2001 From: Yves Galante Date: Sat, 7 Nov 2026 07:15:45 +0100 Subject: [PATCH 1/3] Add configurable security context options for Postgres pods Running Postgres clusters under the restricted Pod Security Standard requires settings the operator could not configure. Add operator options that are independent of the configured user ID: - spilo_runasnonroot: sets runAsNonRoot in the pod security context - spilo_seccomp_profile: sets the seccomp profile in the pod security context (RuntimeDefault, Unconfined or Localhost/) - drop_pod_capabilities: capabilities dropped from the containers Pod level settings apply to all containers. Dropped capabilities and a disabled spilo_allow_privilege_escalation are filled into the Spilo, sidecar, init and logical backup containers, unless a container defines them itself or runs privileged. The cluster manifest and operator configuration are not modified. The new options are unset by default, so upgrading the operator does not change pod templates, except for setups that already disable spilo_allow_privilege_escalation: their sidecars and init containers now get allowPrivilegeEscalation: false as well. --- .../crds/operatorconfigurations.yaml | 9 ++ charts/postgres-operator/values.yaml | 9 +- docs/administrator.md | 35 +++++ docs/reference/operator_parameters.md | 27 +++- manifests/configmap.yaml | 3 + manifests/operatorconfiguration.crd.yaml | 9 ++ ...gresql-operator-default-configuration.yaml | 4 + .../v1/operator_configuration_type.go | 4 + .../v1/operatorconfiguration.crd.yaml | 9 ++ .../acid.zalan.do/v1/zz_generated.deepcopy.go | 10 ++ pkg/cluster/k8sres.go | 76 ++++++++++ pkg/cluster/k8sres_test.go | 143 ++++++++++++++++++ pkg/controller/operator_config.go | 3 + pkg/util/config/config.go | 12 ++ pkg/util/config/config_test.go | 34 +++++ 15 files changed, 384 insertions(+), 3 deletions(-) diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index 5f48d89ab..bd5ce969b 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -196,6 +196,10 @@ spec: items: type: string type: array + drop_pod_capabilities: + items: + type: string + type: array enable_cross_namespace_secret: type: boolean enable_finalizers: @@ -501,9 +505,14 @@ spec: spilo_runasgroup: format: int64 type: integer + spilo_runasnonroot: + type: boolean spilo_runasuser: format: int64 type: integer + spilo_seccomp_profile: + pattern: ^(RuntimeDefault|Unconfined|Localhost/.+)?$ + type: string storage_resize_mode: default: pvc enum: diff --git a/charts/postgres-operator/values.yaml b/charts/postgres-operator/values.yaml index 362ce829a..f4850111f 100644 --- a/charts/postgres-operator/values.yaml +++ b/charts/postgres-operator/values.yaml @@ -229,8 +229,15 @@ configKubernetes: # whether the Spilo container should run in privileged mode spilo_privileged: false # whether the Spilo container should run with additional permissions other than parent. - # required by cron which needs setuid + # required by cron which needs setuid when Spilo runs as root spilo_allow_privilege_escalation: true + # refuse to start containers of the Spilo pods running as root + # spilo_runasnonroot: true + # seccomp profile for the Spilo pods: RuntimeDefault, Unconfined or Localhost/ + # spilo_seccomp_profile: RuntimeDefault + # list of capabilities to drop from the containers of the Spilo pods + # drop_pod_capabilities: + # - "ALL" # liveness probe for the spilo pod # liveness_probe: diff --git a/docs/administrator.md b/docs/administrator.md index e2a8a78f2..95d5ec92a 100644 --- a/docs/administrator.md +++ b/docs/administrator.md @@ -472,6 +472,41 @@ avoid sudden connection issues in pods which still use credentials of these users in memory. You have to remove these child users manually or re-enable password rotation with smaller interval so they get cleaned up. +## Pod Security Standards + +To run Postgres clusters in namespaces enforcing the +[`restricted` Pod Security Standard](https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted), +configure Spilo to run as non-root and restrict the security context of all +containers of the Postgres pods, including sidecars, init containers and the +logical backup job: + +```yaml +apiVersion: "acid.zalan.do/v1" +kind: OperatorConfiguration +metadata: + name: postgresql-configuration +configuration: + kubernetes: + spilo_runasuser: 101 + spilo_runasgroup: 103 + spilo_fsgroup: 103 + spilo_runasnonroot: true + spilo_allow_privilege_escalation: false + spilo_seccomp_profile: RuntimeDefault + drop_pod_capabilities: + - ALL +``` + +`spilo_runasgroup` must be the primary group of the `postgres` user in the +Spilo image (`103`), otherwise Spilo's cron jobs (e.g. WAL-G basebackups) are +not executed. Running as non-root, cron works without privilege escalation, +only the optional `renice` job is skipped. Settings defined in the security +context of a sidecar or init container take precedence, and privileged +containers are left untouched. + +Changing these options updates the pod template of every Postgres cluster, +which triggers a rolling update of all pods. + ## Use taints and tolerations for dedicated PostgreSQL nodes To ensure Postgres pods are running on nodes without any other application pods, diff --git a/docs/reference/operator_parameters.md b/docs/reference/operator_parameters.md index 44a015cee..d29984f14 100644 --- a/docs/reference/operator_parameters.md +++ b/docs/reference/operator_parameters.md @@ -544,8 +544,12 @@ configuration they are grouped under the `kubernetes` key. * **spilo_allow_privilege_escalation** Controls whether a process can gain more privileges than its parent - process. Required by cron which needs setuid. Without this parameter, - certification rotation & backups will not be done. The default is `true`. + process. Required by cron which needs setuid when Spilo runs as root. Without + this parameter, certification rotation & backups will not be done. Spilo + running as non-root (see `spilo_runasuser`) does not need it. When set to + `false`, `allowPrivilegeEscalation: false` is also set on sidecars, init + containers and the logical backup container, unless they define it themselves + or run privileged. The default is `true`. * **additional_pod_capabilities** list of additional capabilities to be added to the postgres container's @@ -553,6 +557,25 @@ configuration they are grouped under the `kubernetes` key. PodSecruityPolicy allows the capabilities listed here. Otherwise, the container will not start. The default is empty. +* **drop_pod_capabilities** + list of capabilities to be dropped from the Spilo, sidecar, init and logical + backup containers, e.g. `ALL`. Containers that define their own dropped + capabilities or run privileged are left untouched. Capabilities listed in + `additional_pod_capabilities` are still added to the Spilo container. The + default is empty. + +* **spilo_runasnonroot** + sets `runAsNonRoot` in the pod security context of the Spilo and logical + backup pods, so the kubelet refuses to start any container running as root. + Requires a non-root `spilo_runasuser`. The default is empty (not set). + +* **spilo_seccomp_profile** + seccomp profile set in the pod security context of the Spilo and logical + backup pods, applying to all their containers unless they define their own. + Allowed values are `RuntimeDefault`, `Unconfined` and + `Localhost/`. On OpenShift the SecurityContextConstraints of + the pods must allow the profile. The default is empty (not set). + * **master_pod_move_timeout** The period of time to wait for the success of migration of master pods from an unschedulable node. The migration includes Patroni switchovers to diff --git a/manifests/configmap.yaml b/manifests/configmap.yaml index 0ee789c2b..f52616fa7 100644 --- a/manifests/configmap.yaml +++ b/manifests/configmap.yaml @@ -5,6 +5,7 @@ metadata: data: # additional_owner_roles: "cron_admin" # additional_pod_capabilities: "SYS_NICE" + # drop_pod_capabilities: "ALL" # additional_secret_mount: "some-secret-name" # additional_secret_mount_path: "/some/dir" api_port: "8080" @@ -160,6 +161,8 @@ data: # spilo_runasuser: 101 # spilo_runasgroup: 103 # spilo_fsgroup: 103 + # spilo_runasnonroot: "true" + # spilo_seccomp_profile: "RuntimeDefault" spilo_privileged: "false" storage_resize_mode: "pvc" super_username: postgres diff --git a/manifests/operatorconfiguration.crd.yaml b/manifests/operatorconfiguration.crd.yaml index 5f48d89ab..bd5ce969b 100644 --- a/manifests/operatorconfiguration.crd.yaml +++ b/manifests/operatorconfiguration.crd.yaml @@ -196,6 +196,10 @@ spec: items: type: string type: array + drop_pod_capabilities: + items: + type: string + type: array enable_cross_namespace_secret: type: boolean enable_finalizers: @@ -501,9 +505,14 @@ spec: spilo_runasgroup: format: int64 type: integer + spilo_runasnonroot: + type: boolean spilo_runasuser: format: int64 type: integer + spilo_seccomp_profile: + pattern: ^(RuntimeDefault|Unconfined|Localhost/.+)?$ + type: string storage_resize_mode: default: pvc enum: diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index 07af921f6..1610916be 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -49,6 +49,8 @@ configuration: kubernetes: # additional_pod_capabilities: # - "SYS_NICE" + # drop_pod_capabilities: + # - "ALL" cluster_domain: cluster.local cluster_labels: application: spilo @@ -124,6 +126,8 @@ configuration: # spilo_runasuser: 101 # spilo_runasgroup: 103 # spilo_fsgroup: 103 + # spilo_runasnonroot: true + # spilo_seccomp_profile: RuntimeDefault spilo_privileged: false storage_resize_mode: pvc # toleration: diff --git a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go index 5af67982e..47d3fd812 100644 --- a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go +++ b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go @@ -86,7 +86,11 @@ type KubernetesMetaConfiguration struct { SpiloRunAsGroup *int64 `json:"spilo_runasgroup,omitempty"` SpiloFSGroup *int64 `json:"spilo_fsgroup,omitempty"` AdditionalPodCapabilities []string `json:"additional_pod_capabilities,omitempty"` + DropPodCapabilities []string `json:"drop_pod_capabilities,omitempty"` + SpiloRunAsNonRoot *bool `json:"spilo_runasnonroot,omitempty"` WatchedNamespace string `json:"watched_namespace,omitempty"` + // +kubebuilder:validation:Pattern=`^(RuntimeDefault|Unconfined|Localhost/.+)?$` + SpiloSeccompProfile string `json:"spilo_seccomp_profile,omitempty"` // +kubebuilder:default="postgres-{cluster}-pdb" // defines the template for PDB names PDBNameFormat config.StringTemplate `json:"pdb_name_format,omitempty"` diff --git a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml index 5f48d89ab..bd5ce969b 100644 --- a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml +++ b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml @@ -196,6 +196,10 @@ spec: items: type: string type: array + drop_pod_capabilities: + items: + type: string + type: array enable_cross_namespace_secret: type: boolean enable_finalizers: @@ -501,9 +505,14 @@ spec: spilo_runasgroup: format: int64 type: integer + spilo_runasnonroot: + type: boolean spilo_runasuser: format: int64 type: integer + spilo_seccomp_profile: + pattern: ^(RuntimeDefault|Unconfined|Localhost/.+)?$ + type: string storage_resize_mode: default: pvc enum: diff --git a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go index 9f5e93976..cf3fee70d 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -198,6 +198,16 @@ func (in *KubernetesMetaConfiguration) DeepCopyInto(out *KubernetesMetaConfigura *out = make([]string, len(*in)) copy(*out, *in) } + if in.DropPodCapabilities != nil { + in, out := &in.DropPodCapabilities, &out.DropPodCapabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SpiloRunAsNonRoot != nil { + in, out := &in.SpiloRunAsNonRoot, &out.SpiloRunAsNonRoot + *out = new(bool) + **out = **in + } if in.PDBMasterLabelSelector != nil { in, out := &in.PDBMasterLabelSelector, &out.PDBMasterLabelSelector *out = new(bool) diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 2353e464f..71e859a4f 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -506,6 +506,74 @@ func generateCapabilities(capabilities []string) *v1.Capabilities { return nil } +// generateSeccompProfile converts the configured seccomp profile +// (RuntimeDefault, Unconfined or Localhost/) into its API representation. +func generateSeccompProfile(profile string) *v1.SeccompProfile { + switch { + case profile == "": + return nil + case strings.HasPrefix(profile, string(v1.SeccompProfileTypeLocalhost)+"/"): + localhostProfile := strings.TrimPrefix(profile, string(v1.SeccompProfileTypeLocalhost)+"/") + return &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile} + default: + return &v1.SeccompProfile{Type: v1.SeccompProfileType(profile)} + } +} + +// applyContainerSecurityDefaults fills the container security context fields +// that cannot be set on pod level (dropped capabilities and disallowed privilege +// escalation) from the operator configuration. Fields a container already sets +// are kept and privileged containers are left untouched. The containers and +// their security contexts are copied, as they may be shared with the cluster +// manifest or the operator configuration. +func (c *Cluster) applyContainerSecurityDefaults(containers []v1.Container) []v1.Container { + dropCapabilities := make([]v1.Capability, 0, len(c.OpConfig.DropPodCapabilities)) + for _, capability := range c.OpConfig.DropPodCapabilities { + dropCapabilities = append(dropCapabilities, v1.Capability(strings.ToUpper(capability))) + } + disallowPrivilegeEscalation := c.OpConfig.SpiloAllowPrivilegeEscalation != nil && !*c.OpConfig.SpiloAllowPrivilegeEscalation + + if len(dropCapabilities) == 0 && !disallowPrivilegeEscalation { + return containers + } + + result := make([]v1.Container, len(containers)) + for i, container := range containers { + result[i] = container + securityContext := &v1.SecurityContext{} + if container.SecurityContext != nil { + securityContext = container.SecurityContext.DeepCopy() + } + if securityContext.Privileged != nil && *securityContext.Privileged { + continue + } + if len(dropCapabilities) > 0 && (securityContext.Capabilities == nil || len(securityContext.Capabilities.Drop) == 0) { + if securityContext.Capabilities == nil { + securityContext.Capabilities = &v1.Capabilities{} + } + securityContext.Capabilities.Drop = slices.Clone(dropCapabilities) + } + // the API server rejects allowPrivilegeEscalation=false together with CAP_SYS_ADMIN + if disallowPrivilegeEscalation && securityContext.AllowPrivilegeEscalation == nil && !addsSysAdmin(securityContext.Capabilities) { + securityContext.AllowPrivilegeEscalation = util.False() + } + result[i].SecurityContext = securityContext + } + return result +} + +func addsSysAdmin(capabilities *v1.Capabilities) bool { + if capabilities == nil { + return false + } + for _, capability := range capabilities.Add { + if capability == "SYS_ADMIN" || capability == "CAP_SYS_ADMIN" { + return true + } + } + return false +} + func (c *Cluster) nodeAffinity(nodeReadinessLabel map[string]string, nodeAffinity *v1.NodeAffinity) *v1.Affinity { if len(nodeReadinessLabel) == 0 && nodeAffinity == nil { return nil @@ -864,6 +932,14 @@ func (c *Cluster) generatePodTemplate( securityContext.FSGroup = c.Spec.SpiloFSGroup } + // pod level settings apply to all containers, including init containers, + // unless a container overrides them in its own security context + securityContext.RunAsNonRoot = c.OpConfig.Resources.SpiloRunAsNonRoot + securityContext.SeccompProfile = generateSeccompProfile(c.OpConfig.Resources.SpiloSeccompProfile) + + containers = c.applyContainerSecurityDefaults(containers) + initContainers = c.applyContainerSecurityDefaults(initContainers) + podSpec := v1.PodSpec{ ServiceAccountName: podServiceAccountName, TerminationGracePeriodSeconds: &terminateGracePeriodSeconds, diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 3200dc2e7..eb6db3afd 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4722,3 +4722,146 @@ func TestTopologySpreadConstraints(t *testing.T) { }, ) } + +func TestSecurityContextOptions(t *testing.T) { + newSpec := func() acidv1.PostgresSpec { + return acidv1.PostgresSpec{ + PostgresqlParam: acidv1.PostgresqlParam{PgVersion: "18"}, + TeamID: "myapp", + NumberOfInstances: 1, + Volume: acidv1.Volume{Size: "1G"}, + InitContainers: []v1.Container{ + {Name: "init", Image: "init-image"}, + { + Name: "init-with-own-security-context", + Image: "init-image", + SecurityContext: &v1.SecurityContext{ + AllowPrivilegeEscalation: util.True(), + Capabilities: &v1.Capabilities{Drop: []v1.Capability{"NET_RAW"}}, + }, + }, + }, + Sidecars: []acidv1.Sidecar{{Name: "sidecar", DockerImage: "sidecar-image"}}, + } + } + newConfig := func() config.Config { + return config.Config{ + PodManagementPolicy: "ordered_ready", + ProtectedRoles: []string{"admin"}, + Auth: config.Auth{ + SuperUsername: superUserName, + ReplicationUsername: replicationUserName, + }, + Resources: config.Resources{ + SpiloAllowPrivilegeEscalation: util.True(), + }, + SidecarContainers: []v1.Container{ + { + Name: "privileged-sidecar", + Image: "sidecar-image", + SecurityContext: &v1.SecurityContext{Privileged: util.True()}, + }, + }, + } + } + containerByName := func(containers []v1.Container, name string) v1.Container { + for _, container := range containers { + if container.Name == name { + return container + } + } + t.Fatalf("container %q not found", name) + return v1.Container{} + } + + t.Run("defaults leave the security context unchanged", func(t *testing.T) { + spec := newSpec() + cluster := New(Config{OpConfig: newConfig()}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + + podSpec := sts.Spec.Template.Spec + assert.Nil(t, podSpec.SecurityContext.RunAsNonRoot) + assert.Nil(t, podSpec.SecurityContext.SeccompProfile) + assert.Nil(t, containerByName(podSpec.Containers, constants.PostgresContainerName).SecurityContext.Capabilities) + assert.Nil(t, containerByName(podSpec.Containers, "sidecar").SecurityContext) + assert.Equal(t, spec.InitContainers, podSpec.InitContainers) + }) + + t.Run("options apply to all containers including init containers", func(t *testing.T) { + spec := newSpec() + specInitContainers := []v1.Container{} + for _, container := range spec.InitContainers { + specInitContainers = append(specInitContainers, *container.DeepCopy()) + } + opConfig := newConfig() + opConfig.SpiloRunAsNonRoot = util.True() + opConfig.SpiloSeccompProfile = "RuntimeDefault" + opConfig.SpiloAllowPrivilegeEscalation = util.False() + opConfig.DropPodCapabilities = []string{"all"} + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + + podSpec := sts.Spec.Template.Spec + assert.Equal(t, util.True(), podSpec.SecurityContext.RunAsNonRoot) + assert.Equal(t, &v1.SeccompProfile{Type: v1.SeccompProfileTypeRuntimeDefault}, podSpec.SecurityContext.SeccompProfile) + + for _, container := range []v1.Container{ + containerByName(podSpec.Containers, constants.PostgresContainerName), + containerByName(podSpec.Containers, "sidecar"), + containerByName(podSpec.InitContainers, "init"), + } { + assert.Equal(t, util.False(), container.SecurityContext.AllowPrivilegeEscalation, "container %s", container.Name) + assert.Equal(t, []v1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop, "container %s", container.Name) + } + + // settings of the container itself win + initContainer := containerByName(podSpec.InitContainers, "init-with-own-security-context") + assert.Equal(t, util.True(), initContainer.SecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, []v1.Capability{"NET_RAW"}, initContainer.SecurityContext.Capabilities.Drop) + + // privileged containers are left untouched + privilegedSidecar := containerByName(podSpec.Containers, "privileged-sidecar") + assert.Equal(t, &v1.SecurityContext{Privileged: util.True()}, privilegedSidecar.SecurityContext) + + // the cluster manifest and the operator configuration are not modified + assert.Equal(t, specInitContainers, spec.InitContainers) + assert.Equal(t, &v1.SecurityContext{Privileged: util.True()}, opConfig.SidecarContainers[0].SecurityContext) + }) + + t.Run("allowPrivilegeEscalation is not disabled for containers adding SYS_ADMIN", func(t *testing.T) { + spec := newSpec() + spec.InitContainers = []v1.Container{{ + Name: "init", + Image: "init-image", + SecurityContext: &v1.SecurityContext{Capabilities: &v1.Capabilities{Add: []v1.Capability{"SYS_ADMIN"}}}, + }} + opConfig := newConfig() + opConfig.SpiloAllowPrivilegeEscalation = util.False() + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + assert.Nil(t, sts.Spec.Template.Spec.InitContainers[0].SecurityContext.AllowPrivilegeEscalation) + }) + + t.Run("options apply to the logical backup job", func(t *testing.T) { + opConfig := newConfig() + opConfig.SpiloRunAsNonRoot = util.True() + opConfig.SpiloSeccompProfile = "Localhost/profiles/postgres.json" + opConfig.SpiloAllowPrivilegeEscalation = util.False() + opConfig.DropPodCapabilities = []string{"ALL"} + opConfig.LogicalBackup = config.LogicalBackup{LogicalBackupJobPrefix: "logical-backup-", LogicalBackupSchedule: "30 00 * * *"} + cluster := New(Config{OpConfig: opConfig}, k8sutil.NewMockKubernetesClient(), acidv1.Postgresql{}, logger, eventRecorder) + cluster.Name = "acid-test-cluster" + cronJob, err := cluster.generateLogicalBackupJob() + assert.NoError(t, err) + + podSpec := cronJob.Spec.JobTemplate.Spec.Template.Spec + localhostProfile := "profiles/postgres.json" + assert.Equal(t, util.True(), podSpec.SecurityContext.RunAsNonRoot) + assert.Equal(t, &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile}, podSpec.SecurityContext.SeccompProfile) + assert.Equal(t, util.False(), podSpec.Containers[0].SecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, []v1.Capability{"ALL"}, podSpec.Containers[0].SecurityContext.Capabilities.Drop) + }) +} diff --git a/pkg/controller/operator_config.go b/pkg/controller/operator_config.go index 4c683b637..52d6491e7 100644 --- a/pkg/controller/operator_config.go +++ b/pkg/controller/operator_config.go @@ -81,6 +81,9 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur result.SpiloRunAsGroup = fromCRD.Kubernetes.SpiloRunAsGroup result.SpiloFSGroup = fromCRD.Kubernetes.SpiloFSGroup result.AdditionalPodCapabilities = fromCRD.Kubernetes.AdditionalPodCapabilities + result.DropPodCapabilities = fromCRD.Kubernetes.DropPodCapabilities + result.SpiloRunAsNonRoot = fromCRD.Kubernetes.SpiloRunAsNonRoot + result.SpiloSeccompProfile = fromCRD.Kubernetes.SpiloSeccompProfile result.ClusterDomain = util.Coalesce(fromCRD.Kubernetes.ClusterDomain, "cluster.local") result.WatchedNamespace = fromCRD.Kubernetes.WatchedNamespace result.PDBNameFormat = fromCRD.Kubernetes.PDBNameFormat diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index 718b5ba23..d1b74a0bd 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "regexp" "strings" "fmt" @@ -12,6 +13,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// seccompProfileRegexp matches the supported values of spilo_seccomp_profile +var seccompProfileRegexp = regexp.MustCompile(`^(RuntimeDefault|Unconfined|Localhost/.+)?$`) + // CRD describes CustomResourceDefinition specific configuration parameters type CRD struct { ReadyWaitInterval *metav1.Duration `name:"ready_wait_interval" default:"4s"` @@ -39,6 +43,9 @@ type Resources struct { SpiloPrivileged bool `name:"spilo_privileged" default:"false"` SpiloAllowPrivilegeEscalation *bool `name:"spilo_allow_privilege_escalation" default:"true"` AdditionalPodCapabilities []string `name:"additional_pod_capabilities" default:""` + DropPodCapabilities []string `name:"drop_pod_capabilities" default:""` + SpiloRunAsNonRoot *bool `name:"spilo_runasnonroot"` + SpiloSeccompProfile string `name:"spilo_seccomp_profile"` ClusterLabels map[string]string `name:"cluster_labels" default:"application:spilo"` InheritedLabels []string `name:"inherited_labels" default:""` InheritedAnnotations []string `name:"inherited_annotations" default:""` @@ -333,5 +340,10 @@ func validate(cfg *Config) (err error) { err = fmt.Errorf(msg, cfg.ConnectionPooler.User) } + if !seccompProfileRegexp.MatchString(cfg.SpiloSeccompProfile) { + err = fmt.Errorf("spilo_seccomp_profile %q is invalid, expected RuntimeDefault, Unconfined or Localhost/", + cfg.SpiloSeccompProfile) + } + return } diff --git a/pkg/util/config/config_test.go b/pkg/util/config/config_test.go index 6373fdc9a..56220f84a 100644 --- a/pkg/util/config/config_test.go +++ b/pkg/util/config/config_test.go @@ -335,3 +335,37 @@ func TestNewFromMap(t *testing.T) { }) } } + +func TestSecurityContextOptionsFromMap(t *testing.T) { + cfg := NewFromMap(map[string]string{}) + if cfg.SpiloRunAsNonRoot != nil || cfg.SpiloSeccompProfile != "" || len(cfg.DropPodCapabilities) != 0 { + t.Errorf("expected security context options to be unset by default, got %v %q %v", + cfg.SpiloRunAsNonRoot, cfg.SpiloSeccompProfile, cfg.DropPodCapabilities) + } + + cfg = NewFromMap(map[string]string{ + "spilo_runasnonroot": "true", + "spilo_seccomp_profile": "Localhost/profiles/postgres.json", + "drop_pod_capabilities": "ALL", + }) + if cfg.SpiloRunAsNonRoot == nil || !*cfg.SpiloRunAsNonRoot { + t.Errorf("expected spilo_runasnonroot to be true") + } + if cfg.SpiloSeccompProfile != "Localhost/profiles/postgres.json" { + t.Errorf("unexpected spilo_seccomp_profile %q", cfg.SpiloSeccompProfile) + } + if !reflect.DeepEqual(cfg.DropPodCapabilities, []string{"ALL"}) { + t.Errorf("unexpected drop_pod_capabilities %v", cfg.DropPodCapabilities) + } + + for _, profile := range []string{"RuntimeDefault", "Unconfined", "Localhost/x"} { + NewFromMap(map[string]string{"spilo_seccomp_profile": profile}) + } + + defer func() { + if r := recover(); r == nil || !strings.Contains(fmt.Sprint(r), "spilo_seccomp_profile") { + t.Errorf("expected panic for invalid spilo_seccomp_profile, got %v", r) + } + }() + NewFromMap(map[string]string{"spilo_seccomp_profile": "runtime/default"}) +} From 4e566fc3aed7714991adc2400bd549c447a943a3 Mon Sep 17 00:00:00 2001 From: Yves Galante Date: Sat, 7 Nov 2026 07:26:24 +0100 Subject: [PATCH 2/3] Use a single parser for spilo_seccomp_profile Replace the validation regexp and the pod template conversion with one config.SeccompProfile function used by both, so the accepted values are defined in one place and an invalid value can no longer be turned into an arbitrary profile type. Generating the pod template fails on an invalid value instead. --- pkg/cluster/k8sres.go | 20 +++++------------- pkg/cluster/k8sres_test.go | 9 +++++++++ pkg/util/config/config.go | 25 +++++++++++++++++------ pkg/util/config/config_test.go | 37 ++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 71e859a4f..8eb2189f4 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -506,20 +506,6 @@ func generateCapabilities(capabilities []string) *v1.Capabilities { return nil } -// generateSeccompProfile converts the configured seccomp profile -// (RuntimeDefault, Unconfined or Localhost/) into its API representation. -func generateSeccompProfile(profile string) *v1.SeccompProfile { - switch { - case profile == "": - return nil - case strings.HasPrefix(profile, string(v1.SeccompProfileTypeLocalhost)+"/"): - localhostProfile := strings.TrimPrefix(profile, string(v1.SeccompProfileTypeLocalhost)+"/") - return &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile} - default: - return &v1.SeccompProfile{Type: v1.SeccompProfileType(profile)} - } -} - // applyContainerSecurityDefaults fills the container security context fields // that cannot be set on pod level (dropped capabilities and disallowed privilege // escalation) from the operator configuration. Fields a container already sets @@ -935,7 +921,11 @@ func (c *Cluster) generatePodTemplate( // pod level settings apply to all containers, including init containers, // unless a container overrides them in its own security context securityContext.RunAsNonRoot = c.OpConfig.Resources.SpiloRunAsNonRoot - securityContext.SeccompProfile = generateSeccompProfile(c.OpConfig.Resources.SpiloSeccompProfile) + seccompProfile, err := config.SeccompProfile(c.OpConfig.Resources.SpiloSeccompProfile) + if err != nil { + return nil, fmt.Errorf("could not generate pod security context: spilo_seccomp_profile: %v", err) + } + securityContext.SeccompProfile = seccompProfile containers = c.applyContainerSecurityDefaults(containers) initContainers = c.applyContainerSecurityDefaults(initContainers) diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index eb6db3afd..934ffd8fc 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4845,6 +4845,15 @@ func TestSecurityContextOptions(t *testing.T) { assert.Nil(t, sts.Spec.Template.Spec.InitContainers[0].SecurityContext.AllowPrivilegeEscalation) }) + t.Run("invalid seccomp profile fails the statefulset generation", func(t *testing.T) { + spec := newSpec() + opConfig := newConfig() + opConfig.SpiloSeccompProfile = "runtime/default" + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + _, err := cluster.generateStatefulSet(&spec) + assert.ErrorContains(t, err, "spilo_seccomp_profile") + }) + t.Run("options apply to the logical backup job", func(t *testing.T) { opConfig := newConfig() opConfig.SpiloRunAsNonRoot = util.True() diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index d1b74a0bd..72876a9b2 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -2,7 +2,6 @@ package config import ( "encoding/json" - "regexp" "strings" "fmt" @@ -13,8 +12,23 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// seccompProfileRegexp matches the supported values of spilo_seccomp_profile -var seccompProfileRegexp = regexp.MustCompile(`^(RuntimeDefault|Unconfined|Localhost/.+)?$`) +// SeccompProfile converts a seccomp profile option (RuntimeDefault, Unconfined +// or Localhost/) into its API representation. An empty value means no +// profile is set. +func SeccompProfile(profile string) (*v1.SeccompProfile, error) { + localhostPrefix := string(v1.SeccompProfileTypeLocalhost) + "/" + switch { + case profile == "": + return nil, nil + case profile == string(v1.SeccompProfileTypeRuntimeDefault), profile == string(v1.SeccompProfileTypeUnconfined): + return &v1.SeccompProfile{Type: v1.SeccompProfileType(profile)}, nil + case strings.HasPrefix(profile, localhostPrefix) && len(profile) > len(localhostPrefix): + localhostProfile := strings.TrimPrefix(profile, localhostPrefix) + return &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile}, nil + default: + return nil, fmt.Errorf("invalid seccomp profile %q, expected RuntimeDefault, Unconfined or Localhost/", profile) + } +} // CRD describes CustomResourceDefinition specific configuration parameters type CRD struct { @@ -340,9 +354,8 @@ func validate(cfg *Config) (err error) { err = fmt.Errorf(msg, cfg.ConnectionPooler.User) } - if !seccompProfileRegexp.MatchString(cfg.SpiloSeccompProfile) { - err = fmt.Errorf("spilo_seccomp_profile %q is invalid, expected RuntimeDefault, Unconfined or Localhost/", - cfg.SpiloSeccompProfile) + if _, seccompErr := SeccompProfile(cfg.SpiloSeccompProfile); seccompErr != nil { + err = fmt.Errorf("spilo_seccomp_profile: %v", seccompErr) } return diff --git a/pkg/util/config/config_test.go b/pkg/util/config/config_test.go index 56220f84a..e1ec85528 100644 --- a/pkg/util/config/config_test.go +++ b/pkg/util/config/config_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + v1 "k8s.io/api/core/v1" ) func TestMain(m *testing.M) { @@ -369,3 +371,38 @@ func TestSecurityContextOptionsFromMap(t *testing.T) { }() NewFromMap(map[string]string{"spilo_seccomp_profile": "runtime/default"}) } + +func TestSeccompProfile(t *testing.T) { + localhostProfile := "profiles/postgres.json" + for _, tt := range []struct { + profile string + expected *v1.SeccompProfile + wantErr bool + }{ + {profile: "", expected: nil}, + {profile: "RuntimeDefault", expected: &v1.SeccompProfile{Type: v1.SeccompProfileTypeRuntimeDefault}}, + {profile: "Unconfined", expected: &v1.SeccompProfile{Type: v1.SeccompProfileTypeUnconfined}}, + {profile: "Localhost/profiles/postgres.json", expected: &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile}}, + {profile: "Localhost/", wantErr: true}, + {profile: "Localhost", wantErr: true}, + {profile: "runtime/default", wantErr: true}, + {profile: "runtimedefault", wantErr: true}, + {profile: "RuntimeDefault ", wantErr: true}, + } { + t.Run(tt.profile, func(t *testing.T) { + got, err := SeccompProfile(tt.profile) + if tt.wantErr { + if err == nil { + t.Errorf("expected an error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, got) + } + }) + } +} From baddc813f85150763243cf5bbb3b11fc74bd6e37 Mon Sep 17 00:00:00 2001 From: Yves Galante Date: Sat, 7 Nov 2026 07:52:25 +0100 Subject: [PATCH 3/3] Rename to spilo_run_as_non_root and set runAsNonRoot on containers Align the option name with the one proposed in #3209. runAsNonRoot is now also filled into the Spilo, sidecar, init and logical backup containers, unless they define it themselves or run privileged. Admission webhooks enforcing runAsNonRoot (e.g. Kyverno) inject it on container level too; with only the pod field set, the generated StatefulSet kept differing from the mutated one and the operator rolled the cluster on every sync (#3210). --- .../crds/operatorconfigurations.yaml | 4 ++-- charts/postgres-operator/values.yaml | 2 +- docs/administrator.md | 10 +++++++-- docs/reference/operator_parameters.md | 10 +++++++-- manifests/configmap.yaml | 2 +- manifests/operatorconfiguration.crd.yaml | 4 ++-- ...gresql-operator-default-configuration.yaml | 2 +- .../v1/operator_configuration_type.go | 2 +- .../v1/operatorconfiguration.crd.yaml | 4 ++-- pkg/cluster/k8sres.go | 19 +++++++++++------ pkg/cluster/k8sres_test.go | 21 +++++++++++++++++++ pkg/util/config/config.go | 2 +- pkg/util/config/config_test.go | 4 ++-- 13 files changed, 63 insertions(+), 23 deletions(-) diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index bd5ce969b..9b8b752e6 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -502,11 +502,11 @@ spec: type: integer spilo_privileged: type: boolean + spilo_run_as_non_root: + type: boolean spilo_runasgroup: format: int64 type: integer - spilo_runasnonroot: - type: boolean spilo_runasuser: format: int64 type: integer diff --git a/charts/postgres-operator/values.yaml b/charts/postgres-operator/values.yaml index f4850111f..53b7bb5c1 100644 --- a/charts/postgres-operator/values.yaml +++ b/charts/postgres-operator/values.yaml @@ -232,7 +232,7 @@ configKubernetes: # required by cron which needs setuid when Spilo runs as root spilo_allow_privilege_escalation: true # refuse to start containers of the Spilo pods running as root - # spilo_runasnonroot: true + # spilo_run_as_non_root: true # seccomp profile for the Spilo pods: RuntimeDefault, Unconfined or Localhost/ # spilo_seccomp_profile: RuntimeDefault # list of capabilities to drop from the containers of the Spilo pods diff --git a/docs/administrator.md b/docs/administrator.md index 95d5ec92a..be5ed672a 100644 --- a/docs/administrator.md +++ b/docs/administrator.md @@ -490,7 +490,7 @@ configuration: spilo_runasuser: 101 spilo_runasgroup: 103 spilo_fsgroup: 103 - spilo_runasnonroot: true + spilo_run_as_non_root: true spilo_allow_privilege_escalation: false spilo_seccomp_profile: RuntimeDefault drop_pod_capabilities: @@ -502,7 +502,13 @@ Spilo image (`103`), otherwise Spilo's cron jobs (e.g. WAL-G basebackups) are not executed. Running as non-root, cron works without privilege escalation, only the optional `renice` job is skipped. Settings defined in the security context of a sidecar or init container take precedence, and privileged -containers are left untouched. +containers are left untouched. With `spilo_run_as_non_root` enabled, clusters +must not override `spiloRunAsUser` with `0` in their manifest. + +When these fields are enforced by mutating admission webhooks (e.g. Kyverno), +configure the same values in the operator. Otherwise the StatefulSet generated +by the operator differs from the mutated one and the operator triggers a +rolling update of the cluster on every sync. Changing these options updates the pod template of every Postgres cluster, which triggers a rolling update of all pods. diff --git a/docs/reference/operator_parameters.md b/docs/reference/operator_parameters.md index d29984f14..f75dc23c9 100644 --- a/docs/reference/operator_parameters.md +++ b/docs/reference/operator_parameters.md @@ -564,10 +564,16 @@ configuration they are grouped under the `kubernetes` key. `additional_pod_capabilities` are still added to the Spilo container. The default is empty. -* **spilo_runasnonroot** +* **spilo_run_as_non_root** sets `runAsNonRoot` in the pod security context of the Spilo and logical backup pods, so the kubelet refuses to start any container running as root. - Requires a non-root `spilo_runasuser`. The default is empty (not set). + It is also set in the security context of the Spilo, sidecar, init and logical + backup containers, unless they define it themselves or run privileged. This + keeps the generated pod template in sync with admission webhooks (e.g. + Kyverno) enforcing `runAsNonRoot`, which would otherwise trigger a rolling + update on every sync. Requires a non-root `spilo_runasuser`: a cluster + manifest setting `spiloRunAsUser: 0` will fail to start its pods. The default + is empty (not set). * **spilo_seccomp_profile** seccomp profile set in the pod security context of the Spilo and logical diff --git a/manifests/configmap.yaml b/manifests/configmap.yaml index f52616fa7..833bf99cd 100644 --- a/manifests/configmap.yaml +++ b/manifests/configmap.yaml @@ -161,7 +161,7 @@ data: # spilo_runasuser: 101 # spilo_runasgroup: 103 # spilo_fsgroup: 103 - # spilo_runasnonroot: "true" + # spilo_run_as_non_root: "true" # spilo_seccomp_profile: "RuntimeDefault" spilo_privileged: "false" storage_resize_mode: "pvc" diff --git a/manifests/operatorconfiguration.crd.yaml b/manifests/operatorconfiguration.crd.yaml index bd5ce969b..9b8b752e6 100644 --- a/manifests/operatorconfiguration.crd.yaml +++ b/manifests/operatorconfiguration.crd.yaml @@ -502,11 +502,11 @@ spec: type: integer spilo_privileged: type: boolean + spilo_run_as_non_root: + type: boolean spilo_runasgroup: format: int64 type: integer - spilo_runasnonroot: - type: boolean spilo_runasuser: format: int64 type: integer diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index 1610916be..f4696089e 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -126,7 +126,7 @@ configuration: # spilo_runasuser: 101 # spilo_runasgroup: 103 # spilo_fsgroup: 103 - # spilo_runasnonroot: true + # spilo_run_as_non_root: true # spilo_seccomp_profile: RuntimeDefault spilo_privileged: false storage_resize_mode: pvc diff --git a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go index 47d3fd812..a9683468b 100644 --- a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go +++ b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go @@ -87,7 +87,7 @@ type KubernetesMetaConfiguration struct { SpiloFSGroup *int64 `json:"spilo_fsgroup,omitempty"` AdditionalPodCapabilities []string `json:"additional_pod_capabilities,omitempty"` DropPodCapabilities []string `json:"drop_pod_capabilities,omitempty"` - SpiloRunAsNonRoot *bool `json:"spilo_runasnonroot,omitempty"` + SpiloRunAsNonRoot *bool `json:"spilo_run_as_non_root,omitempty"` WatchedNamespace string `json:"watched_namespace,omitempty"` // +kubebuilder:validation:Pattern=`^(RuntimeDefault|Unconfined|Localhost/.+)?$` SpiloSeccompProfile string `json:"spilo_seccomp_profile,omitempty"` diff --git a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml index bd5ce969b..9b8b752e6 100644 --- a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml +++ b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml @@ -502,11 +502,11 @@ spec: type: integer spilo_privileged: type: boolean + spilo_run_as_non_root: + type: boolean spilo_runasgroup: format: int64 type: integer - spilo_runasnonroot: - type: boolean spilo_runasuser: format: int64 type: integer diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 8eb2189f4..0f1dab428 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -507,19 +507,22 @@ func generateCapabilities(capabilities []string) *v1.Capabilities { } // applyContainerSecurityDefaults fills the container security context fields -// that cannot be set on pod level (dropped capabilities and disallowed privilege -// escalation) from the operator configuration. Fields a container already sets -// are kept and privileged containers are left untouched. The containers and -// their security contexts are copied, as they may be shared with the cluster -// manifest or the operator configuration. +// from the operator configuration: dropped capabilities and disallowed privilege +// escalation, which cannot be set on pod level, and runAsNonRoot, which is also +// set on pod level but repeated on the containers so the generated pod template +// matches what admission webhooks enforcing it inject. Fields a container +// already sets are kept and privileged containers are left untouched. The +// containers and their security contexts are copied, as they may be shared with +// the cluster manifest or the operator configuration. func (c *Cluster) applyContainerSecurityDefaults(containers []v1.Container) []v1.Container { dropCapabilities := make([]v1.Capability, 0, len(c.OpConfig.DropPodCapabilities)) for _, capability := range c.OpConfig.DropPodCapabilities { dropCapabilities = append(dropCapabilities, v1.Capability(strings.ToUpper(capability))) } disallowPrivilegeEscalation := c.OpConfig.SpiloAllowPrivilegeEscalation != nil && !*c.OpConfig.SpiloAllowPrivilegeEscalation + runAsNonRoot := c.OpConfig.SpiloRunAsNonRoot - if len(dropCapabilities) == 0 && !disallowPrivilegeEscalation { + if len(dropCapabilities) == 0 && !disallowPrivilegeEscalation && runAsNonRoot == nil { return containers } @@ -543,6 +546,10 @@ func (c *Cluster) applyContainerSecurityDefaults(containers []v1.Container) []v1 if disallowPrivilegeEscalation && securityContext.AllowPrivilegeEscalation == nil && !addsSysAdmin(securityContext.Capabilities) { securityContext.AllowPrivilegeEscalation = util.False() } + if runAsNonRoot != nil && securityContext.RunAsNonRoot == nil { + securityContext.RunAsNonRoot = new(bool) + *securityContext.RunAsNonRoot = *runAsNonRoot + } result[i].SecurityContext = securityContext } return result diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 934ffd8fc..6b330330b 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4738,6 +4738,7 @@ func TestSecurityContextOptions(t *testing.T) { SecurityContext: &v1.SecurityContext{ AllowPrivilegeEscalation: util.True(), Capabilities: &v1.Capabilities{Drop: []v1.Capability{"NET_RAW"}}, + RunAsNonRoot: util.False(), }, }, }, @@ -4784,6 +4785,7 @@ func TestSecurityContextOptions(t *testing.T) { assert.Nil(t, podSpec.SecurityContext.RunAsNonRoot) assert.Nil(t, podSpec.SecurityContext.SeccompProfile) assert.Nil(t, containerByName(podSpec.Containers, constants.PostgresContainerName).SecurityContext.Capabilities) + assert.Nil(t, containerByName(podSpec.Containers, constants.PostgresContainerName).SecurityContext.RunAsNonRoot) assert.Nil(t, containerByName(podSpec.Containers, "sidecar").SecurityContext) assert.Equal(t, spec.InitContainers, podSpec.InitContainers) }) @@ -4814,12 +4816,14 @@ func TestSecurityContextOptions(t *testing.T) { } { assert.Equal(t, util.False(), container.SecurityContext.AllowPrivilegeEscalation, "container %s", container.Name) assert.Equal(t, []v1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop, "container %s", container.Name) + assert.Equal(t, util.True(), container.SecurityContext.RunAsNonRoot, "container %s", container.Name) } // settings of the container itself win initContainer := containerByName(podSpec.InitContainers, "init-with-own-security-context") assert.Equal(t, util.True(), initContainer.SecurityContext.AllowPrivilegeEscalation) assert.Equal(t, []v1.Capability{"NET_RAW"}, initContainer.SecurityContext.Capabilities.Drop) + assert.Equal(t, util.False(), initContainer.SecurityContext.RunAsNonRoot) // privileged containers are left untouched privilegedSidecar := containerByName(podSpec.Containers, "privileged-sidecar") @@ -4872,5 +4876,22 @@ func TestSecurityContextOptions(t *testing.T) { assert.Equal(t, &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile}, podSpec.SecurityContext.SeccompProfile) assert.Equal(t, util.False(), podSpec.Containers[0].SecurityContext.AllowPrivilegeEscalation) assert.Equal(t, []v1.Capability{"ALL"}, podSpec.Containers[0].SecurityContext.Capabilities.Drop) + assert.Equal(t, util.True(), podSpec.Containers[0].SecurityContext.RunAsNonRoot) + }) + + t.Run("runAsNonRoot alone is set on pod and container level", func(t *testing.T) { + spec := newSpec() + opConfig := newConfig() + opConfig.SpiloRunAsNonRoot = util.True() + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + + podSpec := sts.Spec.Template.Spec + assert.Equal(t, util.True(), podSpec.SecurityContext.RunAsNonRoot) + spiloContainer := containerByName(podSpec.Containers, constants.PostgresContainerName) + assert.Equal(t, util.True(), spiloContainer.SecurityContext.RunAsNonRoot) + assert.Nil(t, spiloContainer.SecurityContext.Capabilities) + assert.Equal(t, util.True(), spiloContainer.SecurityContext.AllowPrivilegeEscalation) }) } diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index 72876a9b2..39b8b4956 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -58,7 +58,7 @@ type Resources struct { SpiloAllowPrivilegeEscalation *bool `name:"spilo_allow_privilege_escalation" default:"true"` AdditionalPodCapabilities []string `name:"additional_pod_capabilities" default:""` DropPodCapabilities []string `name:"drop_pod_capabilities" default:""` - SpiloRunAsNonRoot *bool `name:"spilo_runasnonroot"` + SpiloRunAsNonRoot *bool `name:"spilo_run_as_non_root"` SpiloSeccompProfile string `name:"spilo_seccomp_profile"` ClusterLabels map[string]string `name:"cluster_labels" default:"application:spilo"` InheritedLabels []string `name:"inherited_labels" default:""` diff --git a/pkg/util/config/config_test.go b/pkg/util/config/config_test.go index e1ec85528..0c82099bd 100644 --- a/pkg/util/config/config_test.go +++ b/pkg/util/config/config_test.go @@ -346,12 +346,12 @@ func TestSecurityContextOptionsFromMap(t *testing.T) { } cfg = NewFromMap(map[string]string{ - "spilo_runasnonroot": "true", + "spilo_run_as_non_root": "true", "spilo_seccomp_profile": "Localhost/profiles/postgres.json", "drop_pod_capabilities": "ALL", }) if cfg.SpiloRunAsNonRoot == nil || !*cfg.SpiloRunAsNonRoot { - t.Errorf("expected spilo_runasnonroot to be true") + t.Errorf("expected spilo_run_as_non_root to be true") } if cfg.SpiloSeccompProfile != "Localhost/profiles/postgres.json" { t.Errorf("unexpected spilo_seccomp_profile %q", cfg.SpiloSeccompProfile)