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.
|
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
lhotari
left a comment
There was a problem hiding this comment.
Thanks — this is a big step forward, and the fsGroup discussion is fully settled from my side. I re-ran the whole review against 9e0f9204, rendering rather than reading, and all six previous findings check out. I've resolved those threads.
Verified fixed
| Finding | How I checked |
|---|---|
--reuse-values nil pointer |
--set autorecovery.probe=null renders clean; absent writableRootfsVolumes + readOnlyRootFilesystem still yields copy-pulsar-conf and all three volumes. hasKey semantics are right. |
bookkeeper waitMetadataTimeout: 0 |
Now renders init=[copy-pulsar-conf] with conf/logs/tmp mounted. All 16 guard combinations render correctly — never empty, never omitted when a child renders. |
| pulsar-manager cluster-initialize Job | Seeds conf and mounts conf/logs/tmp on all four containers, so curl -D headers.txt works. |
runAsGroup / fsGroup |
Zero stale copies; replaced with the correct opposite. |
| autorecovery probes | Both default off. Default-values delta is exactly 21 added lines — your number is precise, and no probe delta remains. |
| Docs over-claim | Narrowed correctly. I agree narrowing beats merging: silently rewriting user-supplied container specs would be the more surprising behaviour. |
The pre-existing bookkeeper bug is real on master (waitMetadataTimeout=0 plus user initContainers emits an orphan list item and fails to parse) and fixed here. Please keep it in this PR — it's a side effect of a guard you had to touch anyway, and splitting it out would just create a rebase dependency.
On the Gatekeeper justification: you're right and I was wrong to frame the capability as unnecessary. K8sPSPAllowedUsers is a current template in the official Gatekeeper library exposing exactly fsGroup.rule (enum MustRunAs/MayRunAs/RunAsAny) and ranges[].min/max. The split is well executed — the group-ranges file opens by saying it is not what PSS restricted requires, points at the safer file, and even carries an accurate OpenShift note. Settled.
New blockers
The adversarial pass found four things, all verified by rendering. Three are in the new PSA example and the pulsar-manager StatefulSet; the fourth is a design gap in the volume feature itself. Details inline.
Briefly:
values-psa-restricted.yamlcan't install into anenforce=restrictednamespace — the bundled monitoring stack is left enabled, and node-exporter renders withhostNetwork: true,hostPID: trueandhostPathmounts.- The PSA example's own reasoning breaks the pre-upgrade hooks —
runAsNonRoot: truewithoutrunAsUser, but the sts-cleanup Jobs runalpine/k8s, which has noUSERdirective. - Six templates get
readOnlyRootFilesystemwith no writable volumes at all — pulsar-manager is one instance of a wider class. - The rootfs volumes collide fatally with any user-supplied mount on the same path.
On the emptyDirVolumes redesign
I like the direction and I think finding 3 forces it — see the inline note on values.yaml. Short version: rename to emptyDirVolumes, make it a list of {path, seedFromImage, sizeLimit}, and allow a per-component override. That turns the six-component gap from "documented exclusion" into something a user can actually fix, and it removes the collision by letting people drop an entry.
Happy to iterate on the shape before you build it — no need to guess.
Reviewed with Codex gpt-5.6-sol and Claude Opus 5. Claude Fable was in the reviewer set but failed to return again, so this round is two-model rather than three. Every finding was reproduced locally by rendering the chart.
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.
Both examples set `runAsUser` explicitly rather than relying on the images'
own USER, because not every image the chart runs has one: the zookeeper and
broker sts-cleanup pre-upgrade hooks use alpine/k8s, which has no USER
directive and runs as UID 0. With runAsNonRoot and no runAsUser the kubelet
refuses those pods ("container has runAsNonRoot and image will run as root"),
and because they are pre-upgrade hooks that blocks `helm upgrade` outright.
kubectl in that image was verified to work as UID 10000.
Both examples also disable the bundled monitoring stack, because these values
do not reach subchart templates: prometheus-node-exporter requires
hostNetwork, hostPID and hostPath, Grafana chowns its data directory as root,
and some of the stack's workloads set no seccompProfile. Left enabled its
pods are rejected on admission while Pulsar itself comes up fine. With it
disabled, the whole rendered release is admitted by a namespace enforcing
`restricted` with no violations.
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.
9e0f920 to
754cb7d
Compare
|
Thanks — all four are addressed. Force-pushed as two commits again. 1. 2. The six templates that had no writable volumes. dekaf gets
3. The PSA example. 4. The pre-existing bookkeeper Verification: One open question: opting |
lhotari
left a comment
There was a problem hiding this comment.
All four addressed, and the round-1 fixes survived the rewrite. I've resolved the remaining threads. LGTM.
The rewrite touched 21 files and replumbed volumes in every workload, so I re-tested the earlier fixes specifically rather than assuming — everything below is from rendering, not reading.
emptyDirVolumes
The implementation is better than what I sketched. The collision that made this a blocker is gone, and every validation you claim actually fires:
| Case | Result |
|---|---|
relative path (pulsar/logs) |
fails |
| duplicate paths | fails |
| unknown entry key | fails |
| over-long derived name (70 chars) | fails |
derived-name collision (/pulsar/logs + /pulsar-logs) |
fails |
trailing-slash collision (/tmp + /tmp/) |
fails |
| valid custom list | renders |
[] opt-out |
no volumes |
The last two are worth calling out. The sanitiser plus the DNS-1123 regexMatch and 63-char check catches both collision classes I raised — including /tmp vs /tmp/, which I only thought to test after reading the helper.
The pulsar_manager guard
Well built. It keys on the merged effective context rather than the global, and the message names the escape hatch. I hunted false positives specifically:
| Scenario | Behaviour |
|---|---|
| global roRootFS + pulsar_manager enabled | fails (intended) |
+ pulsar_manager.containerSecurityContext.readOnlyRootFilesystem: false |
renders — escape hatch works |
| pulsar_manager enabled, roRootFS unset | renders |
| roRootFS set, pulsar_manager disabled | renders |
That distinction — a guard you can escape rather than a wall — is exactly right.
Everything else
- Six templates: oxia server/coordinator and dekaf get
/tmp. Leaving the sts-cleanup hooks outside is the right call: the script only runskubectlwith no temp files, and kubectl degrades gracefully when its discovery cache isn't writable. - Both examples:
runAsUser: 10000set, monitoring stack fully disabled (zero VM/node-exporter references, zerohostNetwork/hostPID/hostPathviolations). Every workload in both files carries all four PSS-restricted controls, and both passkubeconform -strict(43 valid, 0 invalid). - Default delta: exactly 21 added, 0 removed. Your number is precise.
- No regressions. The one I was most concerned about was the key rename: under
--reuse-valuesbothautorecovery.probeandemptyDirVolumesare absent, and the chart correctly falls back to the default list —init=[copy-pulsar-conf, wait-zookeeper-ready, wait-bookkeeper-ready],vols=[pulsar-conf, pulsar-logs, tmp]. Renaming the key didn't reintroduce the original bug. bookkeeperwaitMetadataTimeout: 0still correct too. - Pre-existing bookkeeper fix retained, as agreed.
Your open question — no need to handle it
Don't give the cluster-initialize Job its own key. pulsar_manager support is going to be dropped from the chart before long: the upstream project is no longer maintained and carries unfixed vulnerabilities, and dekaf is already in the chart as the replacement. Adding values surface for a component on its way out isn't worth it, and the edge case is harmless in the meantime — the Job simply doesn't get the read-only hardening it could have had. Leave it.
That also retroactively justifies not investing in making the pulsar-manager StatefulSet itself work under a read-only root: the fail is the right amount of effort for a component with a limited remaining life.
One optional nit
<component>.emptyDirVolumes: [] combined with readOnlyRootFilesystem: true gives a read-only container with no writable paths and no warning — the same trap class as the old pulsar-manager case, just user-initiated. It's the documented escape hatch so it's defensible; a line in the values comment saying you must then supply the paths yourself via extraVolumes/extraVolumeMounts would close the loop. Not blocking.
Nice work on this one — it went from a broad feature to something with real edges tested.
This round was reviewed solo (Claude Opus 5); Codex gpt-5.6-sol and Claude Fable both failed to return within the time budget, so it did not get the usual multi-model cross-check. Every finding above was reproduced locally by rendering the chart.
| ## | ||
| ## Keep in sync with the fallback in the `pulsar.emptyDirVolumes.resolve` helper, which | ||
| ## applies when the key is absent (`helm upgrade --reuse-values`). | ||
| emptyDirVolumes: |
There was a problem hiding this comment.
Optional, non-blocking.
<component>.emptyDirVolumes: [] plus readOnlyRootFilesystem: true produces a read-only container with no writable paths and no warning — structurally the same trap as the pulsar-manager case, just chosen by the user rather than inherited. It's the documented escape hatch so this is defensible as-is, but a sentence here would close the loop:
Setting this to
[]for a component opts it out entirely; if that component also hasreadOnlyRootFilesystem: true, you must supply the writable paths yourself via<component>.extraVolumes/<component>.extraVolumeMounts.
The rest of this block reads well — the REPLACES-not-extends note in particular is the thing people would otherwise get wrong.
There was a problem hiding this comment.
Added, folded into the sentence that sets the trap rather than as a separate paragraph:
## `<component>.emptyDirVolumes` REPLACES this list rather than extending it; use `[]`
## for "this component needs none" -- but a component with `readOnlyRootFilesystem: true`
## and an empty list has no writable path at all, so supply them yourself through
## `<component>.extraVolumes` / `<component>.extraVolumeMounts`. A path the component
## already mounts through `extraVolumeMounts` is skipped, since duplicate mountPaths are
## rejected.
Amended into the readOnlyRootFilesystem change; comment-only, so the rendered output is unchanged.
I stopped short of failing the render on [] + readOnlyRootFilesystem: true, since the helper can't tell that mistake from someone who legitimately supplied the paths via extraVolumeMounts -- it only drops list entries whose path is already mounted, and never sees mounts beyond them.
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.
The chart now mounts an emptyDir over each path in the new `emptyDirVolumes` list on
every container and initContainer of the component, and prepends a copy-pulsar-conf
initContainer that seeds the entries marked `seedFromImage` from the image, because an
emptyDir starts empty and apply-config-from-env.py edits files that must already
exist. The default list describes the Pulsar images:
emptyDirVolumes:
- path: /pulsar/conf
seedFromImage: true
- path: /pulsar/logs
sizeLimit: 1Gi
- path: /tmp
sizeLimit: 1Gi
A list rather than a boolean, so that an existing deployment is not broken by a
mountPath the chart cannot know about: an entry whose path the component already
mounts through extraVolumeMounts is dropped, since a duplicate mountPath is rejected
outright by the API server. `sizeLimit` is set because an uncapped emptyDir can fill a
node's ephemeral storage and get pods evicted.
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. `<component>.emptyDirVolumes` replaces the list it would
otherwise inherit; `[]` opts a component out entirely, for users who would rather
declare the mounts themselves through extraVolumes/extraVolumeMounts.
Resolution is `--reuse-values` safe. The helper carries a copy of the values.yaml
default and distinguishes an absent key from an explicit `[]` with `hasKey`, because
`helm upgrade --reuse-values` does not coalesce in a new chart's defaults: the key is
simply absent there, and treating that as an empty list would render a read-only root
filesystem with none of the volumes that make it work.
Components that do not run a Pulsar image must not receive that Pulsar-shaped list --
the seed step would try to copy a /pulsar/conf that does not exist in their images --
so they carry their own default at the call site rather than in values.yaml, which
keeps them correct when the key is absent. Measured by running each image read-only:
* oxia server and coordinator get /tmp. Oxia keeps its own state under the data
directory -- WAL, Pebble SSTables and snapshots all live there, and an incoming
snapshot is written straight into the db path rather than staged elsewhere -- and
/tmp stayed empty through 260MB of write load per server, compaction, and a full
snapshot install into a wiped follower, measured with a writable /tmp mounted so
that any use of it would have been visible. Neither Pebble nor hashicorp/raft
references os.TempDir outside their tests. It is provided regardless: the cost is
one emptyDir, and a dependency or a later oxia version falling back to
os.TempDir() would otherwise take the metadata store down under load rather than
at startup.
* dekaf needs /tmp; without it the JVM fails with
java.nio.file.FileSystemException: /tmp/dekaf...: Read-only file system.
* the sts-cleanup pre-upgrade hooks run kubectl from alpine/k8s, which needs nothing
writable, and are left outside the mechanism because the values key they would
read is shared with a Pulsar-image workload.
The pulsar_manager StatefulSet cannot support it. Beyond /run/postgresql and /tmp the
apachepulsar/pulsar-manager image needs /var/log/nginx, /var/lib/nginx and
/run/nginx.pid, and writes a supervisord socket into /pulsar-manager, its own install
directory, which an emptyDir cannot cover without hiding the application. Enabling
both now fails the render with a message naming the reason and the opt-out, rather
than producing a pod that never becomes ready. Note that opting it out also opts out
its cluster-initialize Job, which shares the pulsar_manager securityContext even
though it runs a Pulsar image and would otherwise work.
A path is rejected at render time rather than at apply time when it is relative,
carries an unknown key (`seedFromimage` would otherwise silently leave the conf
directory unseeded), collides with another entry, reduces to the same volume name as
another entry (/pulsar/logs and /pulsar-logs both give pulsar-logs), or is long enough
to exceed the 63-character limit on a Kubernetes name.
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 against the previous commit for the default values, all 19 .ci/clusters/*
configs, .ci/values-common.yaml 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, 51 clean kubeconform runs across k8s 1.25/1.31/1.36 over 17 value
sets covering TLS, JWT, standalone, oxia, dekaf, all components, and three
--reuse-values shapes. Verified at runtime on EKS 1.34 by installing without it and
then running `helm upgrade --reuse-values --set
containerSecurityContext.readOnlyRootFilesystem=true`: every StatefulSet gains the
seed container and the volumes, copy-pulsar-conf completes first, the seeded conf is
rewritten by apply-config-from-env.py, and 200 messages produced before the upgrade
are consumed after it with no read-only filesystem errors in any container. oxia and
dekaf were confirmed read-only at runtime with the volumes above and nothing else.
Logs on an emptyDir do not survive pod replacement, which is noted in values.yaml and
the README.
754cb7d to
d412675
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.