The null-safe fallback helpers pass values through fromYaml, which parses
every number as float64. Go's "%v" switches float64 to exponential
notation from about 1e6 upwards, so a bare byte count like 1048576 reached
multiplyQuantity as "1.048576e+06" and the render aborted with
unsupported resource quantity suffix "e+06" in "1.048576e+06"
Both the input and the non-integer product now go through a formatNumber
helper that forces decimal notation and trims the padding zeros; strings
such as 512Mi pass through untouched. This is the canonical fix agreed for
all charts - multiplyQuantity is the same copy everywhere, so deviating
here would create drift instead of a solution.
Measured against ci/voucher-vault/test-values.yaml on both request keys
that get a computed limit (memory, ephemeral-storage): 1048576 renders a
limit of "3145728" instead of aborting, and the fallback case 65536 is
unchanged at 196608.
Part of #157
BentoPDF computes entirely in the visitor's browser; the container only serves
static files through nginx. The chart is therefore close to cyberchef - same
nginx-unprivileged base, uid 101, port 8080, probes on "/" - with four
differences that shaped the design.
Runtime config.json. The one setting reachable at runtime is the tool list.
It is a static file in the middle of the web root, so it is mounted by
subPath: a directory mount would hide the whole application. subPath mounts
are never refreshed by the kubelet, which makes the checksum/config annotation
(#82) load-bearing rather than decorative. bentopdf.config is passed through
verbatim, like searxng's settings, so a key upstream adds tomorrow is a values
change and not a chart change.
Three env switches that would kill the pod. PORT, DISABLE_IPV6 and
ROBOTS_NOINDEX are implemented as "sed -i" against files in the image, run
under "set -e" in /docker-entrypoint.d/, and the base image's entrypoint
aborts when one of them fails. With readOnlyRootFilesystem: true the pod
therefore never starts instead of ignoring the switch - measured in k3d as
CrashLoopBackOff, exit 1, "sed: can't create temp file
'/etc/nginx/nginx.confXXXXXX': Read-only file system". None of the three is
offered as a first-class value; values.yaml names the failure picture and the
escape hatch (env block plus readOnlyRootFilesystem: false).
Writable paths were measured, not copied from the upstream chart.
/etc/nginx/tmp holds the pid file and /var/cache/nginx is where nginx creates
its temp directories at startup - without it the master aborts with
'mkdir() "/var/cache/nginx/client_temp" failed'. /var/run is deliberately not
mounted: the running pod proves it is unnecessary.
Everything else about BentoPDF is a build-time argument and out of reach for
any chart; values.yaml says so explicitly rather than leaving it to be
discovered. Two accepted consequences are named there too: some tools fetch
WASM modules from a public CDN at runtime, and the served pages carry a
canonical link to the vendor's site.
Fixes#174
The comment on the probe block said a probe on "/" would "risk" a 429 once
the limiter is on. It was measured against a reachable Valkey in k3d and it
does not risk it, it collects one: "/" answered 429 while /healthz answered
200. Comment only, no rendered change.
SearXNG as a stateless Deployment plus a settings ConfigMap, with the four CI
steps a new chart needs: publish workflow, change-app-version choice list,
ci/searxng/ fixtures and a renovate.json5 block.
Three points where the chart is not a cyberchef with another name:
* The image entrypoint writes settings.yml into /etc/searxng only when the
file is missing. The chart always mounts one by subPath, so the copy and
the random-secret sed never run and the container needs no write access
there - which is what allows readOnlyRootFilesystem: true.
* The signing key is a required OnePasswordItem, not a ConfigMap key. It
signs the settings cookie and the image-proxy URLs, and the entrypoint's
self-generating fallback cannot fire here, so without it the instance would
run on the literal "ultrasecretkey" - which webapp.py refuses with exit 1.
* All three probes use /healthz, the only path the bot detection lets through
unconditionally. Measured: with the limiter active, "/" answers 429 while
/healthz still answers 200.
ingress.domain is read a second time outside the Ingress template, for
SEARXNG_BASE_URL, and carries its own required() there - so it stays
mandatory with the Ingress switched off.
The Renovate versioning regex deviates from the plan on the issue and matches
the commit hash WITHOUT capturing it. Renovate parses a "build" group with
Number.parseInt, so a hash becomes the digits leading it; measured on
renovate 41.173.1 and 44.48.0, the planned form reports an image pushed
earlier the same day as an update to a newer one. Reasoning in the block and
in README.
The failure path prints container logs, and an app may print a value it was
given - gluetun logs its Wireguard address on startup. diagnose() now runs
through a filter that replaces the values of the declared REQUIRED_ENV
variables with the variable name. GitHub masks them as well, but that is the
platform's promise rather than this harness's, and the script runs locally too,
where nothing masks anything.
freesailarr was excluded from the install-test because gluetun never becomes
healthy without a real Wireguard key and no app container behind its kill
switch can become Ready. The green "Install-test freesailarr pass" was a
25-second SKIP on the most complex chart in the repo - a signal that proved
nothing.
With a CI-only Mullvad key in 1Password the reason is gone:
- ci/run-install-test.sh learns a general rule: ci/<chart>/fixtures.env.yaml is
rendered with envsubst before it is applied, restricted to the variable names
a chart declares in REQUIRED_ENV. A missing or empty declared variable fails
the run loudly and names the variable - it never skips the chart.
- The install-test job loads the key from 1Password Connect for freesailarr
only, following the pattern publish-helm-chart.yml documents: values via
steps.<id>.outputs.*, mapped into the step's own env, quoted, with ":?"
guards that name the missing op:// field before a cluster is created.
- Fixtures for freesailarr: three media claims, the gluetun secret template
with variable names only, firewallOutboundSubnets so the kill switch does not
block cluster DNS, and INSTALL_TIMEOUT=900 for nine containers.
freesailarr/Chart.yaml goes 3.0.0+up1.0.0 -> 3.0.1+up1.0.0. That bump is
deliberately empty of content: the install-test matrix follows changed chart
directories, and without it this PR could not run the test it adds.
Both are documentation only - the rendering is byte-identical to the previous
commit.
The *arr probes: with a UrlBase set, radarr/sonarr/prowlarr answer the bare
/ping with 307 and Location: /<urlbase>/ping, not 200. The probes keep passing
because the kubelet counts 200-399 as success (9/9 Ready, 0 restarts), so the
probe paths stay at /ping and are valid in both ingress shapes. Recorded so
nobody reads the 307 as a fault.
Bazarr under a sub-path without its manual base URL: it does not answer 404.
It returns its index page for every unknown path, so /bazarr/ and
/bazarr/<asset> both come back as 200 text/html of identical length - the page
loads, the assets silently arrive as HTML, and nothing looks like an error.
The content type is the tell, not the status code.
Adds a second, optional ingress shape and fixes the download target of
qBittorrent. The existing per-app-host shape stays the default and renders
unchanged.
Shared domain (#167)
Setting ingress.host moves every app without a host of its own into one
Ingress object with one path each (<app>.ingress.path, default /<app>), one
TLS secret and therefore one DNS name and one certificate for the whole
stack. A per-app host always wins and keeps its own object, so mixed
operation needs no extra values.
The apps differ in what they can do under a sub-path, and the chart follows
that rather than pretending they are alike:
- radarr, sonarr, prowlarr get <APP>__SERVER__URLBASE from the chart, but
only when their own env block does not already carry the variable.
- bazarr has no environment variable for its base path and stays one
documented manual step (Settings -> General -> Base URL).
- qbittorrent has no base path at all and renders a second object on the
same host with a prefix-stripping rewrite. It repeats the same TLS secret
name and deliberately omits the cert-manager annotation, so both objects
state their own TLS while only one certificate is requested.
- seerr has no base path either and cannot be rewritten, so it is pinned to
"/" and a fail refuses any other path. A second fail refuses two apps
claiming the same path.
qBittorrent save path (#166)
Without Session\DefaultSavePath qBittorrent writes to its built-in default
/config/Downloads - the 1Gi config volume. It fills within minutes, every
torrent goes to "errored" and qBittorrent can no longer write its own
metadata to BT_backup, so a restart in that state costs the torrent list.
The init container (renamed qbittorrent-config-interface ->
qbittorrent-config-seed, since it no longer only sets the interface) now
seeds qbittorrent.savePath, default /downloads. Unlike the interface binding
it is inserted ONLY when the key is absent and never replaced: a path changed
in the WebUI is a legitimate operator decision and must survive a restart.
This is the reason for the major bump: an installation that never set the
path writes somewhere else after the upgrade.
Documentation
NOTES.txt and the media block in values.yaml now spell out why a shared claim
with three subPaths does not produce hardlinks: the kernel compares the
vfsmount in do_linkat, not the superblock, so two bind mounts of one
filesystem are EXDEV - while stat reports the same device number on both
sides and suggests the opposite. The layout that would work (one claim, one
mount, categories as sub-directories) is named as a possibility; the chart
cannot express it today.
README.md gains the shared domain as the second admissible ingress shape of
the style guideline.
media.downloads, media.movies and media.tv rendered three fixed volume
entries, one per category. Two categories on the same existingClaim
therefore produced two pod volumes with an identical claimName, and such a
pod never starts: it stays in Init:0/1 without a single kubelet event
(measured on RKE2/Longhorn, RWO and RWX alike).
The volume list is now deduplicated by claim name. A new mediaVolumeName
helper names the entry after the first ENABLED category in the fixed order
downloads, movies, tv that carries the claim; the other categories mount
that same entry and differ only in their subPath. The restriction to
enabled categories matters because the three entries render under
different conditions - a canonical owner whose entry is not rendered would
be a dangling reference.
With three distinct claims the chart renders byte-identically to 2.0.1,
hence a patch bump.
Also corrected in the values documentation, after measuring instead of
assuming: the kubelet creates missing subPath directories itself, but they
inherit the claim root's mode, and fsGroup cannot be relied on to fix that
- Longhorn's fsGroupPolicy (ReadWriteOnceWithFSType) skips fsGroup on RWX
claims entirely. What has to hold is that the claim root is writable for
PUID/PGID. No chown or init container was added.
Closes the three-step round that carried the #99 null-safety to every Java
line. Pure restoration of the directory to the newest line:
version: 4.0.0+up2026.8.2.java17 -> 4.0.1+up2026.8.2.java21
appVersion: "2026.8.2-java17" -> "2026.8.2-java21"
minecraft.version: "1.20.1" -> "1.21.1"
Nothing changes in content, and that is not a claim but a measurement. Against
the java21 state published from #156, the ENTIRE chart tree differs in exactly
one line - the chart version - and .Chart.Version is not read by any template,
so the rendering is byte-identical:
4.0.0+up2026.8.2.java21 (published) -> this chart:
file tree: 1 differing line, Chart.yaml version
rendering: byte-identical (CI values, serverpack init container enabled)
main (java17) -> this chart:
exactly TWO differences, both the line selection itself -
image itzg/minecraft-server:2026.8.2-java17 -> :2026.8.2-java21 and
env VERSION 1.20.1 -> 1.21.1.
Why 4.0.1 and not 4.0.0: 4.0.0+up2026.8.2.java21 is already in the registry
from #156, and helm push overwrites an existing OCI tag without complaint. The
version check in validate-charts.yml does not catch this - it compares only
against github.base_ref, where the version would read java17, so a republished
4.0.0 would pass the gate and silently overwrite the published artifact. Tracked
as #160.
What this round achieves, measured across the three published line states:
templates/ and values.schema.json are byte-identical on java8 (f7047b8), java17
(2b22ce5) and java21 (this chart); values.yaml differs only in minecraft.version
and the comment naming the appVersion; and all three now carry initImage
alpine:3.24, so the bump that had reached only java21 is no longer missing on
the other two. All three lines therefore run the same hardening, the same three
exec probes, the same resources handling and the same null-safe fallbacks.
Left untouched on purpose: the float64 quantity finding (#157) and seccompProfile
(#161) - the latter has to be pulled through all three lines and has its own
unknowns in gosu and the serverpack init container.
Refs #99
Third of three line publications that carry the #99 null-safety to every Java
line. Templates and schema are the same for all three lines; only the
line-selecting fields change:
version: 4.0.0+up2026.8.2.java8 -> 4.0.0+up2026.8.2.java17
appVersion: "2026.8.2-java8" -> "2026.8.2-java17"
minecraft.version: "1.12.2" -> "1.20.1" (Java 17 runs
Minecraft 1.18-1.20.4)
What the diff does NOT show, same as for java8: the java17 line was last
published as 3.0.0+up2026.8.2.java17, and since then main also moved the
serverpack init image from alpine:3.22 to alpine:3.24 (commit 5a35ea4, which
landed on the java21 line only). That bump rides along here.
It is invisible in the diff against main, because relative to main the file
already says 3.24 - the java8 publication in #159 brought it there. Relative to
what a java17 release actually runs today it is a base-image change, so it is
named here rather than left to be discovered.
Measured against ci/minecraft-server/test-values.yaml with the serverpack init
container enabled, compared structurally (documents paired by kind/name,
recursive over keys) instead of line by line:
3.0.0+up2026.8.2.java17 (published) -> this chart:
exactly ONE difference, initContainers[0].image alpine:3.22 -> alpine:3.24.
main (java8) -> this chart:
exactly TWO differences, both the line selection itself -
image itzg/minecraft-server:2026.8.2-java8 -> :2026.8.2-java17 and
env VERSION 1.12.2 -> 1.20.1.
Hardening, the three exec probes, resources and the init.sh chown are unchanged,
which is what the null-safety was built to do: it changes what an ERASED block
renders, not what the defaults render.
At file level the line invariant holds exactly - against main's tree the only
differing files are Chart.yaml and values.yaml, in the three line fields plus
the comment naming the appVersion. _helpers.tpl, values.schema.json and both
templates are byte-identical across the lines.
The null-safety itself is not re-swept - #156 proved it on identical templates.
Confirmed once on this build that it carries: erasing both security contexts
renders byte-identical to the defaults.
Left untouched on purpose: the float64 quantity finding (#157), because
multiplyQuantity is the same copy in all 21 charts and a single-chart fix would
create drift, and seccompProfile (#84), which changes runtime behaviour and
belongs in its own change.
Refs #99
Second of three line publications that carry the #99 null-safety to every Java
line. The chart is published once per line, the templates and the schema are
the same for all three, and only the directory's line-selecting fields change:
version: 4.0.0+up2026.8.2.java21 -> 4.0.0+up2026.8.2.java8
appVersion: "2026.8.2-java21" -> "2026.8.2-java8"
minecraft.version: "1.21.1" -> "1.12.2" (Java 8 runs
Minecraft <= 1.16.5)
What the diff does NOT show, and what this commit therefore has to say out
loud: the java8 line was last published as 3.0.0+up2026.8.2.java8, and since
then main also moved the serverpack init image from alpine:3.22 to alpine:3.24
(commit 5a35ea4, which landed on the java21 line only). That bump rides along
here. It is invisible in this PR's diff, because relative to main the file
already says 3.24 - but relative to what a java8 release actually runs today it
is a base-image change, and the cluster side has to know.
Measured, rather than assumed. Rendered against ci/minecraft-server/test-values
with the serverpack init container enabled and compared structurally (documents
paired by kind/name, recursive over keys) instead of line by line:
3.0.0+up2026.8.2.java8 (published) -> this chart:
exactly ONE difference, initContainers[0].image alpine:3.22 -> alpine:3.24.
main (java21) -> this chart:
exactly TWO differences, both the line selection itself -
image itzg/minecraft-server:2026.8.2-java21 -> :2026.8.2-java8 and
env VERSION 1.21.1 -> 1.12.2.
Nothing else moves: the hardening, the three exec probes, the resources and the
init.sh chown are unchanged, which is what the null-safety was built to do (it
changes what an ERASED block renders, not what the defaults render).
The null-safety itself is not re-swept here - #156 proved it on identical
templates. Confirmed once on this build that it carries: erasing both security
contexts renders byte-identical to the defaults.
The float64 quantity finding (#157) is deliberately untouched, per its own
issue: multiplyQuantity is the same copy in all 21 charts and a single-chart
fix would create drift.
Refs #99
The last open gap of #99. minecraft-server was the one chart of 21 where an
erased security context removed the hardening in silence: it never got the
defaultedDict helper, the StatefulSet rendered .Values.securityContext.pod
straight through toYaml, and the schema typed securityContext.pod as a strict
"object" without required. Measured against ci/minecraft-server/test-values.yaml
on 3.0.1:
--set securityContext.pod=null --set securityContext.container=null
-> helm exit 0, no schema error, "securityContext: null" for BOTH the pod and
the container - no runAsNonRoot, no readOnlyRootFilesystem, no dropped
capabilities, in a chart with a PVC.
Bitter place for it: the README lists this chart as the pilot for #68. It got
its schema early and was therefore never caught by the later null-safety wave.
Both halves are added, because they cover different cases. For a key WITH a
chart default Helm's coalescing deletes the null before validation ever sees
it, and only the defaultedDict fallback catches that; for a key WITHOUT one
(server.resources.limits) the null survives into validation, where the strict
"object" aborted the render. Now widened to ["object","null"].
The fallback table reproduces THIS chart's defaults, deviations included:
uid/gid pinned to 1000 (the image's minecraft user - starting as it directly
takes the entrypoint's non-root branch, which skips the recursive chown of
/data and the write to /etc/nsswitch.conf that a read-only root filesystem
would reject), fsGroupChangePolicy OnRootMismatch (a recursive chown of a 32Gi
world on every start costs minutes), the explicit empty capabilities.add list,
and all three probes as exec probes running the image's mc-health script - this
image has no HTTP endpoint, so a probe default rebuilt as httpGet would leave
the workload with no working probe at all. The startup budget stays 15s x 80.
server.resources goes through the same helper while keeping its memory request
deliberately unset, so it is still derived from the JVM heap.
Also, the same class one level down: init.sh chowned the migrated serverpack to
a hardcoded 1000:1000 while the uid it has to match was overridable. Both reads
now go through the same helper, so they cannot disagree. With the chart defaults
the rendered script is unchanged, so checksum/config does not move and no
existing pod rolls.
minecraft.resources.storage and .memory stay deliberately not null-safe: an
erased value yields an invalid PVC or an unparseable quantity, which is the loud
class #99 excludes.
Evidence, all against ci/minecraft-server/test-values.yaml unless noted:
- the reported call now renders the defaults, byte-identical to the
unmodified rendering. Fallback, not rejection.
- rendering with securityContext, all three probes and server.resources set
to null is byte-identical to rendering the chart defaults - which is what
keeps the table and values.yaml in sync instead of leaving it to discipline.
- no regression: rendering before and after the change is byte-identical, on
the CI values, on the chart defaults, and with curseforgeServerpack enabled
(the init-container path the CI values do not exercise). Confirmed
structurally too, not only by raw diff.
- partial nulling, 14 keys swept one at a time. On 3.0.1 twelve of them
changed the rendering silently, including capabilities (the container kept
every default capability instead of dropping ALL) and requests (cpu and
ephemeral-storage disappeared entirely). On 4.0.0 all fourteen render
identical to the defaults.
- explicit false and 0 still win over the defaults; a scalar in a mapping slot
is still rejected, now naming the values path.
Major bump: erasing one of these blocks used to change the rendered workload
and now does not, which is a runtime behaviour change. appVersion is untouched.
Refs #99
The previous wording named freesailarr as a documented exception but closed on
"the field is omitted, lint still exits 0", which reads as a general licence to
drop the icon. The rule is now stated with its criterion and its reach: the
exception exists because the chart has no single upstream whose logo the icon
could be, it is the only chart in the repo without one, and a single-upstream
chart missing an icon is a missing icon rather than an exception.
Retrofits the repo-wide groundwork that #59 deliberately left out to keep the
reviewed scope. Chart 2.0.0+up1.0.0 - a major, because seccompProfile changes
runtime behaviour; appVersion stays the freely chosen marker 1.0.0.
values.schema.json (#68). The largest schema in the repo: nine app blocks with
probes, resources, security contexts, env and image. additionalProperties is
false on every chart-owned level; the objects that go verbatim into the pod
spec stay open. It rejects a mistyped app name, a mistyped key at any depth and
ingress.domain - the key #59 removed, which until now would have been accepted
in silence.
Null safety (#99), both halves, because they cover different cases. The
defaultedDict helper catches a key WITH a chart default, where Helm's
coalescing deletes the null before the schema ever sees it; the widened
["object","null"] types catch a key WITHOUT one, where the null survives into
validation. Measured on the old chart: radarr.securityContext: null rendered
"securityContext: null" - the whole hardening gone - and
securityContext.capabilities: null dropped the capability block entirely, so
the container kept every default capability instead of dropping ALL. Both
render green.
The fallback table reproduces THIS chart's defaults including its deviations:
the five linuxserver.io containers keep runAsNonRoot unset and a writable root
filesystem so their s6 init can drop to PUID/PGID, flaresolverr keeps its
numerically pinned uid, gluetun keeps NET_ADMIN, and recyclarr keeps its exec
probes. Restoring the "clean" standard there would put the linuxserver
containers back on the image's uid 911 - the failure calibre-web-automated hit.
The sync between table and values.yaml is not left to discipline: rendering
with every block of the silent class explicitly set to null is byte-identical
to rendering the defaults, which is how a drift shows up.
seccompProfile RuntimeDefault (#84) in the pod context, so it covers the init
container too. Trivy's KSV-0104 drops from ten findings to none.
NOTES.txt (#43) covering what actually bites: the pod is not Ready until the
tunnel is up, the two fields the 1Password item needs, the warning when
firewallOutboundSubnets is unset, the immutable volumeClaimTemplates, and the
measured fact that imports do not hardlink across the media categories.
Also, from point 5 of the plan: checksum/config does not apply - the chart has
no ConfigMap - but checksum/secret-ref does, and was missing. Helm never sees
the secret's contents, yet it does see which op:// item the OnePasswordItem
points at, so repointing secrets.gluetunSecretRef now restarts the pod instead
of leaving the old credentials running.
README: the #43 status line was stale (it still claimed only homeassistant
ships NOTES.txt), and the mandatory-icon rule now records freesailarr as a
documented exception - #59 dropped the icon and did not amend the rule.
linuxserver/qbittorrent still carries 14.3.9 and 20.04.1 from earlier tagging
schemes. Docker versioning sorts both above every real release, so Renovate
offers them as major updates - PRs #152 and #153 are exactly those, and they
would have to be closed by hand again after every lookup.
They cost more than the noise. Renovate fills the major bucket once, so while a
stray occupies it the genuine major is never offered at all: measured from
currentValue 4.6.4, the unconstrained lookup returns 4.6.7 and 20.04.1, and
never mentions 5.2.3.
Both are excluded by name. A bound such as "<10" would reject a genuine major
sooner or later, whereas two finished artifacts cannot grow and so cannot mask
a release that does not exist yet. A shape-based filter was tried first and
measured out: requiring a non-padded minor drops 20.04.1 and simply promotes
14.3.9 into its place.
Verified with renovate 44.41.0 (dry-run=lookup) against the live registry:
currentValue 5.2.1 -> [5.2.3 (patch)], both strays gone
currentValue 4.6.4 -> [4.6.7 (patch), 5.2.3 (major)]
The second fixture is the guard the nginx rule taught us to run - it proves the
filter does not silently empty the update list. A full run over the real
repository with the real config confirms it in place: qbittorrent offers 5.2.3
only, nginx is unchanged, and no docker dependency carries a skipReason or a
warning.
docker.io/busybox and docker.io/library/busybox resolve to the same image on
Docker Hub, and the short form is the spelling the rest of the fleet will use
when the remaining unqualified official images (paperless-ngx, matrix-synapse,
minecraft-server) are qualified in the #84 follow-up.
Verified: the rendered output differs in exactly this one line, and a Renovate
lookup against the real chart files (44.41.0, official container, dry-run
lookup) still extracts all ten images through helm-values with no skipReason
and no warnings - docker.io/busybox included, whose candidate list stays empty
because 1.38.0 is current.
main qualified five charts' image references in 6c73ef8 on the same day this
chart would otherwise arrive carrying the pattern that was just cleared out.
The seven Docker Hub names now say where they come from: qmcgaw/gluetun, the
five linuxserver.io images and the busybox init image, the last as
docker.io/library/busybox since it is an official image. The three ghcr.io
names were already qualified - checked rather than assumed.
Verified with Renovate 44.41.0 in the official container (RENOVATE_PLATFORM=
local, RENOVATE_DRY_RUN=lookup) that the prefixed form still resolves under the
built-in helm-values manager, since that manager is the only thing keeping
these images updated: it extracts all ten, datasource docker, no skipReason and
no warnings, and returns concrete candidates for eight of them. A control run
against the unqualified form produced byte-identical results, so the prefix
changes nothing about what Renovate offers.
Trivy's trusted-registry check (KSV-0125) drops from ten findings to none, which
is what the registry list in ci/trivy/data/ksv0125.yaml documents as the point
of writing images this way.
Chart metadata: the icon was gluetun's logo and the description opened with a
list of four of the nine projects, both suggesting a "main app" the stack does
not have. The description now names none of them, and the icon field is omitted
rather than filled with another project's logo - every other chart's icon is its
one upstream's, and this chart has no such upstream. helm lint --strict notes
"icon is recommended" as INFO and still exits 0.
Rendered output differs in exactly the seven image prefixes and nothing else.
The previous commit moved every image tag into values.yaml but left appVersion
described as a marker "for the gluetun line", to be updated together with
gluetun.image.tag. That still asserts a coupling the chart does not have, and
the value v3.41.1 confirmed it at a glance.
appVersion is now 1.0.0 (version 1.0.0+up1.0.0): the chart's own version
marker, freely chosen and tied to no upstream. The texts say that instead of
the old coupling in Chart.yaml, values.yaml and the two README passages. The
README's remaining true statement is kept but generalized - renovate-bump-chart
bumps the patch level on an unchanged appVersion for all nine images alike,
which is not a gluetun special case.
Also corrected on the way: the busybox init image comment claimed it was
"pinned independently of appVersion (which tracks gluetun)". Neither half holds
any more - appVersion tracks nothing, and every image in the chart is pinned
the same way.
No functional change: the rendered gluetun image is qmcgaw/gluetun:v3.41.1
before and after.
The chart bundles nine independently versioned upstreams, and eight of them
were already pinned in values.yaml while only gluetun took its tag from
appVersion. That split had no upside: it made gluetun the one image Renovate
could not reach through the built-in helm-values manager, and it implied a
"main app" the stack does not have.
gluetun now carries gluetun.image.repository/tag like every other image, and
the StatefulSet renders it from there. No tag comes from appVersion any more.
Renovate picks all nine up through helm-values - the manager the repo already
relies on for sidecar images - and ci/renovate-bump-chart.sh bumps the chart
version with each update.
Consequences, all documented where they bite:
- renovate.json5 deliberately gets no app-version block for this chart. One
would either duplicate the helm-values updates or rewrite an appVersion that
selects no image. The absence is commented so it does not read as an
oversight.
- freesailarr leaves the change-app-version.yml choice list. That workflow
rewrites appVersion and republishes, which for this chart would change no
deployed image at all.
- appVersion becomes a hand-maintained marker for the gluetun line. Since
renovate-bump-chart.sh bumps the patch level on an unchanged appVersion, a
gluetun update has to be paired with a hand edit of appVersion, or the +up
suffix stops describing what the chart deploys. Noted in Chart.yaml,
values.yaml and README.
FIREWALL_INPUT_PORTS does not open the app ports inside a pod network. gluetun
detects the pod's own subnet and inserts an INPUT accept for it ahead of the
per-port rules, and every packet arriving at the pod IP matches that rule by
destination. Measured with the packet counters: 50 fresh requests moved the
subnet rule from 306 to 356 packets while every --dport rule stayed at 0 since
container start. A port that listens but is not in the list (gluetun's control
server on 8000) is reachable from another pod all the same.
The variable is kept - it is the explicit statement of which ports are meant to
be reachable, and the subnet detection is gluetun's behaviour rather than a
guarantee of this chart - but the comments no longer claim the traffic would be
dropped without it.
Second correction, same run: the /tmp/gluetun emptyDir fixed the "open ...
permission denied" failure, but gluetun also chowns the file, which needs
CAP_CHOWN. With all capabilities but NET_ADMIN dropped that logs "chown
/tmp/gluetun/ip: operation not permitted" on every VPN restart. The file is
written and the public IP is persisted, so this is a log line rather than a
failure, and granting CHOWN to silence it would widen the container's
capabilities for nothing. Noted where the mount is declared.
The runtime test contradicted the assumption behind the change. Kubernetes
turns every volumeMount into its own bind mount, and link()/rename() across two
mount points fails with EXDEV even when both sides are the same claim on the
same device: in the radarr container /downloads and /movies both report device
65025 and resolve to the same PV directory, yet linking between them fails with
"Cross-device link", while the identical link succeeds when the claim root is
mounted once and the categories are sub-directories under it.
It is not a property of subPath - two plain mounts of the same claim without
any subPath fail the same way, so the chart never supported cross-category
hardlinks and this change does not regress anything. But the comment claimed a
benefit the chart does not deliver, so it now states the measurement instead:
subPath buys the layout (one claim, one sub-directory per category), not
hardlinked imports. Getting those needs a single mount containing all
categories, which is a change to the mount layout rather than a values setting.
Two defects kept the publish broken after the load-secrets-action v5 bump
in #140.
v5 no longer exports the loaded values as environment variables for later
steps the way v4 did; only the step outputs carry them. Every
"${{ env.JK_CR_* }}" reference therefore resolved to an empty string from
that bump on, and nothing reported it - the loading step logs "Populating
variable" and stays green either way. #149 moved three of the four
references to steps.<id>.outputs but left "-u ${{ env.JK_CR_USERNAME }}"
on the old form, and piped the username instead of the token into
--password-stdin.
The second defect is why this surfaced as a terminal error: an empty value
collapsed into a missing argument instead of failing. With an empty
username, "-u" swallowed the following "--password-stdin", helm stopped
reading the password from stdin, prompted interactively and died with
"inappropriate ioctl for device".
All four values now come from steps.<id>.outputs, are mapped into each
step's own env block and used as quoted shell variables guarded with ":?".
An empty or unset value aborts the job naming the op:// path to fix,
quoting keeps it from shifting arguments, and the token no longer appears
on the command line.
ci/run-trivy-config.sh renders every changed chart before scanning it and exits
non-zero when the render fails - which it does for freesailarr, because the
chart is excluded from the install-test and therefore has no
ci/freesailarr/test-values.yaml to fall back on. The scan job would go red
without ever scanning anything.
The file supplies only the values the chart declares required (secret ref,
cluster issuer, the three media claims and one ingress host per app, since the
chart has no global ingress.domain) and nothing that would alter the security
posture the scan is meant to look at.
Three review changes to the freesailarr chart.
Ingress: the chart no longer derives hosts as <app>.<ingress.domain> from one
Ingress object. The global ingress.domain is gone; every app carries its own
<app>.ingress.host and renders its own Ingress <release>-<chart>-<app>-ingress
with its own TLS secret tls-<release>-<chart>-<app>-ingress. The hosts are
therefore independent and may live in unrelated domains. The host is required,
but the required() call sits inside the loop over the *enabled* app ingresses,
so switching an app's ingress off must not make the chart demand a host for it.
clusterIssuer, className and annotations stay global defaults and are
overridable per app; an explicitly set per-app value wins (nil check instead of
"default", so an empty override is honoured).
Media: media.<category>.subPath mounts a sub-directory of the claim instead of
its root, default null (claim root = previous behaviour). The same
existingClaim may back all three categories with different subPaths. That is
the point rather than cosmetics: Radarr and Sonarr import by moving, and a move
within one filesystem can be a hardlink instead of a copy - across volume
boundaries every import duplicates the file.
Services: the collective Service with named ports is replaced by one ClusterIP
Service <release>-<chart>-<app>-service per enabled app that listens on a port,
flaresolverr included; recyclarr serves no port in daemon mode and gets none.
Each ingress points at its own app's service. All containers share gluetun's
network namespace, so the services differ only in their port - they provide
stable per-app DNS names, not network separation. The StatefulSet's governing
serviceName no longer names a removed object.
gluetun logs "clearing public IP data: open /tmp/gluetun/ip: permission
denied" every few seconds and never persists its public IP. It creates
/tmp/gluetun with mode 0644, i.e. without the execute bit needed to enter
the directory; it runs as root, but with all capabilities except
NET_ADMIN dropped there is no DAC_OVERRIDE left to paper over the missing
bit. An emptyDir provides the path with usable permissions.
qBittorrent had no network interface configured, so libtorrent listened
and announced on every interface of the shared pod network namespace -
including eth0 (the pod IP) and loopback. gluetun's kill switch drops
exactly those packets, so trackers answered with "skipping tracker
announce (unreachable)" or timed out, no peers were found and torrents
stalled at 0 bytes in stalledDL. Beyond the stall this is a leak: without
the binding qBittorrent also listens on the pod IP, i.e. reachable past
the tunnel.
The setting only exists in the persisted config file, which qBittorrent
creates itself on first start, so an init container seeds it before the
app comes up. The edit is idempotent and preserves existing user
settings: it replaces the keys if present, inserts them into the
[BitTorrent] section if missing, and creates file or section if needed.
The interface is configurable via qbittorrent.networkInterface (default
tun0); emptying it drops the init container.
With readOnlyRootFilesystem the s6-overlay init cannot rewrite
/etc/passwd, so the containers silently kept the image's built-in
uid/gid 911 instead of the configured PUID/PGID (verified at runtime) -
breaking ownership on the shared media volumes. Drop the now-unneeded
/run and /tmp emptyDirs; deviation documented in values.yaml.
Short tunnel re-negotiations recover internally; a kubelet restart only
prolongs the outage while the kill switch already blocks all egress.
Liveness budget 60s, readiness stays at 30s.
Replace the upstream-recommended postStart hook, which runs
concurrently with the entrypoint and loses the race (observed: it
deleted the ip rule the restarted gluetun had just created, leaving the
container in a permanent CrashLoop behind the kill switch). A command
wrapper now removes stale table-51820 rules and the leftover tun
device deterministically before exec'ing /gluetun-entrypoint, covering
container restarts after unclean exits.
An uncleanly exited gluetun leaves its ip rule in the shared pod
network namespace; the restarted container then fails forever with
'adding ip rule 101: file exists' while the kill switch keeps blocking
all egress. Apply the upstream-recommended Kubernetes fix: a postStart
hook dropping table-51820 rules.
The image copies and patches the chromedriver binary to the hardcoded
path /app/chromedriver at startup (no env override exists) and writes
chromedriver caches under /app, so readOnlyRootFilesystem cannot hold;
deviation documented in values.yaml. Reverts the HOME=/tmp workaround,
which only covered the cache path, not the binary patching.
The image's home is /app on the read-only root filesystem, but
flaresolverr writes chromedriver caches under $HOME (/app/.local);
pointing HOME at the /tmp emptyDir keeps the root filesystem read-only.
The image declares its non-root user by name only, which the kubelet's
runAsNonRoot check cannot verify (CreateContainerConfigError); pinning
the uid/gid numerically satisfies the check.
The issue plan listed qmcgaw/gluetun:3.41.1, but the published tag is
v3.41.1. appVersion is the exact image tag per the style guide, so
appVersion becomes v3.41.1 and the chart version 1.0.0+upv3.41.1
(precedent: matrix-synapse, satisfactory, calibre-web-automated).
Sets seccompProfile.type: RuntimeDefault in the pod security context of
minecraft-router, outline, urlaubsverwaltung, zipline, voucher-vault,
satisfactory, patchmon and calibre-web-automated, 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.
Five of the eight also get the docker.io/ prefix on their previously
unqualified image references, so the pull no longer depends on the
runtime's default-registry setting: itzg/mc-router,
outlinewiki/outline, urlaubsverwaltung/urlaubsverwaltung,
l4rm4nd/vouchervault (twice, app and init container) and
guacamole/guacd:1.6.0. zipline, satisfactory and calibre-web-automated
were already qualified with ghcr.io.
Verified in a throwaway k3d cluster against real application work, not
just a Running pod, and with Seccomp: 2 (filter mode) read from
/proc/<pid>/status inside each container:
- minecraft-router: a Minecraft handshake for a mapped domain travels
through the router to a backend and the backend's status payload comes
back to the client.
- outline: all migrations apply (45 tables), OIDC discovery succeeds and
the server-rendered page answers 200 with <title>Outline</title>.
- urlaubsverwaltung: Liquibase applies 79 changesets (35 tables), the app
seeds its settings and vacation types, /login answers 200.
- zipline: 27 migrations applied, then a full round trip - first user
created, login, token, upload, and the file downloaded back byte-exact
from the PVC.
- voucher-vault: the init container's collectstatic output is served
(200, 22 KB), Django has its 22 tables and uWSGI answers 200 with
<title>VoucherVault</title>.
- satisfactory: application behavior is NOT proven - the image is
multiple GB and pulls 10 GB+ of game files on first start, which is why
the chart sits in ci/excluded-charts.txt. What is proven is that the
rendered pod securityContext is accepted server-side and yields
Seccomp: 2 with uid/gid 1000 on a substitute image.
- patchmon: both containers run under the pod profile and both report
Seccomp: 2. The server applies 47 migrations (44 tables), connects to
Postgres and Redis and serves its dashboard; guacd answers a real
Guacamole "select rdp" instruction with its full args list, over the
same 127.0.0.1:4822 path the chart wires up.
- calibre-web-automated: the readOnlyRootFilesystem: false deviation is
unaffected - id abc is still uid 1000 (not the 911 the image falls back
to in read-only mode), /config and the Calibre metadata.db are owned by
1000, and both the root s6 init and the uid-1000 application report
Seccomp: 2. calibredb 9.1 runs and adds a book. Its IPC worker prints a
traceback while doing so, but an unconfined control pod on the same
image prints the same class of traceback, so it is not the filter.
trivy config now reports "0 to act on" for all eight; the untouched
samba still shows KSV-0104 and KSV-0030, which confirms these charts are
what closed the findings.
No values.schema.json needed changing: in all eight schemas
securityContext.pod is type ["object", "null"] with no property
restrictions, so the new key validates as it stands. The #99 null paths
still hold - nulling securityContext or securityContext.pod falls back to
the defaults including the new one.
Renovate resolves the prefixed guacd reference: a local dry-run lookup
(renovate 44.40.1, --platform=local --dry-run=lookup) reports depName
docker.io/guacamole/guacd against registryUrl https://index.docker.io
with lookupName guacamole/guacd, currentVersion 1.6.0 and its real
release timestamp, with no warnings. The other four prefixed references
live in templates, which Renovate never reads - they are tracked by the
customManagers via appVersion in Chart.yaml with a depName hardcoded in
renovate.json5, so they are unaffected.
Major bump for all eight: runtime behavior changes.
Refs #84
The rationale called the alpine switch a proposal from the issue text.
A later comment on #84 commissions it as a check with a defined scope
(nginx.conf compatibility, port 8080, uid 101, /tmp redirection) to be
done alongside the seccompProfile PR for this chart. The outcome is open,
the check is not - the constraint has to survive either way.
The anchored form rejected any tag with a suffix. Docker versioning only
offers candidates carrying the current tag's suffix, so the day the
delegation sidecar moves to a slimmer base image (#84, still open) every
candidate would fail the filter and nginx would stop receiving updates -
without an error, a warning, or a red dashboard.
Verified against currentValue 1.28.0-alpine: anchored form returns
"updates": [] and "warnings": [], the suffix-tolerant form proposes
1.30.4-alpine and still refuses the newer 1.31.x mainline.
nginx numbers its release branches by minor: even is stable, odd is
mainline. PR #139 proposed 1.30.4 -> 1.31.4, which would have moved the
Synapse delegation sidecar onto mainline without that ever being a
decision. allowedVersions now permits even minors only; patch updates
inside the stable branch still come through and still automerge.
The two open Renovate PRs (#138 alpine 3.22 -> 3.24, #139 nginx 1.30.4 ->
1.31.4) never automerged because both are minor updates while the rule
only covered patch and digest. Sidecar images barely move in patch steps,
so patch-only would practically never fire.
Automerging a chart update publishes it, so the install-test can no
longer report failures without blocking: continue-on-error is gone and
issue #22's stabilization phase ends here. Majors stay manual.
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.
Three changes, all about values that do not do what they promise:
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level.
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 from rallly ran over values.yaml and templates
and came back empty here: ingress.domain is read nowhere but in the
Ingress template. Unlike rallly, zipline, voucher-vault and outline,
this gate therefore frees both domain and clusterIssuer, and no extra
required() is needed.
3. Null-safety for hardening, probes and resources (#99).
This chart's hardening is a documented deviation from the repo
standard, and the fallbacks reproduce it exactly instead of restoring
the standard: no runAs* values (s6-overlay boots as root and drops to
PUID/PGID 1000), readOnlyRootFilesystem stays false (read-only mode
disables the PUID/PGID remapping, silently moving the runtime uid from
1000 to 911 and breaking existing volume data), and capabilities.add
keeps its six entries that the s6 init and supervision need. A
"cleaned up" standard default here would not be tidier, it would be an
outage.
cwa.storage stays deliberately not null-safe: an erased value yields an
invalid PVC, which is the loud class #99 excludes.
Rendering with normal values is byte-identical to 4.0.0.
No ingress gate: this chart has no Ingress at all (game and messaging
ports 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. Like teamspeak-server, this chart keeps env,
probes and securityContext at the TOP level, and the schema mirrors
that shape.
2. Null-safety for hardening, probes and resources (#99). What a rebuilt
default must not lose here:
- liveness and startup run the image's own healthcheck script via
exec. It queries the server's HTTPS API on SERVERGAMEPORT, so it
survives a port override, which a tcpSocket default could not.
- readiness keeps the named "api" port.
- the resources default keeps limitFactor 2 rather than the repo-wide
3, which is what holds the memory limit at the published 8Gi.
- the pod context keeps fsGroupChangePolicy OnRootMismatch, which is
what avoids a recursive chown of multi-GB game files on every start.
server.storage stays deliberately not null-safe: an erased value yields
an invalid PVC, which is the loud class #99 excludes.
This chart is excluded from the CI install-test, so the local proof is
the only one. Rendering is byte-identical to 4.0.0 with default values
and with the production bundle values, and the rendered manifests were
additionally validated by the cluster's API server via a server-side
dry-run.
No ingress gate: this chart has no Ingress at all (voice/file-transfer
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. Note that this chart keeps replicas,
resources, probes and securityContext at the TOP level rather than
under a chart-named key, so the schema mirrors that shape.
2. Null-safety for hardening, probes and resources (#99). The rebuilt
probes keep the "file-transfer" port name: the voice service is UDP
and cannot be probed, so that TCP port is the only thing a probe can
target - a default probe without it would leave the workload with no
working probe at all.
The container defaults keep capabilities.add as an empty list, exactly
as values.yaml has it, so an erased block restores today's rendering
byte for byte rather than a cleaned-up variant.
storage stays deliberately not null-safe: an erased block yields an
invalid PVC, which is the loud class #99 excludes.
Rendering with normal values is byte-identical to 4.0.0.
No ingress gate: this chart already gates its Ingress correctly. Two
changes plus one guard the existing gate turned out to need.
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level.
2. Null-safety for hardening, probes and resources (#99). All three
probes keep /_health when rebuilt: it is Outline's only health
endpoint, and "/" returns 500 as well when the database is down, so
there is no dependency-free path to fall back to.
3. ingress.domain is required() where the StatefulSet reads it. It feeds
the URL env var - Outline's public URL for generated links and the
OIDC redirect URI - and did so without a guard. With the existing gate
and no domain it rendered "https://", breaking logins silently. That
hole predates this change; it now fails loudly.
outline.storage stays deliberately not null-safe: an erased block yields
an invalid PVC, which is the loud class #99 excludes.
Apart from an added comment, the rendered manifest is unchanged.
No ingress gate: this chart already gates its Ingress correctly. Two
changes plus one guard that the existing gate turned out to need.
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level. Lists that go verbatim into the pod spec
(ports, storage.volumes, storage.volumeMounts) stay open.
2. Null-safety for hardening, probes and resources (#99). Two specifics
of this chart:
- The probes were wrapped in "if" guards, so an erased probe block did
not render "livenessProbe: null" - it removed the probe entirely and
silently. The guards are gone; an empty block now means chart
defaults, so the workload keeps its probes.
- The rebuilt probes keep the Host: 127.0.0.1 header. Django's
ALLOWED_HOSTS only accepts 127.0.0.1 and the configured DOMAIN, so a
probe without it would get 400 and the pod would never be ready.
3. ingress.domain is required() where it is read. The dependency lives in
values.yaml, not in a template: the DOMAIN env entry is a tpl string
feeding Django's ALLOWED_HOSTS. With the existing gate and no domain
the app rendered DOMAIN: "" and answered 400 to every real request -
silently, and already before this change. It now fails loudly.
Apart from an added comment, the rendered manifest is unchanged.
Three changes, all about values that do not do what they promise:
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, so a key the chart does not read is rejected
at render time instead of being silently ignored.
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.
As in rallly, ingress.domain is not a pure Ingress value: the
StatefulSet also feeds it to CORE_DEFAULT_DOMAIN, the app's default
domain for generated share links, and did so without required(). The
guarantee only came from the ungated Ingress template, so the gate
would have handed out unusable links silently. That read is now
required() as well; only clusterIssuer becomes unnecessary.
3. Null-safety for hardening, probes and resources (#99): an erased block
fell back to "securityContext: null" (unhardened), a vanished probe or
a container without resource accounting. The rebuilt liveness probe
keeps "/" rather than /api/healthcheck, so a database outage still does
not restart the pod.
config stays deliberately not null-safe: storage and storageClass are
required(), which puts an erased block in the loud class #99 excludes.
Apart from an added comment, the rendered manifest is unchanged.
Three changes, all about values that do not do what they promise:
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, so a key the chart does not read is rejected
at render time instead of being silently ignored.
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.
ingress.domain is NOT a pure Ingress value here - it is also the app's
public base URL (NEXT_PUBLIC_BASE_URL). Gating alone would therefore
have introduced a new silent failure: with the Ingress switched off and
no domain, the deployment rendered value: "https://" and every
generated link would be broken. The deployment's read of the value is
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): an erased block
fell back to "securityContext: null" (unhardened), a vanished probe or
a container without resource accounting. Restoring the pod context
matters especially here, because its fsGroup is what makes the Next.js
cache emptyDir group-writable for the container.
rallly.cache is deliberately NOT null-safe: cache.path is required(), so
an erased block fails loudly and belongs to the loud class #99 excludes.
Apart from an added comment, the rendered manifest is unchanged.
Records the reasoning inline, matching the comment the gotenberg chart
carries, so the next edit does not quietly switch back to the raw helper and
drop the paragraph whenever a resources: block has been erased.
Tika has no Ingress, so the notes lead with the cluster-internal URL, the
Service-port-80 to container-port-9998 mismatch a consumer has to get right,
and /parsers as the direct answer to "why did this file come back empty".
Two warnings come from measuring the running container rather than from
reading the values file:
- The image tag has to keep its "-full" suffix. That variant is where
Tesseract 5.5.0 and the extra parsers live; the plain image carries no
tesseract binary at all, so dropping the suffix does not fail loudly, it
just stops OCR and returns empty text for scans.
- The JVM never sees the container limit. It starts without -Xmx and derives
its heap from the limit at MaxRAMPercentage=25, so the number worth sizing
against is a quarter of it.
The heap figure is computed from the effective limit through the chart's own
null-safe effectiveResources helper, so an erased resources: block still
prints the defaults the container actually receives instead of dropping the
paragraph. Refs #43.
Retrofit of #99 for a chart that already carries a values.schema.json,
plus the schema type change that goes with it.
An override written without children is YAML null. Where the chart
defines a default, Helm's coalescing deletes the default before schema
validation runs, so the schema never sees the null and cannot reject it:
the container rendered "securityContext: null" - unhardened, healthy,
silent. Where the chart defines no default (resources.limits), the null
survives into validation and a strict "object" type aborted the render.
The defaultedDict helper from #96 handles the first case; widening the
types of exactly these blocks to ["object", "null"] handles the second.
All other keys stay strict, additionalProperties: false is untouched.
The probes address the container port by name, so a rebuilt default probe
needs no further context.
Rendering with normal values is byte-identical to 5.0.0.
Retrofit of #99 for a chart that already carries a values.schema.json,
plus the schema type change that goes with it.
An override written without children is YAML null. Where the chart
defines a default, Helm's coalescing deletes the default before schema
validation runs, so the schema never sees the null and cannot reject it:
the container rendered "securityContext: null" - unhardened, healthy,
silent. Where the chart defines no default (resources.limits), the null
survives into validation and a strict "object" type aborted the render.
The defaultedDict helper from #96 handles the first case; widening the
types of exactly these blocks to ["object", "null"] handles the second.
All other keys stay strict, additionalProperties: false is untouched.
The probes address the container port by name, so a rebuilt default probe
needs no further context.
Rendering with normal values is byte-identical to 8.0.0.
Gotenberg has no Ingress, so the notes lead with the cluster-internal URL and
point out that the Service listens on 80 while the container listens on 3000
— the mismatch a consumer such as paperless-ngx has to get right — plus the
port-forward command for reaching it by hand.
Three findings from measuring the running chart went in as warnings:
- The chart passes no command-line flags, so Gotenberg's basic auth, OIDC,
SSRF guards and request timeout cannot be enabled through values at all.
- Memory, not disk, is the first limit to hit: at the default limit a large
document gets the container OOM-killed, the caller sees the connection
dropped rather than an HTTP error, and the notes give the exact kubectl
command that prints OOMKilled.
- Temp space is an emptyDir counted against ephemeral-storage, where running
out looks different — the pod is evicted and replaced, not restarted.
Both limits are read back through the chart's own resources helper, so the
printed figures follow limitFactor and any override instead of being
hardcoded. Refs #43.
Three changes, all about values that do not do what they promise:
1. Ingress gate (#93): the Ingress template is wrapped in
{{- if .Values.ingress.enabled }}. The key already existed in
values.yaml with default true but was never read, so switching it off
did nothing. Both required() calls for domain and clusterIssuer live
inside the Ingress file, so gating also removes the contradiction that
one had to supply a domain and issuer in order to turn off an Ingress
one does not want.
2. Null-safety for hardening, probes and resources (#99): an override
written without children is YAML null and erased the whole default
sub-tree, rendering "securityContext: null" - an unhardened container
that starts and reports healthy.
3. values.schema.json types (#68 + #99): the blocks covered by the
null-safety accept null. This is load-bearing only for keys that have
no chart default (e.g. resources.limits): there Helm has nothing to
delete, so the null survives into schema validation and a strict
"object" type rejected it outright.
Rendering with normal values is byte-identical to 7.0.0.
Prints what an operator would otherwise have to look up after installing:
the rendered Ingress URL, the cert-manager Certificate to watch while the
browser still warns, and the fact that the chart needs no secrets and no
volumes.
Adds three warnings that the values alone do not tell: CyberChef ships no
authentication, so everything that reaches the URL can use it; the app is
static and holds no server-side state, so upgrades and rollbacks need no
migration; and cyberchef.env reaches nginx but never the served bundle.
Scaled-down installations are called out conditionally, naming whichever of
scaleDown / cyberchef.replicas caused it, via the chart's own replicas
helper so the notice cannot drift from the rendered Deployment.
The URL is only ever rendered from .Values.ingress.domain, so no domain is
stored in the chart. Refs #43.
Third change of the same class (#99): an override written without
children is YAML null and replaced the whole default sub-tree, rendering
"securityContext: null" (unhardened container), a vanished probe or a
container without resource accounting - silently, while the workload
reports healthy.
The defaultedDict helper from #96 is adopted for both workloads, so an
empty block means "chart defaults apply" while an explicitly set value
still wins, including false and 0.
The server probes rebuild their Host header from ingress.domain via the
passed-in root context, because the 2.x server rejects requests whose
Host is neither loopback nor a CORS_ORIGIN host.
The schema types allow null for exactly these blocks, so schema and
helper agree instead of the schema rejecting what the helper absorbs.
Rendering with normal values is byte-identical to the previous version,
with and without the guacd sidecar.
Two changes, both about values that do not do what they promise:
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, so a key the chart does not read is rejected
at render time instead of being silently ignored.
2. Null-safety for hardening, probes and resources (#99): an override
written without children is YAML null and replaced the whole default
sub-tree, rendering "securityContext: null" (unhardened container), a
vanished probe or a container without resource accounting - silently.
The defaultedDict helper from #96 is adopted so an empty block means
"chart defaults apply", while an explicitly set value still wins,
including false and 0.
The schema types allow null for exactly these blocks, so the two changes
agree instead of the schema rejecting what the helper is meant to absorb.
No ingress gate here: this chart already gates its Ingress correctly.
Rendering with normal values is byte-identical to the previous version.
Major bump: a schema can break existing deployments that carry dead keys.
Two changes of the same class - values that do not do what they promise:
1. values.schema.json (#68): additionalProperties: false on every
chart-owned object level, so a key the chart does not read is rejected
at render time instead of being silently ignored. Objects passed
verbatim into the pod spec (probes, securityContext, global) stay open.
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. Without the gate the
value did not exist at all and could not switch the Ingress off.
Major bump: a schema can break existing deployments that carry dead keys.
The chart mounted no volumes at all while running with
readOnlyRootFilesystem: true, so every Next.js write to
/app/apps/web/.next/cache failed. The image optimizer's failure even
surfaced as an unhandledRejection, so this was more than log noise.
The cache path is now a writable emptyDir; path and an optional
sizeLimit are configurable under rallly.cache. The hardening is
untouched — the pod's existing fsGroup (1000) is what makes the mounted
volume writable for uid 20000.
Verified in a throwaway k3d cluster which paths the app actually writes:
with a fully writable root filesystem and the cache mounted, the only
path the container touches is the cache directory itself. /tmp, $HOME
(=/app) and the rest of the root filesystem stay untouched, so no second
mount is needed.
Also drops the vestigial "capabilities.add: []": Trivy's AVD-KSV-0022 for
rallly (#84) comes from the older chart version deployed in the cluster,
which rendered a null list entry under add:. Reproduced with trivy config
against both renderings; the app needs no capabilities.
Major bump: runtime behavior changes, a volume is added.
Fixes#60
A "barcodes:" key left without children (e.g. after deleting the last
remaining sub-key, PAPERLESS_CONSUMER_BARCODE_SCANNER, which paperless-ngx
3.0 removed) is YAML null and replaced the whole default sub-tree on merge.
Rendering then failed on the nil pointer, so the release simply stopped
updating.
An empty or null block now means "the chart defaults apply". The merge runs
through a new paperless-ngx.defaultedDict helper: nulls never delete, nested
maps merge recursively, and explicitly set values win - including false and 0,
which "default" and "mergeOverwrite" would swallow. A scalar where the
defaults define a map is rejected with the values path in the message.
Major bump: the meaning of a values override changes.
Fixes#61
additionalProperties: false on every chart-owned object level, so a key
the chart does not read is rejected at render time instead of being
silently ignored. Objects passed verbatim into the pod spec (probes,
securityContext, global) stay open - Kubernetes owns their schema there.
Major bump: a schema can break existing deployments that carry dead keys.
Refs #68
additionalProperties: false on every chart-owned object level, so a key
the chart does not read is rejected at render time instead of being
silently ignored. Objects passed verbatim into the pod spec (probes,
securityContext, global) stay open - Kubernetes owns their schema there.
Major bump: a schema can break existing deployments that carry dead keys.
Refs #68
Home Assistant reads configuration.yaml, http-config.yaml and
recorder-config.yaml from a 1Password-backed Secret, mounted with subPath.
Two different gaps hide behind that, and only one of them can be closed.
Repointing secrets.configSecretRef at a different vault item used to leave the
running pod on the old configuration. That is now covered: the pod template
carries checksum/secret-ref over the rendered OnePasswordItem. The annotation
is named apart from checksum/config on purpose - it only promises that the
config source changed, not the config itself - matching the pattern
minecraft-server introduced.
Changing the CONTENT of the item in the vault still does not restart anything,
and cannot: Helm never sees the content, so there is nothing to hash. The
operator's own auto-restart annotation is no way out either - onepassword-
operator 1.12.0 enumerates exactly one workload type in its secret-update
handler (appsv1.DeploymentList) and this workload is a StatefulSet, so the
annotation would be a silent no-op advertising a guarantee it cannot keep. It
is deliberately not set. values.yaml states the limitation and the manual
rollout restart it requires; the README guideline records the operator finding,
the annotation precedence trap, and the checksum/secret-ref pattern so neither
is rediscovered the hard way.
Major bump: runtime behavior changes - changing the secret reference now costs
a restart. For this release the effect is preventive, since it currently runs
at zero replicas via scaleDown.
Last of the three publishes that carry the checksum annotations from
3.0.0+up2026.8.2.java8, leaving main on the line of the only running server.
Same chart content as the java8 and java17 publishes, only the published Java
line changes: appVersion moves to the dated tag 2026.8.2-java21 and the
minecraft.version default moves to 1.21.1, the version ftb-skies-2 actually
runs (Java 8 runs up to 1.16.5, 1.18-1.20.4 need Java 17, 1.20.5+ need
Java 21).
Against the state before this series (2.0.0+up2026.8.2.java21) the only
replaced line in Chart.yaml and values.yaml is the chart version; appVersion
and the minecraft.version default are byte-identical, everything else is
additive.
Refs #82.
additionalProperties: false on every chart-owned object level, so a key
the chart does not read is rejected at render time instead of being
silently ignored. Objects passed verbatim into the pod spec (probes,
securityContext, global) stay open - Kubernetes owns their schema there.
Major bump: a schema can break existing deployments that carry dead keys.
Refs #68
Second of the three publishes that carry the checksum annotations from
3.0.0+up2026.8.2.java8. Same chart content, only the published Java line
changes: appVersion moves to the dated tag 2026.8.2-java17 and the
minecraft.version default moves to 1.20.1, the line's matching Minecraft
version (Java 8 runs up to 1.16.5, 1.18-1.20.4 need Java 17, 1.20.5+ need
Java 21).
Refs #82.
smb.conf is rendered from samba.shares and mounted with subPath, so adding,
renaming or re-scoping a share rewrote the ConfigMap while the running smbd
kept its old share list: the pod template was unchanged, nothing restarted,
and the kubelet does not refresh subPath mounts. The share simply did not
exist for clients while the release looked healthy - the failure mode from #82.
The pod template now carries checksum/config over the rendered ConfigMap.
users.conf cannot be covered the same way: it comes from a 1Password-backed
Secret whose content Helm never sees, and the operator's auto-restart only
handles Deployments, not this StatefulSet. values.yaml now documents that
limitation and the manual rollout restart it requires.
Major bump: runtime behavior changes - a share change now costs a restart of
the samba pod, which drops open SMB sessions (share data lives on PVCs and is
unaffected).
The StatefulSet now carries two annotations over its two configuration
sources, so a change to either produces a new pod template and a controlled
restart:
checksum/config the rendered ConfigMap (init.sh) - complete, Helm sees
the whole content
checksum/secret-ref the rendered OnePasswordItem - the op:// item path only,
because the Secret's content is materialized by the
operator and is unknown to Helm at render time
Two separate annotations rather than one combined hash: they carry different
guarantees, and a single number would blur "covers everything" and "covers the
path but not the content" into one value that nobody can interpret in a
kubectl describe.
The operator's own operator.1password.io/auto-restart is deliberately NOT set:
it restarts Deployments only. Verified in the operator source (main, 9c3b1b9):
restartWorkloadsWithUpdatedSecrets lists nothing but appsv1.DeploymentList and
getPodTemplate has a single case for *appsv1.Deployment. On this StatefulSet
the annotation would be inert while suggesting the opposite, so the remaining
gap - a rotated value inside the vault item - is documented in values.yaml
instead.
First of the three stage-3-style publishes, so the java8 line goes first and
main ends up on java21. Only the published Java line differs between them:
appVersion 2026.8.2-java8 and the matching minecraft.version default 1.12.2.
Major bump: runtime behaviour changes. A configuration change now costs a full
server restart - 124 s measured for ftb-skies on Longhorn.
Refs #82.
The delegation nginx serves the .well-known documents from an nginx.conf that
is rendered from ingress.domain and mounted with subPath. Changing the domain
rewrote the ConfigMap but left the running pod untouched: the pod template was
unchanged, so nothing rolled, and the kubelet never refreshes a subPath mount.
The pod kept serving the old delegation while looking perfectly healthy - the
failure mode from #82.
The pod template now carries checksum/config over the rendered delegation
ConfigMap, so a changed nginx.conf rolls the deployment.
Also documents the limit that remains for homeserver.yaml: it comes from a
1Password-backed Secret, whose content Helm never sees, and the operator's own
auto-restart only ever covers Deployments - not this StatefulSet. That case
needs a manual rollout restart, which the values.yaml now says out loud.
Major bump: runtime behavior changes - a delegation config change now costs a
restart of the nginx pod (a few seconds, no persistent state).
The deployment now carries a checksum/config annotation over the rendered
ConfigMap, so a changed routing table produces a new pod template and a
controlled rollout.
Without it a mapping change was inert but looked successful: the file is
mounted with subPath (kubelet never refreshes those in a running container)
and mc-router reads ROUTES_CONFIG only at startup. That combination caused the
minecraft-server stage-3 outage - the ConfigMap pointed at the renamed
services, the running router kept proxying to the old ones, and the pod stayed
Ready throughout.
Major bump: runtime behaviour changes. A mapping change now costs a router
restart (~4.5 s in k3d, so roughly 17-19 s in the real cluster) during which
clients cannot connect. That restart is what makes the change take effect at
all.
Refs #82.
A changed ConfigMap did not restart any pod in this repo: no chart carries
a checksum/config annotation, so the pod template stays identical and the
running container keeps its old config. With subPath mounts (the usual form
here) the kubelet never refreshes the file either, so the change is silently
ineffective - see #82, where this caused a real outage while every bundle was
green and the pod Ready.
template-chart is the reference implementation, so it gets the annotation on
both pod templates (Deployment and StatefulSet), and the README style guide
gets a new binding section 9 covering the rule, the reason, and the limit for
1Password-backed Secrets.
Major bump: runtime behavior changes - a config change now costs a restart.
Last of the three stage-3 publishes, leaving main on the line of the only
running server. Same chart content as 2.0.0+up2026.8.2.java8 and .java17, only
the published Java line changes: appVersion moves to the dated tag
2026.8.2-java21 and the minecraft.version default moves to 1.21.1, the version
ftb-skies-2 actually runs (Java 8 runs up to 1.16.5, 1.18-1.20.4 need Java 17,
1.20.5+ need Java 21).
Second of the three stage-3 publishes. Same chart content as
2.0.0+up2026.8.2.java8, only the published Java line changes: appVersion moves
to the dated tag 2026.8.2-java17 and the minecraft.version default moves to
1.20.1, which is the line's matching Minecraft version (Java 8 runs up to
1.16.5, 1.18-1.20.4 need Java 17, 1.20.5+ need Java 21).
Stage 3 of the Fleet migration. Two changes, both breaking, hence the major
bump from 1.0.0 to 2.0.0.
Dated image tags: appVersion moves from the rolling line tag "java8" to the
immutable upstream release tag "2026.8.2-java8". The rolling tags are rebuilt
continuously, so a pod restart could pull a different image than the previous
start without that being recorded anywhere. 2026.8.2 is the newest upstream
release (published 2026-08-23) and exists for all three Java lines.
Whitelist semantics: the chart now wires ENABLE_WHITELIST (and ENFORCE_WHITELIST)
in both positions, so minecraft.whitelist.enabled: false actually switches the
whitelist off. Previously the chart only emitted whitelist variables while
enabled was true and had no way to express "off" - and because server.properties
lives on the PVC, a release that had once written white-list=true kept it
forever. players stays guarded by enabled, since a non-empty WHITELIST enables
the whitelist in the image regardless of ENABLE_WHITELIST.
The default flips from enabled: true to enabled: false. Under the corrected
semantics the old default (enabled with an empty player list) would mean a
server nobody can join; false matches both the image and vanilla Minecraft.
PR C of three, and the last of the stage-2 sequence. Same chart as
1.0.0+upjava8 and 1.0.0+upjava17, published for the third Java line, so
main comes to rest on the line of the only server that is actually
running.
minecraft.version moves with it (1.20.1 -> 1.21.1): a Minecraft version
only runs on a matching JVM, and 1.20.5+ needs Java 21. This default now
equals what ftb-skies-2 sets in its own values file, so the install-test
boots the production version rather than an arbitrary Java-21-capable one.
The CI values deliberately do not override minecraft.version, so this
default is what the install-test exercises.
PR B of three. Same chart as 1.0.0+upjava8, published for the second Java
line: appVersion selects the image tag, so the JVM is fixed per published
chart version.
minecraft.version moves with it (1.12.2 -> 1.20.1), because a Minecraft
version only runs on a matching JVM: Java 8 goes up to 1.16.5, 1.18-1.20.4
need Java 17. Since 1.0.0 this default is no longer overridden in the CI
values, so the install-test boots exactly this version and a mismatched
default would fail CI instead of passing on a CI-only override.
Stage 2 of the Fleet migration for minecraft-server. Major bump: runtime
behaviour changes (probes, hardening) and the values interface breaks
(resource renames, strict schema).
Two real bugs first:
- The container declared port 35565 while the Service targeted 25565. It
only worked because targetPort was numeric. Both are now server.port
(25565) and the Service targets the named port.
- The download-serverpack init container was guarded by the map
.Values.curseforgeServerpack instead of .enabled, so it rendered for
every release and silently no-opped on an empty wget URL.
Conventions:
- Resources renamed to <release>-<chart>-<kind>, templates to
<app>.<kind>.yaml. The PVC deliberately keeps its historical name -
renaming it would provision a new, empty volume and orphan the worlds.
- Probes (liveness/readiness/startup) via the image's own mc-health, so
Ready means the server answers a server-list ping. Startup budget 20 min.
- Hardened, values-configurable security contexts: non-root uid 1000,
read-only root filesystem with an emptyDir at /tmp, all caps dropped.
- Resources from values with the limitFactor helper; an unset memory
request is derived from the JVM heap so the two cannot drift apart.
- Additive env block rendered through tpl, pinned init image, storage
class as a value, RCON wiring only when actually enabled.
- curseforge/ftb/curseforgeServerpack are now mutually exclusive.
- minecraft.version is pinned to the Java line instead of LATEST.
- The mc-router auto-discovery annotation is gone; the service type it
implicitly switched is now the explicit value server.service.type.
Schema pilot (#68): values.schema.json with additionalProperties false on
every chart-owned level, rejecting all three known dead keys.