Renovate only ever rewrites appVersion or an image tag, so every PR it
opened against a chart directory failed the build-chart version check
(#138, #139). ci/renovate-bump-chart.sh recomputes
version: <semver>+up<appVersion> as a postUpgradeTasks hook: chart major
when the app major moves, chart patch otherwise.
Each chart renders its main image as <repo>:{{ .Chart.AppVersion }}, so
repository and version live in different files and Renovate cannot join
them. The mapping is one customManager per chart in renovate.json5,
deliberately not a comment inside each Chart.yaml - that would touch all
20 chart directories and force 20 publishes for a comment change.
Patch and digest updates automerge after a 3-day minimumReleaseAge once
the pipeline is green; majors arrive one line at a time to match the
stepwise app-major procedure. ci/** stays excluded: the install-test
fixtures pin throwaway infrastructure to major lines on purpose.
One step: 2025-08-05 is the immediate successor release, no publication
in between (verified against the project's release list). Tag present on
Docker Hub for amd64 and arm64.
Patch bump, because the changelog review found nothing that touches the
values surface or the chart's behaviour. Checked against the shipped
source of both images rather than against the release notes:
- whitelist.txt format: lib/Configuration.php is byte-identical apart
from its VERSION constant, so the parsing the chart relies on (trim,
split on newline, drop empty entries, "*" wildcard) is unchanged.
- the five production bridges: all present, none renamed. Youtube,
TheHackerNews, ARDMediathek and Reddit are byte-identical; Heise has a
content-level change only (paywalled heise+ items now carry an
archive.is link, two more CSS selectors stripped). The three bridges
removed upstream - Askfm, DansTonChat, FirstLookMediaTech - are none
of ours.
- RSSBRIDGE_* environment mapping: same file, therefore unchanged.
- write paths: re-derived empirically rather than inferred, because the
opcache lock file in /tmp appears in no filesystem diff. The new image
needs exactly the same five, and a control run without /tmp still dies
with "Unable to create lock file: Read-only file system".
- health endpoint: actions/ is identical, /?action=health intact.
- PHP 8.2.28 -> 8.2.29, nginx and Debian unchanged, so the
/run/php/php8.2-fpm.sock path the chart mounts still applies. Measured
fresh-start peak under five concurrent feed fetches: 64.5 MiB on the
new image against 71.1 MiB on the old one under an identical
procedure. Requests and limits stay as they are.
The memory comments in values.yaml are refreshed to the image the chart
now ships, and the older 119 MiB figure is labelled as what it was - a
warm container, not a fresh start.
We stay on the dated release tag. The newest release is itself over
twelve months old and the project now pushes latest and commit-based
sha-* tags continuously, but a sha-* tag is not more released than the
release, only newer, and latest is rolling - both conflict with the
repo's convention of reproducible pinned tags.
Fixes#135
Follow-up to the template-chart hardening fixes in #133, which landed
while this branch was open.
The env block now quotes names, values and keyRef fields. Without it a
value like "5" renders as a YAML number and the API server rejects the
whole manifest. RSS-Bridge takes numeric settings that way
(RSSBRIDGE_http_timeout), so this was reachable with the first env entry
anyone adds.
The port-80 question is settled by measurement rather than by the
"it works in k3d" reasoning #133 warns about. Same pod spec in k3d
(k3s v1.35.5), only ip_unprivileged_port_start changed:
1024, capabilities.drop [ALL] nginx: bind() to 0.0.0.0:80 failed
(13: Permission denied)
1024, plus NET_BIND_SERVICE identical failure - for a non-root
process the capability lands in the
permitted but not the effective
set, so it buys nothing
0 binds, HTTP 200
So the chart cannot buy safety with a capability the way samba does, and
port 80 is not negotiable either: nginx is configured for it inside the
image and the entrypoint's only lever is a sed against a read-only
/etc/nginx. What it can do is stop depending on the node default - the
pod securityContext now pins the sysctl to 0, so the bind no longer
relies on cluster configuration. Verified: the chart-installed pod
reports 0 and answers 200 through the Service.
Worth recording how that failure looks without probes: the entrypoint
backgrounds nginx and then execs php-fpm, so a failed bind leaves a
container reporting Running with no web server at all. Both test pods
above were Running. The probes on /?action=health are what turn it into
a pod that never becomes Ready.
trivy config still reports 0 to act on; the KSV-0117 statement is
updated to the measured reasoning.
Sets seccompProfile.type: RuntimeDefault in the pod security context, so
the container runtime's default syscall filter applies to every container
in the pod.
The value lives in the pod context rather than the container context
because it covers init containers and any container added later without
being repeated; a container that needs a different filter can still
override it under securityContext.container, which wins over the pod
value.
Verified in k3d against the database path rather than a static page:
under the filter the app opens its connection to Postgres and Prisma
applies all migrations (141 rows in _prisma_migrations, 31 tables
created), and the server-rendered pages answer with HTTP 200. The kernel
reports Seccomp: 2 (filter mode) inside the container.
Major bump: runtime behavior changes.
Refs #84
Two findings from the Trivy config scan that landed on main in #129.
AVD-KSV-0125 is fixed rather than excepted: writing the image as
docker.io/rssbridge/rss-bridge puts a real registry in the registry
position instead of "rssbridge", which is what ci/trivy/data/ksv0125.yaml
asks for. Verified in k3d that the qualified reference resolves to the
same digest (sha256:0eca99b2...) and still runs.
AVD-KSV-0117 (containerPort below 1024) becomes a scoped, documented
exception. nginx listens on 80 inside the image, and the entrypoint's
only way to change that is a sed against /etc/nginx/sites-enabled/default
which a read-only root filesystem rejects - so the alternative to port 80
is giving up readOnlyRootFilesystem. The premise of the check does not
hold here anyway: the container binds 80 as uid 33 with all capabilities
dropped and no NET_BIND_SERVICE.
trivy config now reports 0 to act on for this chart.
Takes over the four loose manifests in cluster-config/rss-bridge/ as a
proper chart, on the current template-chart standard: strict values
schema, null-safe helpers, computed limits, replicas/scaleDown,
checksum/config, NOTES.txt.
Two things deviate from the repo standard on purpose:
- The Ingress models host AND TLS secret name per entry instead of the
single conventional tls-<release>-<chart>-ingress. The installation
this replaces has grown three separate certificates, one per host.
Renaming them would make cert-manager issue three new ones and leave
each host unreachable until it did - a self-inflicted outage on a
deployment that has been up for over a year. Entries sharing a secret
name are grouped into one tls entry, and omitting the name falls back
to the convention, so consolidating later is a values change.
- Requests and limits are decoupled. The old manifest requested 2Gi of
ephemeral storage, identical to its limit, reserving two gigabytes for
a workload measured at 2.2 MB after fetching all five whitelisted
bridges. The request drops to 64Mi; the limit stays at 2Gi.
Hardening was verified rather than assumed. The image ships no USER and
runs as root by default, but runs fully hardened once its writable paths
are provided: uid/gid 33, readOnlyRootFilesystem, all capabilities
dropped, seccompProfile RuntimeDefault. The writable paths were found by
running the image with a writable rootfs, exercising every bridge and
reading its diff layer - /app/cache, /run, /run/php, /var/lib/nginx.
/tmp did not show up that way, because PHP's opcache creates its lock
file there and unlinks it immediately; it only surfaced when the
hardened container died at startup. Stopping at the first writable path
would have shipped a chart that cannot start.
Probes use the app's own /?action=health, which renders fixed JSON
without touching the cache or any remote site, so neither a scraped site
going down nor an unwritable cache can restart the pod.
Fixes#128
Five changes to the scaffold every future chart is copied from:
1. seccompProfile.type: RuntimeDefault in the pod security context, in
line with the rest of the seccomp series (#84). Pod context, because
from there it also covers init containers and any container added
later without being repeated.
2. containerPort 80 -> 8080, clearing AVD-KSV-0117. Note that the usual
justification for this does not hold: a non-root container CAN bind
port 80 on current Kubernetes, because the kubelet sets
net.ipv4.ip_unprivileged_port_start=0 in the pod network namespace.
Measured, not assumed - see the values.yaml comment. The reason to
stay above 1024 is that this is a runtime default rather than a
guarantee, and that every real chart here already does.
3. The ConfigMap placeholder key is renamed from the literal "key" to
"example-setting", clearing AVD-KSV-01010 (secret-scanner patterns
match any key named "key").
4. The image placeholder is fully qualified as ghcr.io/ORG/IMAGE, so the
template stops teaching the unqualified form that produced the
repo's unqualified Docker Hub images.
5. env values are rendered through "quote". Without it a value like
"8080" renders as a YAML number and the API server rejects the whole
manifest ("cannot unmarshal number into Go struct field
EnvVar.value of type string"). Every real chart in the repo already
quotes here; only the template did not. Found by instantiating the
template and installing it, not by rendering it.
Verified by instantiating the scaffold into a working chart per the
README procedure and installing it in k3d: the workload becomes Ready,
the Service routes to the app on 8080 and an ephemeral container added
to the running pod inherits Seccomp: 2 from the pod context.
Refs #84
Sets seccompProfile.type: RuntimeDefault in the pod security context, so
the container runtime's default syscall filter applies to every container
in the pod.
The value lives in the pod context rather than the container context
because it covers init containers and any container added later without
being repeated; a container that needs a different filter can still
override it under securityContext.container, which wins over the pod
value.
Verified in k3d against real extractions rather than a reachable port:
the -full image loads its parsers dynamically, so a blocked syscall would
surface as HTTP 200 with empty text, not as an error. The PDF parser, the
HTML parser and the detector/metadata path all return the expected
content under the filter, and the kernel reports Seccomp: 2 (filter mode)
inside the container.
Major bump: runtime behavior changes.
Refs #84
The pod runs rootless as uid/gid 1000; only init-run-ownership is root, to
chown the /run emptyDir that s6-overlay requires to be owned by the runtime
uid. Justified in paperless-ngx/values.yaml, so AVD-KSV-0105/-0022/-0106 are
excepted for that chart with the reason, instead of being reported as if the
workload itself ran as root.
Adds a trivy-config job to the PR pipeline that renders each changed chart
with Helm and runs `trivy config` over the rendered manifests. Reporting
only - TRIVY_CONFIG_MODE=report always exits 0, so the job never blocks a PR.
Rendered manifests rather than the chart directory: `trivy config <chart-dir>`
renders with default values, which fails on every chart here because of their
required() values - and Trivy does not fail with it, it logs "Skipping chart"
and prints an empty, green report.
Values come from the install-test fixtures (ci/<chart>/test-values.yaml), the
only committed per-chart values in the repo; none of them touches
securityContext, images or capabilities, so the scan sees the chart's own
hardening. Charts without fixtures get ci/trivy/values/<chart>.yaml.
Exceptions live in ci/trivy/trivyignore.yaml in Trivy's native ignore-file
schema, each with a statement - the script refuses to run without one and
reports suppressed findings with their reason instead of hiding them.
AVD-KSV-0125 is configured instead of excepted: ci/trivy/data/ksv0125.yaml
lists the trusted registries, so the check stays able to flag a genuinely
foreign source.
Refs #84.
Sets seccompProfile.type: RuntimeDefault in the pod security context, so
the container runtime's default syscall filter applies to every container
in the pod.
The value lives in the pod context rather than the container context
because it covers init containers and any container added later without
being repeated; a container that needs a different filter can still
override it under securityContext.container, which wins over the pod
value.
Gotenberg is the chart where a syscall filter was most likely to bite: it
drives headless Chromium and LibreOffice, and browser sandboxes are the
classic casualty of a restrictive seccomp profile. Verified in k3d that
both engines still work under the filter - Chromium HTML->PDF and
LibreOffice document->PDF both return real, non-empty PDFs, and the
kernel reports Seccomp: 2 (filter mode) inside the container.
Major bump: runtime behavior changes.
Refs #84
Sets seccompProfile.type: RuntimeDefault in the pod security context, so
the container runtime's default syscall filter applies to every container
in the pod. Verified in k3d: /proc/1/status reports Seccomp: 2 (filter
mode) with one filter loaded, and nginx keeps serving the app.
The value lives in the pod context rather than the container context
because it covers init containers and any container added later without
being repeated; a container that needs a different filter can still
override it under securityContext.container, which wins over the pod
value.
Major bump: runtime behavior changes, even though it stays invisible for
this workload.
Refs #84
The computed limits (limitFactor 3 x request = 1536Mi memory, 30Mi ephemeral
storage) were too small for real documents: a 12 MB HTML already gets the
container OOM-killed, and the caller sees no HTTP error at all, just a dropped
connection and a rising RESTARTS counter.
Rather than raise the requests and reserve conversion-sized capacity on the
node around the clock, requests and limits are decoupled: the requests stay as
they were, and both limits are now set explicitly. The chart already supported
this - an explicit limit wins over the limitFactor computation.
Measured in a throwaway k3d cluster with the official image (HTML via
Chromium), building on the series in the issue:
18 MB HTML, 2Gi -> OOMKilled
18 MB HTML, 2560Mi -> OOMKilled
18 MB HTML, 3Gi -> HTTP 200 in ~22s, 21 MB PDF
12 MB HTML, 1536Mi -> OOMKilled (the old computed limit)
12 MB HTML, 2Gi -> HTTP 200 in ~13s
Memory beyond 3Gi buys nothing: gotenberg's own request timeout is 30s and the
chart passes no flags to change it, so a document needing more memory has
already run out of time (a 25 MB HTML misses the 30s even at 4Gi).
The ephemeral-storage limit is raised in the same change, because raising
memory alone only moves the failure. With 3Gi memory and the old 30Mi limit,
/tmp peaked at 40 MB on a single conversion and at ~125 MB over a short
series, and the pod was evicted:
Pod ephemeral local storage usage exceeds the total limit of containers 30Mi
That failure is worse than the OOM-kill: the pod does not restart, it is
replaced, and the old one is left behind as Evicted.
The defaults in the effectiveResources helper are updated in lockstep with
values.yaml, so an erased "resources:" block (#99) falls back to the new
explicit limits instead of the old computed ones.
NOTES.txt is pulled along. Its numbers came from effectiveResources already,
but the text described a threshold that has moved and advice that no longer
holds: raising the request no longer drags the limit with it now that the
limits are explicit. The warning is kept but no longer claims memory is the
first limit you hit - with these limits, the 30s timeout is.
Major, because the runtime behaviour changes and the pod may occupy more
memory during large conversions than it was allowed to before.
Fixes#106
Closes out the schema programme (#68) by moving what the 18 charts taught
into the reference implementation and the binding guidelines.
template-chart:
- values.schema.json as the reference schema: additionalProperties false
on every chart-owned level, pod-spec passthrough left open, and the
reasoning inline in the descriptions so a copy carries it along.
- defaultedDict and subBlock plus the per-block fallbacks for both
security contexts, the three probes and resources, wired into both the
Deployment and the StatefulSet.
- the reference itself violated guideline 1: its env block reads
ingress.domain, so with the Ingress gated off it rendered an empty
value. That read is now required(), and the comment says why - the
template is what people copy, so it has to teach the guard, not the
hole.
README, new section "Values that must not lie" with the five rules:
1. a value read outside its obvious template needs its own guard
(matrix-synapse published '{"m.server": ":443"}' to federation without
it, rendering cleanly and reporting healthy)
2. probes, contexts and resources through the null-safe helper, never
behind an "if" - a guard removes the probe instead of showing a null
3. no liveness on an external dependency where a dependency-free path
exists, otherwise document why on the helper
4. a fallback reproduces THIS chart's defaults, not the repo standard -
calibre's readOnlyRootFilesystem: false is what keeps the runtime uid
at 1000 instead of 911 and existing volume data readable
5. a probe default carries its context - port name, Host header, exec
command - or the probe vanishes (teamspeak) or gets 403 (patchmon)
Plus the two mechanics that are easiest to lose: null reaches schema
validation only for keys WITHOUT a chart default (which is why the helper
and the ["object","null"] types each cover a case the other cannot), and
schemas must be validated against real bundle values - that step caught a
draft of mine that would have broken three live paperless releases.
CLAUDE.md: the new-chart checklist now names the schema and the
adaptation step, since the copied file is chart-specific and wrong until
it is adapted.
freesailarr is not covered: it is not on main yet (#59).
The deployment renders SMTP_REJECT_UNAUTHORIZED from
rallly.smtp.rejectUnauthorized, but values.yaml never carried the key. With
SMTP enabled the variable therefore reached the app as an empty string --
a certificate check nobody had configured either way.
It now defaults to true, so the mail server's certificate is verified unless
the operator switches the check off deliberately. values.yaml explains when
that is needed (a mail server with a self-signed certificate), because the
symptom otherwise points at the mail server rather than at this value.
The env var is not rendered with "| default true": an explicit false is a
zero value and default would swallow it, silently re-enabling the check the
operator just switched off. A nil falls back to true instead, so the variable
can no longer reach the app empty. The schema now rejects strings.
Operationally inert: the rallly fleet bundle and the deployed release both
have smtp.enabled: false, so the variable is not rendered at all -- the
render with SMTP disabled is byte-identical to before.
Major, because the runtime behaviour of mail sending changes.
Fixes#109
The centrepiece is the secret inventory: the four OnePasswordItem CRs carry
only item paths, while the fields each item must contain are decided in the
deployment. The notes list them per item — including the eight OIDC fields
nobody would guess — because a missing field does not fail the install, it
starts the container with an empty variable and misbehaves later.
The warnings are the ones this chart earns:
- Sign-in is OIDC-only. The chart sets OIDC_DISABLE_LOCAL_AUTH, so a wrong
OIDC item locks everyone out with no local account to fall back on.
- A wrong ingress.domain never becomes Ready: the server answers 403 to any
unknown Host header, the probes send that domain, and the log shows plain
403s rather than anything resembling a configuration error.
- The first 2.x start rebuilds database indexes and may take minutes, so the
notes print the actual startup budget and say a slow boot is not a hang.
- The presence registry is keyed by pod hostname, so replicas do not scale.
The startup budget is read through the null-safe probe helper, and the guacd
sidecar and scaled-down states are reported only when they apply. Refs #43.
Three changes, across both workloads (Synapse StatefulSet and delegation
nginx Deployment).
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, including the whole matrix.delegation block
with its own contexts, probes and resources.
2. Ingress gate (#93): the Ingress template is wrapped in
{{- if .Values.ingress.enabled }} and the key is added to values.yaml
with default true, so behaviour is unchanged.
The domain checkpoint found a second reader: the delegation nginx
serves ingress.domain in its /.well-known/matrix/{server,client}
endpoints, and read it without a guard. Gating alone would therefore
have published '{"m.server": ":443"}' and base_url "https://" - a
federation-breaking answer that renders and starts cleanly. Both reads
are now required(), so that case fails loudly. Only clusterIssuer
actually becomes unnecessary when the Ingress is off.
3. Null-safety for hardening, probes and resources (#99) for both
workloads. The rebuilt probes keep each workload's own endpoint:
Synapse /health, delegation /.well-known/matrix/server - the endpoint
that workload exists to serve - including its shorter 3s timeout. The
delegation pod context keeps having no fsGroup, because it mounts no
volume that needs one.
The nginx checksum/config annotation from #82 is untouched.
storage stays deliberately not null-safe: an erased block yields an
invalid PVC, which is the loud class #99 excludes.
Rendering is byte-identical to 6.0.0, both with the CI values (which
enable delegation) and with the production bundle values.
samba.globalConfig has been part of values.yaml since the chart's first
commit but is read by no template. Setting it never had an effect.
Rather than implement it, the key is removed and the strict schema now
rejects it: additionalProperties is already false on the samba object, so
dropping the property turns a silently ignored key into a render-time
error.
Operationally inert: neither the cluster-smb fleet bundle nor the deployed
release's user-supplied values set the key -- it only ever appeared as the
chart's own null default.
Major, because a published value disappears and is now rejected.
Fixes#117
Largest values surface of the programme, and the first chart where the
null-safety covers two workloads.
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, including files.barcodes, ocr (with enums
for the 3.x modes), scripts, tikaGotenberg, the oidc provider block
and the SMB CronJob.
2. Null-safety for hardening, probes and resources (#99), for the server
AND the SMB consume-mover CronJob. It reuses the defaultedDict helper
the barcodes block already introduced (#61) instead of adding a second
mechanism - one merge helper, now with more callers.
The server's pod context is consumed twice: as the pod securityContext
and by the /run-ownership init container, which chowns /run to
runAsUser:runAsGroup because s6-overlay refuses to start unless /run is
owned by the runtime uid. Both reads now go through the same helper, so
the chown cannot disagree with the context it has to match. Erasing
that block used to abort rendering with a nil-pointer error; it now
falls back to the defaults like every other chart.
The CronJob has no probes (documented exception), so only its two
contexts and resources are covered.
files.storage stays deliberately not null-safe: an erased value yields an
invalid PVC, which is the loud class #99 excludes.
Rendering is byte-identical to 11.0.0, both with the CI values (which
include the #61 regression guard) and with the production paperless-jk
bundle values.
No ingress gate: this chart has no Ingress at all (clients reach it via a
NodePort), and ingress.domain appears neither in its templates nor in its
values.
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, including the entries of the mappings list,
where domain and internalUrl are required.
2. Null-safety for hardening, probes and resources (#99). Two things a
rebuilt default probe must not lose, both verified:
- the API port is addressed by NAME ("api-port"), so the probe keeps
working when server.apiPort is overridden.
- mc-router still has no /health endpoint in 1.46.4, so GET /routes is
the health signal, and the "Accept: application/json" header belongs
to that request.
The pod context keeps pinning uid/gid 10000 even though the image
declares USER 65532 since 1.46.0, so the runtime identity does not
silently change with the base image.
mappings stays deliberately not null-safe: the chart fails loudly on a
missing or empty routing table, which is the loud class #99 excludes - a
router without routes is not operable and has no sensible default.
Rendering with normal values is byte-identical to 2.0.0.
No ingress gate change: this chart already honours ingress.enabled, and
its ingress block keeps the upstream-style host/tls lists that the README
documents as an exception. The schema models that shape as it is instead
of pushing the repo's domain/clusterIssuer pair onto it, and the list
entries stay open because they go verbatim into the Ingress spec.
The domain checkpoint ran over values.yaml and templates and came back
empty: ingress.domain does not exist in this chart at all.
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level. Pod-spec passthrough (volumes, volumeMounts,
nodeSelector, tolerations, affinity, ingress hosts/tls) stays open.
2. Null-safety for hardening, probes and resources (#99).
This chart's hardening is a documented deviation and the fallbacks
reproduce it exactly: the pod context is deliberately EMPTY, because
the official image must start as root (s6-overlay does its setup as
uid 0 and the image has no non-root user), and capabilities.add keeps
NET_RAW, without which device discovery (DHCP watcher, ping/arp
integrations) logs "Operation not permitted".
core.storage stays deliberately not null-safe: an erased block yields an
invalid PVC, which is the loud class #99 excludes.
The #82 limitation around the 1Password-backed config Secret is
untouched: Helm never sees the content, so it cannot be hashed, and the
values.yaml note about the manual rollout restart stays as it is.
Rendering with normal values is byte-identical to 5.0.0.
No ingress gate: this chart has no Ingress at all (SMB and NetBIOS are
exposed via NodePort), and ingress.domain appears neither in its
templates nor in its values.
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, including the entries of the samba.shares
list.
2. Null-safety for hardening, probes and resources (#99).
This chart's hardening is a documented deviation forced by the
ghcr.io/dockur/samba image, and the fallbacks reproduce it exactly
instead of restoring the repo standard: no runAs* values (the
entrypoint creates Unix users and smbd impersonates the share user per
session, both requiring a root start), readOnlyRootFilesystem stays
false (the entrypoint writes /etc/passwd, /etc/group, the healthcheck
symlink and the Samba TDBs), and capabilities.add keeps the six
entries a multi-user root file server needs. Restoring the standard
here would stop the file server from working.
The probes target the SMB TCP port directly, because SMB is not HTTP.
The users.conf / checksum-config limitation from #82 is untouched: Helm
never sees the Secret content, so it cannot be hashed, and the comment in
values.yaml documenting the manual rollout restart stays as it is.
Rendering with normal values is byte-identical to 5.0.0.