Add configurable pod and container securityContext, including readOnlyRootFilesystem support - #715
Add configurable pod and container securityContext, including readOnlyRootFilesystem support#715mouchar wants to merge 2 commits into
Conversation
lhotari
left a comment
There was a problem hiding this comment.
Thanks for this — it's careful work and the parts that are right are very right. I verified your central compatibility claim and it holds exactly: with default values the rendered output differs from master in precisely the three items you list, 43 diff lines total. With oxia and pulsar_manager enabled it's byte-identical. kubeconform 1.31 strict passes for both the default render and values-restricted-psp.yaml. The mergeOverwrite + deepCopy choice is correct, and the reasoning in the helper comment about merge discarding zero values is right.
Commit 1 (the securityContext cascade) looks close to mergeable on its own. The issues are in commit 2's edge cases and in the docs.
Blocking
Four issues, all inline: a --reuse-values render failure, and three cases where readOnlyRootFilesystem produces a pod that can't start. The first one is the most urgent since it breaks upgrades for everyone who uses that flag, regardless of whether they touch the new feature.
The fsGroup / GID 0 framing — please reconsider
This is the part I'd most like changed, because I think the example teaches the wrong lesson, and it's the reason fsGroup: 0 is the chart default in the first place.
fsGroup: 0 is not a privilege. Group 0 inside a container is an ordinary group; root power comes from UID 0 and capabilities. It does grant DAC access to group-0-accessible files — which is exactly the mechanism that makes the Pulsar image work under an arbitrary UID — but it is not "running as root".
Concretely: the Kubernetes restricted Pod Security Standard places no constraint on fsGroup, fsGroupChangePolicy or supplementalGroups at all. Its controls are runAsNonRoot, runAsUser != 0, allowPrivilegeEscalation, capabilities, seccompProfile, and the host/volume rules. The PSS-restricted policy sets that Gatekeeper and Kyverno ship mirror PSA and likewise don't restrict fsGroup — both engines can via separately installed policy, but nothing does out of the box.
So the four fsGroup: 10000 overrides in examples/values-restricted-psp.yaml aren't needed for the file's stated purpose, and they're the single most operationally dangerous lines in it — a recursive chown of every bookie ledger volume, which your own warning comment at the top of the file describes.
The background, from Red Hat's UID guide: "the user in the Container always has GID=0, which is the root group", and Red Hat's recommendation is that writable files "should be owned by the root group and be read/writable by GID=0". That's why the chart defaults to fsGroup: 0.
Worth knowing though — I went and checked, and on a stock OpenShift project neither value is admissible. From OpenShift's shipped manifest (openshift/cluster-kube-apiserver-operator, bindata/bootkube/scc-manifests/…_00_scc-restricted-v2.yaml), restricted-v2 is:
runAsUser: {type: MustRunAsRange}
fsGroup: {type: MustRunAs}
supplementalGroups: {type: RunAsAny}
allowPrivilegeEscalation: false
requiredDropCapabilities: [ALL]
seccompProfiles: [runtime/default]and the MustRunAs implementation (openshift/apiserver-library-go, pkg/securitycontextconstraints/group/mustrunas.go) validates any pod-supplied group against the namespace's preallocated range, rejecting with "%d is not an allowed group". On a typical range (~1000620000/10000): fsGroup: 10000 rejected, fsGroup: 0 rejected, runAsUser: 10000 rejected. Omitting them lets admission inject correct values. (fsGroup: 0 works on OpenShift today under anyuid or a custom SCC, which use fsGroup: RunAsAny.)
Nice result worth calling out: your containerSecurityContext block matches restricted-v2 field for field. That half of the example is exactly right.
Suggested changes:
- Keep the component
fsGroup: 0defaults as they are. - Keep the example's
containerSecurityContextblock verbatim. - Drop
runAsUser/runAsGroup/fsGroup/supplementalGroupsfrom the example'spodSecurityContext, keepingrunAsNonRoot: true, and delete the four per-componentfsGroup: 10000blocks. - Retitle the README's "Moving off GID 0" — it reads as a hardening step, which it isn't — and note that PSA
restricteddoesn't constrain fsGroup. - If you want an OpenShift example, it needs to be a separate file that clears the defaults (
zookeeper: {securityContext: null}etc.), because simply removing the overrides restoresfsGroup: 0, whichrestricted-v2also rejects.
Non-blocking
- User-supplied
<component>.initContainersare rawtoYamlpassthrough and never merged withcontainerSecurityContext— same foroxia.coordinator.extraContainers(oxia-coordinator-deployment.yaml:93) anddekaf…extraContainers(dekaf-deployment.yaml:105). The README says the settings apply to "every container and initContainer the chart renders". Either narrow the wording or merge them. autorecovery.probe.liveness.enabled: trueadds a default-on livenessProbe to a component that previously had none, targeting an endpoint that comes from the image's BookKeeper stats defaults rather than anything the chart sets. A user who changesstatsProviderClass/prometheusStatsHttpPortviaautorecovery.configDataturns a working deployment into a restart loop, where that config was previously inert. Defaulting livenessfalseand readinesstruewould be the safer compatibility choice.- "No functional effect" for the three Jobs inheriting
fsGroup: 0is true for volume ownership but not for admission —fsGroupis an SCC input on OpenShift. Narrow, but the claim as written is incomplete.
Reviewed with Codex gpt-5.6-sol and Claude Opus 5; every finding reproduced locally by rendering the chart, validating with kubeconform -strict, and checking OpenShift's shipped SCC manifests.
| @@ -158,6 +170,43 @@ spec: | |||
| - name: "{{ template "pulsar.fullname" . }}-{{ .Values.autorecovery.component }}" | |||
| image: "{{ template "pulsar.imageFullName" (dict "image" .Values.images.autorecovery "root" .) }}" | |||
| imagePullPolicy: "{{ template "pulsar.imagePullPolicy" (dict "image" .Values.images.autorecovery "root" .) }}" | |||
There was a problem hiding this comment.
This breaks helm upgrade --reuse-values for every existing release, whether or not they use the new feature.
.Values.autorecovery.probe.liveness.enabled is dereferenced unconditionally here (same for .readiness and .startup below), but autorecovery.probe is a brand-new values key. --reuse-values reuses the previous release's computed values without coalescing in the new chart's defaults, so the map is simply absent:
$ helm template test charts/pulsar --set 'autorecovery.probe=null' \
-s templates/autorecovery-statefulset.yaml
Error: template: .../autorecovery-statefulset.yaml:180:22: executing ... at
<.Values.autorecovery.probe.liveness.enabled>: nil pointer evaluating interface {}.liveness
Rendering aborts before a single manifest reaches the API server.
writableRootfsVolumes has the same exposure from the other direction: under --reuse-values it's absent, and pulsar.rootfs.enabled tests it by truthiness, so "absent" is indistinguishable from an explicit false. Someone upgrading with --reuse-values --set containerSecurityContext.readOnlyRootFilesystem=true would get read-only roots with no seed container and no mounts.
Guarding with dig or hasKey/default throughout would fix both, e.g.:
{{- if (dig "probe" "liveness" "enabled" false .Values.autorecovery) }}
| {{ toYaml .Values.bookkeeper.securityContext | indent 8 }} | ||
| {{- end }} | ||
| {{- include "pulsar.podSecurityContext" (dict "securityContext" .Values.bookkeeper.securityContext "root" . "indent" 6) }} | ||
| {{- if and .Values.bookkeeper.waitMetadataTimeout (gt (.Values.bookkeeper.waitMetadataTimeout | int) 0) }} |
There was a problem hiding this comment.
The entire initContainers: block — including copy-pulsar-conf two lines below — is inside this waitMetadataTimeout > 0 guard, but the rootfs volumes and the main container's mounts are not. So with waitMetadataTimeout: 0 the bookie gets an empty emptyDir mounted over /pulsar/conf and nothing to seed it:
$ helm template test charts/pulsar \
--set containerSecurityContext.readOnlyRootFilesystem=true \
--set bookkeeper.waitMetadataTimeout=0
renders the bookie with no initContainers at all, while the main container still mounts /pulsar/conf, /pulsar/logs and /tmp and all three emptyDir volumes are present. The empty conf volume shadows the image's copy, so apply-config-from-env.py conf/bookkeeper.conf has no file to edit and the bookie never starts.
The seed container needs to render whenever pulsar.rootfs.enabled is true, independently of waitMetadataTimeout — which probably means hoisting initContainers: out of this guard and emitting it when either condition holds.
| - name: "{{ template "pulsar.fullname" . }}-{{ .Values.pulsar_manager.component }}-init" | ||
| image: "{{ template "pulsar.imageFullName" (dict "image" .Values.pulsar_metadata.image "root" .) }}" | ||
| imagePullPolicy: "{{ template "pulsar.imagePullPolicy" (dict "image" .Values.pulsar_metadata.image "root" .) }}" | ||
| {{- include "pulsar.containerSecurityContext" (dict "securityContext" .Values.pulsar_manager.containerSecurityContext "root" . "indent" 10) }} |
There was a problem hiding this comment.
This Job breaks under a global readOnlyRootFilesystem: true. Rendered with --set containerSecurityContext.readOnlyRootFilesystem=true --set components.pulsar_manager=true, the container gets readOnlyRootFilesystem: true, image apachepulsar/pulsar-all, and no volumeMounts at all — while the script below does cd /tmp and then curl -sS -D headers.txt, later grepping headers.txt for the token and JSESSIONID. curl can't create the file, so manager initialization never completes.
This also means the README's "Components that do not run a Pulsar image — oxia, dekaf, pulsar_manager — are not given these volumes" is inaccurate: this Job does run a Pulsar image, and it does write to /tmp.
Either give it the rootfs volumes like the other Pulsar-image workloads, or exclude it from the container security context.
| type: RuntimeDefault | ||
|
|
||
| # zookeeper, bookkeeper, broker and oxia.server ship `securityContext.fsGroup: 0`, | ||
| # and a per-component value takes precedence over the global one, so each has to be |
There was a problem hiding this comment.
These four overrides are the part I'd most like to see removed — see the main review comment for the full reasoning.
Short version: PSA restricted (and the PSS-restricted policy sets that Gatekeeper and Kyverno ship) place no constraint on fsGroup at all, so nothing in this file's stated target requires moving off GID 0. Meanwhile these four lines are the most operationally dangerous thing in the example — they trigger a recursive chown of every bookie ledger volume, exactly as your warning at the top of the file describes.
And fsGroup: 0 is deliberate: on OpenShift the container user always has GID 0, so group-0 ownership is what makes volumes writable under an arbitrary assigned UID. Moving off it is not a hardening step.
Suggest deleting these four blocks and the runAsUser/runAsGroup/fsGroup/supplementalGroups lines from podSecurityContext above, keeping runAsNonRoot: true and the whole containerSecurityContext block — those are the controls restricted actually enforces.
| fsGroupChangePolicy: OnRootMismatch | ||
| ``` | ||
|
|
||
| Set `runAsGroup` to the same GID as `fsGroup`, or the process loses write access to its |
There was a problem hiding this comment.
This isn't right, and it's repeated in four places in values.yaml too.
fsGroup is applied as a supplementary group to every container process, so runAsGroup doesn't need to match it. The Kubernetes reference example deliberately uses runAsUser: 1000, runAsGroup: 3000, fsGroup: 2000, and its own id output is:
uid=1000 gid=3000 groups=2000,3000,4000
with the group-2000 volume writable.
Worth fixing because the advice actively backfires on OpenShift: following it makes users replace their primary GID 0 — the thing that gives them access to the root-group-owned files baked into the Pulsar image — for no benefit. That undercuts the very model the chart's fsGroup: 0 default relies on.
| extraVolumeMounts: [] | ||
| # Ensures 2.10.0 non-root docker image works correctly. | ||
| # Merged over the global `podSecurityContext`; these keys win over it. Override | ||
| # `fsGroup` here if a cluster policy forbids GID 0 -- also set `runAsGroup` to the |
There was a problem hiding this comment.
Same correction as the README note — runAsGroup does not need to equal fsGroup, since fsGroup is added as a supplementary group. This wording is repeated at lines 860 and 979 as well.
The first half of the sentence is good though: "Override fsGroup here if a cluster policy forbids GID 0" is appropriately conditional, and reads much better than the README's "Moving off GID 0" heading. Might be worth making the README match this framing rather than the other way round.
Adds two global values, `podSecurityContext` and `containerSecurityContext`, applied to every pod and every container the chart itself renders, including initContainers and the init/cleanup Jobs. Each is merged with a matching per-component override that wins on a per-key basis: podSecurityContext <- <component>.securityContext containerSecurityContext <- <component>.containerSecurityContext Before this change the chart exposed a pod-level securityContext for only zookeeper, bookkeeper, broker and oxia.server, and had no container-level securityContext support at all, so allowPrivilegeEscalation, capabilities, seccompProfile and readOnlyRootFilesystem were not settable at any value. Clusters running a restrictive pod security policy (Pod Security Admission, OPA Gatekeeper, Kyverno) had no supported way to make the chart conform. The merge uses mergeOverwrite rather than merge, because merge treats zero values in its destination as absent and would silently discard a per-component `fsGroup: 0` or `allowPrivilegeEscalation: false`. Containers supplied through `<component>.initContainers`, `oxia.coordinator.extraContainers` and `dekaf.deployment.extraContainers` are raw passthrough and are not merged with these settings; the README says so. Both globals default to empty, so rendered output is unchanged apart from two things: - The zookeeper and broker sts-cleanup upgrade-hook Jobs gain pod template labels via pulsar.template.labels. They were the only pod templates in the chart without them, so .Values.labels never reached their pods. - Three Jobs (bookkeeper cluster-initialize and the two sts-cleanup hooks) now render the `fsGroup: 0` of the component they belong to. Those pods mount only ConfigMaps, Secrets and the service account token, so this has no effect on volume ownership. It is not entirely inert everywhere: fsGroup is an admission input for OpenShift SCCs. The autorecovery StatefulSet gains liveness, readiness and startup probes, all disabled by default, so this adds a knob rather than behaviour. It had none and no values knob to add them. The daemon does not start BookKeeper's HTTP service, so the probes target the Prometheus stats endpoint on autorecovery.ports.http -- the same endpoint the PodMonitor already scrapes. They default to off because that endpoint comes from the image's BookKeeper stats provider rather than from anything the chart configures: a cluster overriding statsProviderClass or prometheusStatsHttpPort through autorecovery.configData would otherwise get a restart loop from liveness, or a permanently unready pod stalling the rollout from readiness. Every probe value is read with `dig`, because `autorecovery.probe` is a new key: under `helm upgrade --reuse-values` the new chart's defaults are not coalesced in, so the map is absent and a direct dereference aborts the render for every existing release. `fsGroup` is applied as a supplementary group, so `runAsGroup` does not need to match it. The docs say so rather than the opposite. Adds two worked examples. examples/values-psa-restricted.yaml targets the Kubernetes `restricted` Pod Security Standard and deliberately leaves the per-component `fsGroup: 0` defaults alone, since `restricted` places no constraint on fsGroup, fsGroupChangePolicy or supplementalGroups. examples/values-restricted-group-ranges.yaml covers the narrower case of a policy that constrains group IDs to a numeric range -- Gatekeeper's PodSecurityPolicy-derived K8sPSPAllowedUsers, an equivalent Kyverno policy, or an OpenShift SCC -- and overrides fsGroup on the four components that ship it, with a warning about the recursive volume ownership change. Validated with helm lint and kubeconform (k8s 1.25/1.31/1.36) against the default values, every .ci/clusters/* config, and .ci/templates-all-values.yaml. Also verified on a 4-node kind cluster (k8s 1.36.1) and on EKS 1.34 with the AWS EBS CSI driver, upgrading in place from published 4.7.0. Moving fsGroup from 0 to 10000 triggers the recursive relabel and preserves data: a 5000-message unacked backlog was consumed intact afterwards, with identical storageSize and no pod restarts.
Setting `containerSecurityContext.readOnlyRootFilesystem: true` previously rendered but did not run: the Pulsar images rewrite their configuration under /pulsar/conf on startup (bin/apply-config-from-env.py), write logs under /pulsar/logs, and the JVM and functions worker use /tmp. For every component that runs a Pulsar image, the chart now mounts an emptyDir over those three paths on each container and initContainer, and prepends a copy-pulsar-conf initContainer that seeds the conf volume from the image, because an emptyDir starts empty and apply-config-from-env.py edits files that must already exist. This could not be done from values alone. Values-supplied `<component>.initContainers` are appended after the built-in ones, so a seeding container cannot run first, and only autorecovery exposes initContainersExtraVolumeMounts, so the built-in init containers elsewhere could not be given the conf volume. broker/wait-bookkeeper-ready in particular runs apply-config-from-env.py and would fail. The volumes are driven by the effective container securityContext, using the same global/per-component merge as `pulsar.containerSecurityContext`, so one global setting covers the whole release and a component that overrides readOnlyRootFilesystem back to false also loses the volumes. `writableRootfsVolumes: false` opts out entirely for users who would rather declare the mounts themselves through extraVolumes/extraVolumeMounts. That key is read with `hasKey` rather than by truthiness, so that under `helm upgrade --reuse-values` -- where the new chart's defaults are not coalesced in and the key is simply absent -- it still means "chart default: enabled" rather than being indistinguishable from an explicit false, which would render a read-only root filesystem with none of the volumes that make it work. oxia and dekaf are untouched, as is the pulsar_manager StatefulSet: they do not run a Pulsar image and have no /pulsar tree. The pulsar_manager cluster-initialize Job does run one, and does `cd /tmp` and `curl -D headers.txt`, so it is included. The kubectl container of the JWT secret Job gets only /tmp. The bookkeeper StatefulSet emitted `initContainers:` only when waitMetadataTimeout was greater than zero. It is now emitted whenever anything needs it -- waitMetadataTimeout, this feature, cacerts, or user-supplied bookkeeper.initContainers -- with verify-clusterid still gated on the timeout. Besides being required here, this fixes a latent bug that predates this change: with `bookkeeper.waitMetadataTimeout: 0` and `bookkeeper.initContainers` set, the user's init containers were emitted without their parent key and the template failed to parse. Since readOnlyRootFilesystem is unset by default, rendered output is unchanged. Verified byte-identical against the previous commit for the default values, all 20 .ci/clusters/* configs and .ci/templates-all-values.yaml, comparing only the parent chart's documents (the bundled victoria-metrics-k8s-stack regenerates its self-signed webhook certificates on every helm invocation). With it enabled, 129 clean kubeconform runs across k8s 1.25/1.31/1.36. Verified at runtime on EKS 1.34: all pods reach Ready, copy-pulsar-conf and the built-in init containers complete, apply-config-from-env.py writes to the seeded conf volume, and produce/consume keeps working with no read-only filesystem errors in any container. Logs on an emptyDir do not survive pod replacement, which is noted in values.yaml and the README.
|
Hello Lari, thank you for your time spent on reviewing my PR. All four blocking issues are Fixed
One pre-existing bug, found while fixing the second itemThe The user's init containers are emitted without their parent key. My change fixes it as a The "restricted" confusion — my faultWe were talking about different things. You read it as the What I was actually targeting is a Gatekeeper constraint built on the PSP-derived fsGroup: {rule: MustRunAs, ranges: [{min: 1, max: 65535}]}which rejects The example is therefore split rather than deleted:
The README section is retitled "Overriding fsGroup, if a policy constrains group IDs" and I have not added an OpenShift example. It needs to clear the chart defaults rather than Re-verification
|
a71e9ac to
9e0f920
Compare
Motivation
The chart cannot currently be deployed on a cluster that enforces a restrictive pod security policy (Pod Security Admission, OPA Gatekeeper, Kyverno):
securityContextis exposed for onlyzookeeper,bookkeeper,brokerandoxia.server.securityContextanywhere, soallowPrivilegeEscalation,capabilities,seccompProfileandreadOnlyRootFilesystemare not settable at any value.securityContextand has no probes.readOnlyRootFilesystemcannot work unaided, because the Pulsar images rewrite/pulsar/confon startup and log to/pulsar/logs.Modifications
Two commits, reviewable independently.
1.
securityContextcascade. Adds globalpodSecurityContextandcontainerSecurityContext, each merged with a matching per-component override that wins per key:Applied to all 18 pod templates, including initContainers and the init/cleanup Jobs. The merge uses
mergeOverwriterather thanmerge, becausemergetreats zero values in its destination as absent and would silently discard a per-componentfsGroup: 0orallowPrivilegeEscalation: false.2.
readOnlyRootFilesystemsupport. When the effective containersecurityContextsets it, the chart mounts anemptyDirover/pulsar/conf,/pulsar/logsand/tmpand prepends acopy-pulsar-confinitContainer that seeds the conf volume from the image, for the components that run a Pulsar image.writableRootfsVolumes: falseopts out in favour of your ownextraVolumes/extraVolumeMounts.This could not be done from values: values-supplied
initContainersare appended after the built-in ones so a seeding container cannot run first, and onlyautorecoveryexposesinitContainersExtraVolumeMounts, sobroker/wait-bookkeeper-ready— which runsapply-config-from-env.py— would fail.Also adds
examples/values-restricted-psp.yamlas a worked example, including the per-component overrides needed to move the four components shippingfsGroup: 0off GID 0.Rendered output with default values is unchanged except for three deliberate items:
autorecovery.ports.http— the endpoint the PodMonitor already scrapes.pulsar.template.labels. They were the only pod templates without them, so.Values.labelsnever reached their pods.fsGroup: 0of the component they belong to. Those pods mount only ConfigMaps, Secrets and the service account token, so this has no functional effect.Verifying this change
helm lintclean.kubeconformagainst k8s 1.25 / 1.31 / 1.36 for the default values, every.ci/clusters/*config and.ci/templates-all-values.yaml, with the new feature both off and on.Rendered output diffed against
masteracross all of the above: identical apart from the three items listed, and byte-identical for commit 2 whenreadOnlyRootFilesystemis unset.Runtime-tested on EKS 1.34 with the AWS EBS CSI driver (gp3/ext4,
fsGroupPolicy: ReadWriteOnceWithFSType), upgrading in place from published 4.7.0:fsGroupfrom0to10000triggers the recursive relabel and preserves data: a 5000-message unacked backlog was consumed intact afterwards, with identicalstorageSizeand no pod restarts.readOnlyRootFilesystem: true, all pods reach Ready,copy-pulsar-confand the built-in init containers complete,apply-config-from-env.pywrites to the seeded conf volume, and produce/consume keeps working with no read-only filesystem errors.Also exercised on a 4-node kind cluster (k8s 1.36.1) with the default values, with the restricted-PSP example applied as an upgrade, and with Oxia as the metadata store.