Author SHA1 Message Date
jklein 6a3b05503e Make the all-charts install-test repeatable, as one shared job
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 10s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 21s
The install-test matrix follows the charts a pull request changes, so a
chart nobody has touched since the forge move carries a green history from
a different runner - and that green says nothing about today. Gitea PR #48
showed what the answer is worth: correcting a comment in 21 charts made
every one of them "changed" and turned the run into the first all-charts
install-test after the move, 19 of 20 matrix charts Ready in 12m41s and
exactly one not (rss-bridge, Gitea #51). By accident.

The install-test job moves into the reusable .github/workflows/
install-test.yml and is called twice: by validate-charts.yml with the
charts a pull request changed, and by the new install-test-all-charts.yml
with all of them. Not copied for the second caller - a second copy is a
second chance to test differently, unnoticed, which is the argument that
gives ci/excluded-charts.txt exactly one parser. The parts that would
drift are the ones that were paid for once already: the pinned kubectl
with its skew check, envsubst, the containerised-job detection, the
per-leg cluster name.

Nothing about how a chart is installed changed. The moved job differs from
its predecessor in the matrix source (inputs.charts instead of the detect
job's output), an id on the install step, and a step that records the
result; every other step is byte-identical.

The all-charts run is workflow_dispatch only - a schedule would spend
compute every day on a question that changes rarely - with an optional
subset input, which is what lets a single chart be tested against the
runner as Gitea #51 needs. One throwaway cluster per chart, no reuse:
reuse saves time and changes what the test proves. An unknown or excluded
chart name, and an empty matrix, are loud errors rather than a green run
over nothing.

Every leg writes its own outcome into the job summary next to the four
facts that decide what the outcome means - date, runner label, K3S_IMAGE,
kubectl version - and a final job records the verdict over the matrix. A
green run nobody notes is unknown again next time. The excluded charts
appear with their justification, from the one parser, as in the pull
request pipeline. 20 charts in 12m41s is written down as the expectation,
so a later deviation is visible as one.

Fixes #52
2026-09-27 14:30:39 +00:00
jklein 813c796a7b feat(valkey): one Valkey instance per application (1.0.0+up9.1.2.alpine)
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 11s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m22s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Valkey Helm Chart / Publish Valkey Helm Chart (push) Successful in 7s
A new chart for a single Valkey instance per application, replacing the
shared cluster-redis whose separation rests on one ACL file in one
1Password field - a construction in which every change touches six
applications and which cost a month of Paperless standstill.

Shape: StatefulSet with one replica, ClusterIP Service, no cluster mode,
no Ingress and no value to add one.

The password arrives as a finished configuration line ("requirepass
<secret>") in the 1Password field auth-conf, is mounted as a file and
included by the main configuration - never in argv, never in the
environment. The include is rendered last, so nothing in extraConfig can
override it. Because a misspelled or empty field would leave an open
server that looks perfectly healthy, an init container greps the mounted
file for the line and refuses to start the pod without it; it never
prints the file.

maxmemory is derived from the computed memory limit (maxmemoryFactor,
default 0.75) and a configured value above that limit aborts the render:
otherwise the kubelet's OOM kill decides what the eviction policy was
configured to decide. Persistence and eviction are documented as pairs -
queue (PVC + noeviction, the default) and cache (emptyDir +
allkeys-lru) - and the combination "no persistence + noeviction" carries
a warning in values.yaml and in NOTES.txt.

Probes: valkey-cli ping as an exec probe, authenticated through
VALKEYCLI_AUTH read out of the mounted file, with "-e" and an assertion
on the answer PONG. Without -e valkey-cli exits 0 on an error reply
(valkey 9.1.2, src/valkey-cli.c: the config.set_errcode branch in
cliReadReply), which would make the probe unable to fail.

Hardening: uid 999 / gid 1000 - the image's own valkey user - with
fsGroup matching, runAsNonRoot, readOnlyRootFilesystem, all capabilities
dropped and seccompProfile RuntimeDefault in the pod context so the init
container is covered too. /data is the only writable path.

NetworkPolicy on by default (first chart in this repo with one), with a
switch and a list of additional namespaces; values.yaml names that it is
only a boundary where the CNI enforces policies.

Wiring: publish-valkey.yml, the chart-name choice list, ci/valkey
fixtures for the persistent variant, a plain docker-versioning Renovate
block, README chart row.
2026-09-27 11:24:55 +00:00
jklein e0b552d93e Replace the obsolete no-dependencies rationale in every chart helper
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 31s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 1m34s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 15m49s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Apache Tika Helm Chart / Publish Apache Tika Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish BentoPDF Helm Chart / Publish BentoPDF Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Calibre Web Automated Helm Chart / Publish Calibre Web Automated Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Cyberchef Helm Chart / Publish Cyberchef Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Gotenberg Helm Chart / Publish Gotenberg Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Homeassistant Helm Chart / Publish Homeassistant Helm Chart (push) Successful in 8s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Matrix Synapse Helm Chart / Publish Matrix Synapse Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Minecraft-Router Helm Chart / Publish Minecraft-Router Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Minecraft-Server Helm Chart / Publish Minecraft-Server Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Outline Helm Chart / Publish Outline Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Patchmon Helm Chart / Publish Patchmon Helm Chart (push) Successful in 6s
Publish Paperless-NGX Helm Chart / Publish Paperless-NGX Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Rallly Helm Chart / Publish Rallly Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Samba Helm Chart / Publish Samba Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Satisfactory Helm Chart / Publish Satisfactory Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish TeamSpeak Server Helm Chart / Publish TeamSpeak Server Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish VoucherVault Helm Chart / Publish VoucherVault Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Zipline Helm Chart / Publish Zipline Helm Chart (push) Successful in 6s
The doc comments on the resources and defaultedDict helpers justified the
copy-per-chart with "charts in this repo deliberately have no dependencies".
That rule fell with #214: a dependency is admissible now, just expensive, and
it sits in the README as guideline 14, not 13. The justification still holds,
but its reason is a different one - sharing the helper would mean a library
chart, a second artefact to publish, version and pull for something that
changes about once a year. searxng got that wording in PR #38; this carries it
to 21 further charts, 40 occurrences in all.

rss-bridge is deliberately left out. Its install-test fails on the Gitea
runner for a reason that predates this branch, and touching the chart would
keep it in the install-test matrix and the check red. Its copy of the sentence
is carried by #51, which has to open the chart anyway.

Comments only: every touched chart renders byte-identical against its
ci/<chart>/test-values.yaml. Each published chart takes a patch bump,
appVersion and +up suffix untouched; template-chart is never published and
takes none, but is corrected because every new chart inherits from it.

Two repo files carried the same claim at no version cost:
validate-charts.yml explained the all-charts quantity render test with it, and
change-app-version.yml still counted nine images for freesailarr - MediathekArr
made it ten.
2026-09-27 10:27:57 +00:00
jlab-renovate-bot 9df2b68ed9 Update ghcr.io/paperless-ngx/paperless-ngx Docker tag to v3.2.1
renovate/stability-days Updates have not met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 9s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 13s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 3m30s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 9s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 8s
Publish Paperless-NGX Helm Chart / Publish Paperless-NGX Helm Chart (push) Successful in 9s
2026-09-26 22:04:00 +00:00
jklein 733fea5d9e docs: point the two workflow cross-references at the right steps
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Skipped
Moving the wiki step from 7 to 5 shifted the two items behind it, and the
references that name them by number stayed where they were. The wiki
section still called itself step 7, and the auto-merge bullet cited rule 6
for "the user merges", which is rule 7 — rule 6 is the agent's lifetime.

A number pointing at the wrong step is the kind of reference that passes
for a reason on the next reading, so both are corrected here rather than
in an issue of their own.
2026-09-26 21:46:03 +00:00
jklein 1d3bacfb12 docs: name the remote that exists, and a push rule for the wiki
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
The remote bullet described two remotes, github and JLab-Git, and denied
that origin exists. The checkout has exactly one, origin, pointing at the
Gitea instance, and so does every worktree cut from it; two agents lost
time on the difference. The point the bullet made survives — Gitea is the
leading copy, the mirror is fed from there, and where a second remote does
exist the advice to name it still holds.

The wiki has no PR flow, so parallel edits meet only at push time. During
Gitea #16 and #18 two agents edited the same two pages minutes apart and
the rebase happened to be clean; a force push would have deleted the other
edit with nothing to report it. Fetch and rebase before every push, never
force-push.

The chart pages no longer quote versions, so the sentence that explained
them as snapshots now says they carry no numbers at all. That change is
already in the wiki.
2026-09-26 21:43:14 +00:00
jklein 8074ed14a5 Guard the four unguarded OnePasswordItem references with required()
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 3m11s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Calibre Web Automated Helm Chart / Publish Calibre Web Automated Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Minecraft-Server Helm Chart / Publish Minecraft-Server Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish SearXNG Helm Chart / Publish SearXNG Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 7s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 6s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 19s
calibre-web-automated 7.0.0, minecraft-server 6.0.0 and urlaubsverwaltung
10.0.0 each majors: an installation that renders today with an empty itemPath
stops rendering. searxng 1.0.6 is documentation only - its valkey reference is
correctly optional and stays gated by {{- if }}.

Every message names the values path. values.yaml now says per key why it is
required, reusing the wording the charts' NOTES.txt already carry. The
hand-rolled "cwaSecretRef IS UNSET" block in calibre-web-automated's NOTES.txt
is gone: after the guard the render aborts first and that block is
unreachable. minecraft-server's note stays true and now points at the guard.

No "required" array in the schemas, and README guideline 12 now says why. The
plan called for one; measuring it showed it could never fire: the key ships as
null, so it is present at validation time, and JSON Schema's required tests
presence, not null-ness. Making it fire means either dropping the null default
or narrowing the type to "string", and both turn helm lint --strict red on the
chart's own defaults. The schema therefore describes the key as required and
required() enforces it. The README bullet on how null reaches the schema was
also wrong in the case it was being applied to - a null chart default is not
coalesced away - and now distinguishes the three cases.

searxng: the claim "charts in this repo have no dependencies (README,
guideline 13)" in the valkey template, in values.yaml and twice in _helpers.tpl
has been false since #214 and cited the wrong guideline; dependencies are
admissible and are guideline 14. The same sentence survives in other charts'
helpers and needs its own round.
2026-09-26 21:15:30 +00:00
jklein d9267b2180 sure: fix the readiness probe, the ephemeral-storage budget and HOME
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Sure Helm Chart / Publish Sure Helm Chart (push) Successful in 6s
Second install-test run, three findings, all measured:

- Readiness on "/" failed with "http: server gave HTTP response to HTTPS
  client" on a pod that was answering correctly. The kubelet's HTTP prober
  follows redirects to the same host, and with RAILS_ASSUME_SSL=true the
  app builds its redirect as https://<pod-ip>:3000/registration/new, which
  Puma does not speak. The probe now runs curl without -L and accepts
  2xx-3xx itself. The same log also proves what the probe is for: against
  an empty database the redirect target was /registration/new, the branch
  User.count decides, so the path really does reach Postgres.
- The pod was evicted within two minutes of every start: "ephemeral local
  storage usage exceeds the total limit of containers 60Mi". emptyDirs and
  the stdout log both count there, and the image rebuilds its Bootsnap
  cache under /rails/tmp on every start because the emptyDir hides the one
  baked into the image. The request is now 256Mi (limit 768Mi) per app
  container - headroom, not a measurement.
- /home/rails is read-only, so Bundler created a throwaway home under
  TMPDIR on every invocation, worker probe included. HOME now points at a
  directory the init container creates.

The CI postgres fixture gets -d sure on its readiness probe, so the server
stops logging a missing database that nothing asked for.
2026-09-26 21:08:55 +00:00
jklein a414af089d sure: give Ruby a temporary directory it accepts
The first install-test run failed with both containers crash-looping
during boot:

  system temporary path is world-writable: /tmp
  /tmp is world-writable: /tmp
  . is not writable: /rails
  ArgumentError: could not find a temporary directory

raised from MiniMagick, which Active Storage loads while Rails boots.
Ruby's Dir.tmpdir skips a candidate that is world-writable without a
sticky bit, and a Kubernetes emptyDir is created mode 0777 with no sticky
bit - so with a read-only root filesystem there was no candidate left.

A tiny init container, the app's own image running as the same non-root
uid and with no capability added, now creates one private subdirectory
(mode 0700) inside each tmp emptyDir, and both containers run with
TMPDIR pointing at it. Creating an entry in a 0777 directory and chmod'ing
what one just created needs no privilege, so the hardening standard holds
unchanged.
2026-09-26 21:08:55 +00:00
jklein 93fdb3eaea sure: new chart for the we-promise Sure app (1.0.0+up0.7.4)
One StatefulSet with two containers - the Puma web process and a Sidekiq
worker - sharing one ReadWriteOnce PVC at /rails/storage. That shared
volume is the reason they live in one pod: splitting them would need an
RWX volume. Only the web container migrates, because the image entrypoint
runs db:prepare solely for the './bin/rails server' command; the worker is
started through args: so the entrypoint (and its jemalloc preload) stays
in place and the two can never race.

Probes are chosen for what they prove: liveness and startup use Rails'
/up, which is a boot check and touches no database, while readiness uses
'/', which runs User.count through authenticate_user! in self-hosted mode
and therefore fails while Postgres is unreachable. The worker speaks no
HTTP, so all three of its probes ask sidekiqmon whether this pod still
holds a Sidekiq heartbeat in Redis.

Three OnePasswordItem CRs (app, database, redis), ingress.domain kept
required outside the Ingress because APP_DOMAIN and the WebAuthn relying
party read it, strict values.schema.json, CI fixtures with throwaway
Postgres 16 and Redis, and a Renovate manager on semver so the
-alpha/-rc/-hotfix channel and the moving tags are never selected.
2026-09-26 21:08:55 +00:00
jlab-renovate-bot 819d7b2cf7 Update urlaubsverwaltung/urlaubsverwaltung Docker tag to v6.13.1
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 14s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 16s
2026-09-26 20:41:42 +00:00
jlab-renovate-bot 7115eb87f2 Update lukevella/rallly Docker tag to v4.15.2
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Rallly Helm Chart / Publish Rallly Helm Chart (push) Successful in 9s
2026-09-26 20:41:20 +00:00
jlab-renovate-bot 9344a45992 Update teamspeaksystems/teamspeak6-server Docker tag to v6.0.0-beta13.1
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m28s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish TeamSpeak Server Helm Chart / Publish TeamSpeak Server Helm Chart (push) Successful in 7s
2026-09-25 16:01:36 +00:00
jklein 4715d85b99 satisfactory: server.storage stays the documented exception (#39)
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Satisfactory Helm Chart / Publish Satisfactory Helm Chart (push) Successful in 7s
Walks back the one part of the previous commit that changed rendered output:
storage was pulled into the server fallback, so an erased size fell back to
20Gi instead of rendering a PVC without one.

values.schema.json had documented the opposite decision - "an erased value
yields an invalid PVC rather than a silently degraded workload" - and that is
someone's reasoned trade, not collateral of the nil-pointer bug next to it.
Overturning it inside a patch would have made this the only release where
valid values render differently, and it would have settled an open design
question in a PR nobody opened for that. It goes to #39 instead.

So storage is out of the defaults again and the schema note is restored
verbatim, extended only by where the exception is argued. values.yaml marks
it as the one key of the block that is deliberately not null-safe. The
README rule that had leaned on this example now says the thing that actually
holds: an exception is fine, an unmarked one is not.

Everything else stands - dev and server null-safe, the single-key findings on
the ports, formatNumber against exponential notation, 7.0.1.

Re-measured after the walk-back: rendered output against the chart defaults
and against a full override set is byte-identical to 7.0.0, NOTES.txt
included, and "--set server=null" now renders exactly the sizeless PVC that
"--set server.storage=null" rendered on 7.0.0.
2026-09-23 20:45:24 +00:00
jklein 5ff6e401c7 satisfactory: dev and server fall back instead of aborting (7.0.1)
--set dev=null aborted the render at .Values.dev.debug and --set server=null
at .Values.server.replicas, while the third block, game, has fallen back to
the chart defaults since 7.0.0. Half-safe is the worse state of the two:
whoever sees game=null work assumes server=null does too.

Both blocks now run through satisfactory.defaultedDict like game, via two new
helpers next to it. server.resources is deliberately NOT re-defaulted there -
it already runs through satisfactory.effectiveResources, which carries those
defaults and is null-safe on its own, and a second copy of limitFactor: 2 /
4Gi / 1 / 5Gi would be a third place for them to drift.

server is read in four files, not one, and the fourth is NOTES.txt, which
helm template never renders - a fix confined to the manifests would have
looked green and still aborted on a real helm install. All four go through
the helpers.

The single erased key was the quieter half of the bug and is fixed with it:
a nulled server.gamePort rendered "port:" and "nodePort:" empty into the
Service and an empty containerPort into the StatefulSet, and a nulled
server.storage a PVC asking for no size - manifests rejected by nothing until
the API server saw them. server.storage was documented in values.schema.json
as deliberately not null-safe; that reasoning does not survive the block-level
case, because it made "server: null" render a sizeless PVC instead of falling
back like everything else. The schema note is replaced rather than left lying.

Rendered output for valid values is unchanged: helm template against the
chart defaults and against a full override set is byte-identical to 7.0.0,
and so is NOTES.txt. Hence a patch bump, appVersion untouched.

Fixes #16
2026-09-23 20:45:24 +00:00
jlab-renovate-bot 8074e0de59 Update teamspeaksystems/teamspeak6-server Docker tag to v6.0.0-beta13
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m25s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish TeamSpeak Server Helm Chart / Publish TeamSpeak Server Helm Chart (push) Successful in 6s
2026-09-23 04:02:38 +00:00
jlab-renovate-bot 1edcf6fb9c Update urlaubsverwaltung/urlaubsverwaltung Docker tag to v6.13.0
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m38s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 7s
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 3s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
2026-09-23 01:02:41 +00:00
jlab-renovate-bot c36c5ee2c5 Update itzg/mc-router Docker tag to v1.47.1
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 9s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 42s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Minecraft-Router Helm Chart / Publish Minecraft-Router Helm Chart (push) Successful in 6s
2026-09-22 22:02:23 +00:00
jklein 02b3c3ce75 renovate: align the config with cluster-config
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
The shared jlab-renovate-bot instance serves this repo and cluster-config,
and two repos behaving differently under one bot is a source of surprises
rather than of safety. This adopts cluster-config's configuration:
config:best-practices in extends, labels ["renovate"], no PR caps
(prHourlyLimit 0, prConcurrentLimit 100), separateMinorPatch, an explicit
dependencyDashboard, and global automerge with platformAutomerge.

Two of those choices were previously wrong here and are now right, because
the branch protection on main has caught up: a review requirement and
validate-charts as a required check are both active. platformAutomerge
therefore arms the PR on the forge and Gitea merges it when the check
passes AND the review exists - the review is the gate, which is what makes
automerging majors something other than unattended merging.

Comments that asserted the opposite are rewritten rather than left to rot:
the platformAutomerge: false comment ("not (yet) a required check on either
side"), the "Majors stay manual" rule, the README PR policy bullets and the
required-check paragraph, and the CLAUDE.md forge notes.

config:best-practices pulls in docker:pinDigests, which is switched off
again for custom.regex and helm-values: an appVersion: is a tag by
construction and has nowhere to put a digest. Kept deliberately:
minimumReleaseAge of three days on the routine updates (every merge here
publishes a chart), the ignorePaths and ci/** opt-outs, every per-chart
manager, the nginx and qbittorrent constraints, and postUpgradeTasks.

Fixes #34
2026-09-22 19:44:28 +00:00
jlab-renovate-bot ddbf82e0d4 Update urlaubsverwaltung/urlaubsverwaltung Docker tag to v6.12.1
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m44s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 6s
2026-09-18 16:02:15 +00:00
jklein 5b13569948 docs: the Coder workspace is the normal case, so tea runs without op
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Work on this repo happens in a Coder workspace that has no 1Password CLI.
The rule that tea may only be called as 'op plugin run -- tea' therefore
described an environment that no longer exists here, and following it meant
either waiting for a tool that is not coming or ending with a pushed branch
and no PR.

tea is now called directly. It carries the workspace's stored login, so what
it opens is authored by the bot account - accepted, and named in the report
when it happens. The older instruction is kept for machines that do have op.

Signing needed the same correction: it runs through the workspace's own SSH
signer, not op-ssh-sign, and the check is 'git log --format=%G?' printing G.
2026-09-18 11:20:46 +00:00
jklein feb70cf1e4 docs: order the wiki step before the merge, not after it
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
2026-09-18 08:49:24 +00:00
jklein 03bb454eb7 docs: make the wiki the knowledge base and its upkeep a required step
The wiki now carries one page per chart (prerequisites, installation, the
required 1Password fields, the complete values reference and the chart's
traps) plus the workflow, the pipelines, versioning, Renovate, the style
guide and the incidents behind each rule.

README points at it and keeps its own role: the overview for someone reading
the code, while anything an operator needs lives in the wiki. CLAUDE.md gains
the wiki edit as step 7 of the mandatory workflow, with a table of what
obliges an edit - and the one thing that does not, a plain app-version bump,
since Renovate automerges those and never touches the wiki.

Also corrects the patchmon row: the 2.x server serves frontend and /api/* from
one process, so the chart renders one Deployment plus an optional guacd
sidecar, not two Deployments.
2026-09-18 08:48:34 +00:00
jklein afa1efb598 Merge pull request 'freesailarr: MediathekArr as the tenth container, off by default (3.1.0+up1.0.0)' (#25) from feat/freesailarr-mediathekarr into main
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 7s
2026-09-17 15:19:01 +00:00
jklein 60670182ff docs: describe what is, now that the move is done
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
The documentation still described a migration in progress, and the worst
line claimed the install-test "cannot run on Gitea at all" - true two
days ago, the opposite of true now, and exactly the sort of sentence
someone builds a wrong assumption on.

README and CLAUDE.md now state what holds: everything runs on Gitea,
github.com is a mirror with Actions and Renovate off, branch protection
with a signature requirement is active, and the tool is tea. The move
itself is not retold - that is Gitea #14.

"Where this repo lives" keeps its place but turns around: it is no
longer a list of what blocks us, it is what a Gitea runner does
differently from a GitHub one, with the measurements. Newly written
down because it holds permanently and stood nowhere: a matrix runs
concurrently in ONE job under ONE status context, so a required check
per chart is impossible and the context name carries the unexpanded
matrix expression; expected runtimes, so a later deviation is visible
as one, with the note that the difference to GitHub is structural - one
runner for both legs against a VM per leg - and not performance.

The six assumptions a GitHub runner had been meeting quietly are split
rather than listed. Five are environment (MTU, fs.inotify, missing
kubectl and envsubst, kubeconfig on 0.0.0.0): they fail loudly, say
nothing about our code, and are fixed on the node side. The sixth gets
its own block because it is the one that does say something about our
code - the hard-coded k3d cluster name was wrong on GitHub too and only
never showed there, because a VM per matrix leg meant two legs never saw
each other. A generous environment is no friend when you want to know
whether something is correct.

Two rules generalised out of this round, as rules rather than as a
story: a check that a broken system passes is worse than no check (with
the two vacuous forms that caused it), and green does not mean checked,
pointing at Gitea #24 as an open case of the same kind - explicitly not
claimed as solved.

Also fixes a hazard the move created: most #n in these files name closed
GitHub issues that were never migrated, while Gitea resolves a bare #n
against its own counter - #22 there is an unrelated throwaway pull
request, not the install-test issue the text means. Stated once at the
top instead of rewriting sixteen references into numbers that do not
exist.
2026-09-17 17:13:39 +02:00
jklein ae621746a1 Merge main into the MediathekArr branch
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 11s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 3m45s
The branch predates the Gitea move (#253) by 37 commits and both sides
touched the same two files. Merged rather than rebased, so the signed
commit of the original work stays intact, and resolved by combining
rather than choosing - both statements are true at once: the chart has
ten images now, and the repo lives on Gitea.

README.md, the one real conflict: this branch rewrote the freesailarr
image bullet from nine upstreams to ten, while main had grown a
minecraft-server bullet next to it that this branch never had. Kept
both - our "ten" wording and main's new bullet.

renovate.json5 merged cleanly, but "cleanly" is not "correctly", so the
result was read rather than trusted: the freesailarr exception now says
ten and gained the pointer to the mediathekarr digest block, while the
two things main added right beside it survived unchanged - the note that
the post-upgrade hook is allow-listed on the shared Gitea instance, and
the forge-neutral wording of the automerge rule. Nothing from before the
move brought "GitHub" back into a sentence that had been made neutral.
2026-09-17 11:40:21 +02:00
jlab-renovate-bot 82b90475ce Update lukevella/rallly Docker tag to v4.15.1
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Rallly Helm Chart / Publish Rallly Helm Chart (push) Successful in 7s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
2026-09-17 08:35:15 +00:00
jklein 87221abf29 Merge pull request #253 from jk-cluster/chore/gitea-migration-prep
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Gotenberg Helm Chart / Publish Gotenberg Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish SearXNG Helm Chart / Publish SearXNG Helm Chart (push) Successful in 6s
Gitea-Umzug vorbereiten: gemessene CI-Abweichungen, Renovate im Eigenbetrieb, Issue-Migrationsskript
2026-09-17 07:47:21 +00:00
jklein fdd798e1d9 install-test: provide envsubst, and say why it is not pinned like kubectl
ci/run-install-test.sh renders ci/<chart>/fixtures.env.yaml with
envsubst. GitHub's hosted image ships gettext-base, the Gitea runner
image does not, and without it the fixtures collapse to 'error: no
objects passed to apply' - gluetun never gets its Wireguard key and
nothing behind its kill switch becomes Ready. The script is left
untouched; it does nothing wrong, a standard tool is simply absent.

Checked for rather than pinned, and the difference from kubectl is
written next to it: kubectl's version changes what the test MEANS,
because its distance to the API server is a real source of failure, so
it is pinned, tracked and guarded. envsubst substitutes text and the
release performing it does not change the result, so a pin would be
upkeep without value. Present means do nothing, which keeps the GitHub
path free of any package install; missing means fetch it, so the
difference between the two runners is stated instead of hidden behind
an unconditional apt-get.

Also rewrites the reachability comment into one block. It had grown into
two stacked halves whose first half still described the behaviour of the
version before last - both reasons (the loop against a discovery 503,
counting Ready nodes against an empty list that exits 0) now stand
together with the evidence that the second one is what made the inotify
failure visible at all.
2026-09-17 01:00:38 +02:00
jklein 417dff0f80 install-test: wait for a Ready node, not for an answer
Second defect in the same check, and the more serious one: kubectl get
nodes exits 0 on an EMPTY list. The cluster on Gitea answered while its
node had not registered, the step logged 'No resources found' and went
green, and the failure surfaced five minutes later as a pod stuck in
Pending with no node and no scheduling event - the cause nowhere near
the symptom. kubectl wait --for=condition=Ready nodes --all has the
same hole; with zero nodes it succeeds vacuously. It now counts Ready
nodes, and on timeout dumps the node conditions and the k3s server log
so the error picture is in the job that caused it.
2026-09-17 00:46:54 +02:00
jklein 4766126618 install-test: poll for the cluster instead of asking once
The reachability check added with the containerisation fix was itself
racy and went red on one of two legs of the same GitHub run: k3d's
--wait returns when the node container reports ready, but the API
server can still answer discovery with 503 for a moment after that, and
kubectl cluster-info asked exactly once. A check that produces sporadic
red is worse than no check - that is the one property #227 rules out
explicitly. It now polls kubectl get nodes, which is the thing that has
to work and exercises discovery on the way.
2026-09-17 00:38:41 +02:00
jklein 241eda15a4 Merge pull request #252 from jk-cluster/renovate/gotenberg-gotenberg-8.x
Update gotenberg/gotenberg Docker tag to v8.37.0
2026-09-16 13:45:10 +00:00
jklein c1fddf1966 Merge pull request #254 from jk-cluster/renovate/docker.io-searxng-searxng-2026.x
Update docker.io/searxng/searxng Docker tag to v2026.9.13-3b8009065
2026-09-16 13:45:02 +00:00
jlab-renovate[bot] 0f92701f4e Update docker.io/searxng/searxng Docker tag to v2026.9.13-3b8009065 2026-09-16 13:00:56 +00:00
jlab-renovate[bot] f9a5e48461 Update gotenberg/gotenberg Docker tag to v8.37.0 2026-09-14 15:02:17 +00:00
jklein 78d7020dab freesailarr: MediathekArr as the tenth container, off by default (3.1.0+up1.0.0)
MediathekArr fakes a Usenet indexer towards Prowlarr and a SABnzbd downloader
towards Sonarr/Radarr and fetches from the ARD and ZDF media libraries. Both
consumers already share gluetun's network namespace, so they reach it over
127.0.0.1 - no service, no DNS, no ingress detour. Purely additive: the flag
defaults to false and a default render is byte-identical to 3.0.5 (verified
against the previous chart for both the default values and the CI values).

The four things that fail silently without them:

- A START GATE on gluetun. MediathekArr fetches its ruleset once at start and
  never retries; started before the tunnel it answers every Newznab search with
  0 results while the container is up, Ready and quiet. Kubernetes cannot order
  two regular containers and an initContainer would deadlock, so the wait sits
  in front of the image entrypoint: poll gluetun's health server on
  127.0.0.1:9999 and exec the entrypoint afterwards; on timeout the container
  exits rather than starting blind, because a crash loop is visible and an empty
  indexer is not. The startup probe budget (300s) deliberately outlasts the gate
  (180s).
- incomplete/ AND complete/ ON THE SHARED DOWNLOADS CLAIM, not in an emptyDir.
  An emptyDir counts against the container's ephemeral-storage limit, and one
  large download would have the kubelet evict the whole pod with all ten
  containers - the trap ytdlp-web-player documents.
- THE COMPLETE PATH SONARR SEES. The claim is mounted at /downloads exactly as
  sonarr/radarr mount it, and the chart sets DOWNLOAD_COMPLETE_PATH itself, so
  the SABnzbd complete_dir it reports is the same absolute path on both sides.
- TWO PORTS: 5007 (SABnzbd role and UI, primary) and 5008 (Newznab role for
  Prowlarr), both declared to gluetun's firewall and both on one Service. The
  SABnzbd API is at /download/api - /api on 5007 answers with a misleading
  Newznab validation error.

Further details:

- Image pinned by digest (tag + digest): "latest" is a 2024 build, "beta"
  moves. Single-arch linux/amd64. The built-in helm-values manager cannot
  follow that pair, so renovate.json5 gets the chart's first customManagers
  block; verified by dry-run with two fixtures, since a matching and a broken
  rule look identical while the pin is current.
- Hardened as measured: non-root, uid/gid from puid/pgid,
  readOnlyRootFilesystem: true, all capabilities dropped, no PUID/PGID.
- Probes on measured 200 endpoints (there is no health endpoint): readiness and
  startup on /download/, liveness as an exec covering BOTH ports, because one
  container runs two processes and a wedged Newznab half is exactly what makes
  searches come back empty.
- No ingress by default: the UI links its assets with root-absolute paths, so it
  can only be served at the root of a host; on the shared domain that root
  belongs to seerr. A sub-path fails the render instead of producing a broken
  UI.
- values.yaml and NOTES.txt document the geo MARKER PROCEDURE (not a table of
  series), the escape route of a German exit and its price for the whole stack,
  the fail_message: null trap, and the HLS question as an open question.
- CI: mediathekarr enabled in ci/freesailarr/test-values.yaml, install timeout
  raised to 1200s for the extra image and the gate.

Fixes #245
2026-09-08 23:20:16 +02:00
112 changed files with 5349 additions and 555 deletions

No files matched your search

+3 -1
View File
@@ -38,7 +38,9 @@ on:
- searxng
- bentopdf
- ytdlp-web-player
# freesailarr is deliberately absent: none of its nine images takes
- sure
- valkey
# freesailarr is deliberately absent: none of its ten images takes
# its tag from appVersion (they are all pinned in its values.yaml),
# so this workflow would rewrite appVersion and republish the chart
# without changing a single deployed image. Update its images through
@@ -0,0 +1,216 @@
# The all-charts install-test: the same job the pull-request pipeline runs, with
# a matrix over every installable chart instead of the changed ones (Gitea #52).
#
# The question it answers is not "did this change break a chart" - that is the
# pull-request pipeline's job - but "does the environment under an UNCHANGED
# chart still hold". The install-test matrix follows the charts a pull request
# touches, so a chart nobody has edited since the forge move carries a green
# history from a different runner, and that green says nothing about today.
#
# It exists because the answer arrived once by accident: Gitea PR #48 corrected
# a comment in 21 charts, which made every one of them "changed", and so became
# the first all-charts run after the move - 19 of 20 matrix charts came up in
# 12m41s in one job, and exactly one did not (rss-bridge, Gitea #51). That
# result was worth having and nobody had planned it. This workflow makes it
# repeatable.
#
# WHEN TO RUN IT: after a change to the runner, to K3S_IMAGE, to the pinned
# KUBECTL_VERSION, to the fixtures under ci/ - and after a forge move. Not on a
# schedule: it would spend compute every day on a question that changes rarely,
# and a red run nobody asked for is a red run nobody reads.
name: Install-test (all charts)
on:
workflow_dispatch:
inputs:
charts:
required: false
type: string
default: ""
description: "charts to test, separated by spaces or commas - empty means every installable chart"
jobs:
select-charts:
name: "Select charts"
runs-on: ubuntu-latest
outputs:
charts: ${{ steps.select.outputs.charts }}
count: ${{ steps.select.outputs.count }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
- name: "Select the charts to test"
id: select
env:
# The dispatch input is read as an environment variable and never
# interpolated into the script body. A "${{ inputs.charts }}" inside
# the shell would let whoever triggers the run write commands into
# this step; it is a small door and there is no reason to leave it
# open. The name check further down is the second half of that: these
# values end up as a matrix entry, a k3d cluster name and an argument
# to ci/run-install-test.sh.
REQUESTED: ${{ inputs.charts }}
run: |
FAIL=0
# --- The candidates -------------------------------------------------
# Empty input: every directory with a Chart.yaml, which is the same
# definition detect-changed-charts uses for "this is a chart". No list
# to maintain - adding a chart enrols it, the way the quantity render
# test enrols one.
#
# A subset was asked for: exactly those names. This is what lets
# someone test a single chart against the runner, which is what Gitea
# #51 needs - and it is why an unknown or excluded name below is an
# error rather than a silent omission. Asking for one chart and
# getting a green run over zero charts would be the worst answer this
# workflow could give.
if [ -z "$(printf '%s' "$REQUESTED" | tr -d '[:space:],')" ]; then
SUBSET=false
CANDIDATES="$(ls -d -- */Chart.yaml 2>/dev/null | sed 's|/Chart.yaml$||' | sort)"
echo "No subset requested - every chart directory is a candidate."
else
SUBSET=true
CANDIDATES="$(printf '%s' "$REQUESTED" | tr ',' ' ' | tr -s '[:space:]' '\n' | grep -v '^$' | sort -u)"
echo "Requested subset: $(echo "$CANDIDATES" | tr '\n' ' ')"
fi
for C in $CANDIDATES; do
case "$C" in
*[!a-z0-9-]*)
echo "::error::'$C' is not a chart name - chart directories in this repository are lowercase letters, digits and dashes."
FAIL=1
continue
;;
esac
if [ ! -f "$C/Chart.yaml" ]; then
echo "::error::'$C' is not a chart directory of this repository (no $C/Chart.yaml)."
FAIL=1
fi
done
[ "$FAIL" -eq 0 ] || exit 1
CHARTS="$(printf '%s\n' $CANDIDATES | jq -R . | jq -sc 'unique')"
# --- The exclusion list, through the one parser ---------------------
# ci/run-install-test.sh --list-excluded emits "<chart><TAB><reason>"
# and is the only thing in this repository that reads the format of
# ci/excluded-charts.txt - here no more than in validate-charts.yml,
# because a second parser would be a second chance to select
# different charts, silently and in either direction (issue #231).
# The comparison is jq's "!=" on whole strings, so a chart name is
# never read as a pattern.
#
# The two callers differ in what they do with a hit, and on purpose.
# Over all charts, an excluded chart is dropped and named with its
# reason, exactly as in the pull-request pipeline. In a requested
# subset it is an ERROR: someone typed that name, and quietly
# returning a green run for a chart that was never attempted is the
# lie #231 removed from the check list.
SUMMARY_ROWS=""
while IFS=$'\t' read -r EX_NAME EX_REASON; do
[ -n "$EX_NAME" ] || continue
if [ "$(echo "$CHARTS" | jq -r --arg c "$EX_NAME" 'index($c) != null')" = "true" ]; then
if [ "$SUBSET" = true ]; then
echo "::error::chart '$EX_NAME' was requested but is listed in ci/excluded-charts.txt: $EX_REASON"
FAIL=1
fi
# A "|" in the justification would break out of the table cell.
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$EX_NAME\` | ${EX_REASON//|/\\|} |"$'\n'
echo "Excluded from the install-test: $EX_NAME - $EX_REASON"
fi
CHARTS="$(echo "$CHARTS" | jq -c --arg c "$EX_NAME" 'map(select(. != $c))')"
done <<< "$(ci/run-install-test.sh --list-excluded)"
[ "$FAIL" -eq 0 ] || exit 1
# An empty matrix does not fail - the install-test job would simply
# not be created and the run would end green, having tested nothing.
# That is the shape of signal this whole issue is about, so it is a
# loud error instead.
if [ "$CHARTS" = "[]" ]; then
echo "::error::no chart left to test - every candidate is listed in ci/excluded-charts.txt."
exit 1
fi
COUNT="$(echo "$CHARTS" | jq -r 'length')"
echo "charts=$CHARTS" >> "$GITHUB_OUTPUT"
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
echo "Install-test matrix ($COUNT charts): $CHARTS"
# --- The run record, first half -------------------------------------
# What was asked for, when, and against what. The per-chart outcome
# and the environment each leg measured are written by the legs
# themselves (the called workflow's "Record the result" step), the
# verdict by the record job below. A green run nobody notes is
# unknown again next time.
{
echo "## All-charts install-test"
echo
echo "- Started: **$(date -u '+%Y-%m-%d %H:%M') UTC**"
echo "- Requested: $( [ "$SUBSET" = true ] && echo "subset \`$REQUESTED\`" || echo "**every installable chart**" )"
echo "- Matrix: **$COUNT charts** — $(echo "$CHARTS" | jq -r 'map("`" + . + "`") | join(", ")')"
echo "- Expected runtime, so a deviation is visible as one: **20 charts in 12m41s**, measured on Gitea PR #48 — one job, all legs concurrently on one Docker daemon. The job timeout is 30 minutes per leg."
echo "- One throwaway k3d cluster per chart, no reuse: a reused cluster would carry leftovers, a warm image cache and fixtures already applied, and \"came up from nothing\" is the statement this test exists to make."
echo
echo "Each chart's outcome, with the runner label, \`K3S_IMAGE\` and \`kubectl\` version it was measured against, is in the install-test job summaries; the verdict over the whole matrix is at the end."
echo
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "$SUMMARY_ROWS" ]; then
{
echo "### Charts excluded from the install-test"
echo
echo "These charts are listed in \`ci/excluded-charts.txt\` and get **no** install-test job. There is no green tick for them, because nothing was attempted (issue #231)."
echo
echo "| Chart | Documented reason |"
echo "| --- | --- |"
printf '%s' "$SUMMARY_ROWS"
echo
} >> "$GITHUB_STEP_SUMMARY"
fi
# The same workflow validate-charts.yml calls, with the same inputs, differing
# only in the chart list. Not a copy of the job: see the header of
# .github/workflows/install-test.yml for what would drift.
all-charts:
name: "All charts"
needs: select-charts
uses: ./.github/workflows/install-test.yml
with:
charts: ${{ needs.select-charts.outputs.charts }}
secrets: inherit
# The verdict over the matrix as a whole. A matrix leg only ever sees its own
# job summary, so this is the one place that can say "all of them" - and the
# aggregate is what the question "has the environment changed" is answered
# with. It runs on failure too, hence "if: always()".
record:
name: "Record the run"
needs: [select-charts, all-charts]
if: always()
runs-on: ubuntu-latest
steps:
- name: "Write the verdict"
env:
RESULT: ${{ needs.all-charts.result }}
COUNT: ${{ needs.select-charts.outputs.count }}
run: |
case "$RESULT" in
success)
VERDICT="**all $COUNT charts installed and became Ready** on this runner, on $(date -u '+%Y-%m-%d')"
;;
failure)
VERDICT="**at least one chart did not come up.** Which ones, and whether the chart failed or the cluster never came up, is in the per-chart records above. That is a result and not an accident to re-run: rss-bridge failing this way on Gitea PR #48 is why Gitea #51 exists"
;;
skipped|cancelled)
VERDICT="**no verdict** - the matrix did not run ($RESULT). Nothing was tested; do not read this run as a green one"
;;
*)
VERDICT="**no verdict** - unexpected matrix result '$RESULT'"
;;
esac
{
echo "## Verdict"
echo
echo "$VERDICT"
echo
echo "Worth writing down where it belongs: a result from this run is what the next \"has anything drifted\" question is compared against. If it moved a chart forward or backward, say so on the chart's wiki page or in \`Lessons-Learned\`."
} >> "$GITHUB_STEP_SUMMARY"
+414
View File
@@ -0,0 +1,414 @@
# The install-test, as one job with two callers.
#
# Both callers install a chart the SAME way; they differ only in which charts
# they name. validate-charts.yml passes the charts a pull request changed;
# install-test-all-charts.yml passes every installable chart on demand, to ask
# whether the environment underneath an UNCHANGED chart still holds (Gitea
# #52) - a chart untouched since the forge move carries a green history from a
# different runner, and that says nothing about today.
#
# It is a called workflow and not a second job, for the same reason
# ci/excluded-charts.txt has exactly one parser: a second copy is a second
# chance to test DIFFERENTLY, unnoticed. The parts that would drift are worth
# naming, because each was paid for once already - the pinned kubectl with its
# skew check, envsubst, the detection of a containerised job, the per-leg
# cluster name. A copy that slowly disagrees about any of them does not go red;
# it turns a green run into a statement about something other than what was
# assumed.
#
# What stays with the callers: WHICH charts are tested, and whether the job
# runs at all (needs/if). Everything that decides HOW one is installed is here.
name: Install-test
on:
workflow_call:
inputs:
charts:
required: true
type: string
description: 'JSON array of chart names, e.g. ["cyberchef","zipline"]. The entries of ci/excluded-charts.txt must already be gone - see the matrix comment below.'
secrets:
OP_CONNECT_TOKEN:
# Needed by exactly one leg - freesailarr, for the 1Password Connect
# lookup of its CI Wireguard key. Declared OPTIONAL on purpose, where
# publish-helm-chart.yml declares its token required: on this instance a
# missing secret does not fail the job, it fails job CREATION, so the
# run shows no steps at all (README, "Where this repo lives"). With
# required: true a pull request from a fork - which carries no secrets -
# would lose the entire install-test that way, every chart at once, with
# nothing naming the cause. Optional instead: only the freesailarr leg
# fails, loudly, at the ":?" guards that name the missing op:// field.
required: false
description: "1Password Connect token; only the freesailarr leg reads it"
jobs:
install-test:
name: "Install-test ${{ matrix.chart }}"
# The one job in this repo that does not simply say ubuntu-latest, and the
# expression is not decoration - the same label means different machines on
# the two forges this repo currently lives on (#225).
#
# This is the only job that needs a Docker socket: it creates a k3d
# cluster, and k3d is a Docker client. On GitHub's hosted runners every
# label carries Docker, so the question never came up. On the Gitea
# instance it was MEASURED on a probe pull request, per job:
# detect-changed-charts, build-chart, the quantity render and the Trivy
# scan all pass on `ubuntu-latest` - none of them needs Docker - while the
# install-test dies there at "Create k3d cluster" with "Cannot connect to
# the Docker daemon at unix:///var/run/docker.sock", an error that points
# at k3d and never at the runner label. `ubuntu-docker` is the label that
# carries a socket, and Gitea does evaluate this expression: the probe job
# was picked up by the docker runner with labels ["ubuntu-docker"].
#
# Why an expression and not a plain `ubuntu-docker`: that label does not
# exist on GitHub, so every chart PR would sit queued forever for as long
# as this repo still builds on github.com - blocking the merge with no
# error anywhere. github.server_url is the one context value that separates
# the two without naming our instance.
#
# The label is necessary and was, for a while, not sufficient:
# `ubuntu-docker`'s daemon reported "Cgroup Driver: none" plus "Running in
# rootless-mode without cgroups", and k3d got as far as starting the k3s
# node, which restarted immediately with an empty log because k3s needs
# cgroup delegation (#225). Nothing in this file addressed that - it was
# dealt with on the instance side. It no longer blocks, and the evidence is
# Gitea PR #48: 19 of 20 matrix charts installed and became Ready on this
# label. The twentieth, rss-bridge, failed and is Gitea #51.
runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
# Blocking since Renovate automerges patch/minor updates: a chart that no
# longer starts must stop the PR before it reaches main and publishes. This
# ends the stabilization phase of issue #22 - the job reported failures
# without blocking while it proved itself across several chart PRs.
#
# The 30 minutes are per leg and hold for the all-charts caller too, with
# the measured figure to compare against: Gitea PR #48 ran 20 charts in
# 12m41s in one job, all legs concurrently on one Docker daemon. If a run
# ever comes near this timeout, it is raised HERE, once, for both callers -
# a value that differs per caller would mean the two tests give up at
# different points while claiming to be the same test.
timeout-minutes: 30
strategy:
fail-fast: false
# The charts arrive from the caller ALREADY filtered against
# ci/excluded-charts.txt: validate-charts.yml does it in
# detect-changed-charts, install-test-all-charts.yml in select-charts, and
# both go through ci/run-install-test.sh --list-excluded, the one parser
# of that file. Both also name every chart they dropped WITH its
# documented reason in their own job summary, because an absent matrix
# entry says "not run" and never why (issue #231).
#
# DO NOT move that decision in here, e.g. as an "if" on this job
# if: ... && !contains(fromJSON(inputs.excluded-charts), matrix.chart)
# to get a visible "Skipped" per chart instead of an absent entry.
# GitHub rejects it - jobs.<id>.if is evaluated BEFORE the matrix is
# expanded, so the matrix context does not exist there:
# context "matrix" is not allowed here.
# available contexts are "github", "inputs", "needs", "vars".
# Measured on run 33650724109 (PR #233, closed): the whole workflow ended
# in failure with zero jobs created. The per-chart value has to be gone
# from the matrix before this workflow is called.
matrix:
chart: ${{ fromJSON(inputs.charts) }}
env:
K3D_VERSION: v5.9.0
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
# Pinned rather than taken from the runner image. The argument is not
# "Gitea's image has no kubectl" - that is only how it was noticed. A
# kubectl inherited from whatever the image happens to ship drifts
# unannounced with every image rebuild, and an install-test is supposed
# to run against a KNOWN client. That GitHub's hosted image already
# carries one does not make the pin unnecessary; it only makes the drift
# invisible there. The value mirrors K3S_IMAGE above (v1.35.5), which is
# the cluster this client talks to.
# renovate: datasource=github-releases depName=kubernetes/kubernetes
KUBECTL_VERSION: v1.35.5
# The same expression as runs-on above, written a second time because the
# env context is not available in runs-on - so it cannot be the other way
# round, and reading the label back off the runner is not possible either
# (RUNNER_NAME below names the machine, not the label that picked it).
# It exists for the run record at the end of this job: "on which runner
# was this green tick measured" is the whole question of Gitea #52. Kept
# right under runs-on, so a change to one that forgets the other is
# visible on a single screen.
RUNNER_LABEL: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
- name: "Install Helm"
uses: azure/setup-helm@v5
- name: "Install kubectl"
run: |
# kubectl and the cluster are a pair: the client talks to the server
# K3S_IMAGE starts, and Kubernetes supports at most one minor of
# skew between them. Renovate updates KUBECTL_VERSION on its own
# while K3S_IMAGE is tracked by nothing, so without this check the
# two drift apart until the client is talking to a server it no
# longer supports - silently, because kubectl only warns. And it is
# not theoretical: measured by dry run, a lookup from v1.34.0
# offers v1.37.0 in ONE step, because Renovate fills the non-major
# bucket with the newest candidate rather than walking the minors.
# So this check WILL fire one day, on purpose, with a message that
# says which of the two pins to move.
#
# Deliberately not solved with an allowedVersions constraint pinning
# kubectl to the k3s minor: that is the nginx trap the README
# describes - a filter that silently stops offering updates the day
# the other side moves. A loud failure beats a quiet freeze.
read -r KMAJ KMIN <<<"$(printf '%s' "$KUBECTL_VERSION" | sed -E 's/^v([0-9]+)\.([0-9]+).*/\1 \2/')"
read -r SMAJ SMIN <<<"$(printf '%s' "$K3S_IMAGE" | sed -E 's/.*:v([0-9]+)\.([0-9]+).*/\1 \2/')"
SKEW=$(( (KMAJ * 100 + KMIN) - (SMAJ * 100 + SMIN) ))
if [ "${SKEW#-}" -gt 1 ]; then
echo "::error::kubectl $KUBECTL_VERSION and k3s $K3S_IMAGE are more than one minor apart. Bump K3S_IMAGE to match, or pin KUBECTL_VERSION back."
exit 1
fi
curl -sSfL -o /tmp/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
mkdir -p "$HOME/.local/bin"
install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/kubectl" version --client
- name: "Provide envsubst"
run: |
# ci/run-install-test.sh renders ci/<chart>/fixtures.env.yaml with
# envsubst - today only freesailarr has one, and without the tool
# the fixtures collapse to "error: no objects passed to apply",
# gluetun never gets its Wireguard key, and nothing behind its kill
# switch becomes Ready. GitHub's hosted image ships gettext-base,
# the Gitea runner image does not. The script is fine as it is; a
# standard tool is simply missing.
#
# CHECKED FOR, NOT PINNED - and that is a different decision from
# kubectl above, on purpose. kubectl's VERSION changes what the test
# means: its distance to the API server is a real source of failure,
# which is why it is pinned, tracked and guarded. envsubst
# substitutes text; which release performs the substitution does not
# change the result. Pinning it would buy nothing and add a line for
# Renovate to carry. So: present - do nothing, which keeps the
# GitHub path free of any package install; missing - fetch it, and
# the difference between the two runners is stated rather than
# hidden behind an unconditional apt-get.
if command -v envsubst >/dev/null 2>&1; then
echo "envsubst present at $(command -v envsubst) - nothing to do"
exit 0
fi
echo "envsubst missing on this runner image - installing gettext-base"
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends gettext-base
command -v envsubst
# freesailarr is the one chart whose install-test needs credentials:
# without a real Wireguard key gluetun never completes a handshake, and
# behind its kill switch not a single app container becomes Ready. The key
# comes from 1Password Connect like the registry credentials in
# publish-helm-chart.yml, and it is a CI-ONLY Mullvad key: Mullvad ties an
# address to a key and sends the tunnel to the endpoint it saw last, so
# reusing the production key would let every PR run pull the tunnel out
# from under the running stack - with the symptom in the cluster and the
# cause in a pull request. Loaded only for this chart; no other chart run
# touches the VPN key.
- name: "Configure OP-Connect"
if: matrix.chart == 'freesailarr'
uses: 1password/load-secrets-action/configure@v5
with:
connect-host: https://op-connect.jk-dev.de
- name: "Load freesailarr VPN credentials"
id: load-freesailarr-vpn
if: matrix.chart == 'freesailarr'
uses: 1password/load-secrets-action@v5
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
FREESAILARR_WG_KEY: op://GitHub-Actions/freesailarr-mullvad-test-config/credential
FREESAILARR_WG_ADDRESSES: op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address
# The same trap publish-helm-chart.yml documents at length: v5 of
# load-secrets-action does NOT export the loaded values as environment
# variables for later steps, only its step outputs carry them, and the
# loading step logs "Populating variable" and stays green either way. So
# the values are mapped into each step's own env block via
# steps.<id>.outputs.* and read as quoted shell variables. The ":?" guards
# run before the cluster is even created and name the op:// field that is
# missing - an empty key would otherwise reach gluetun and look like a
# broken chart instead of a missing secret. They print the variable name,
# never its value.
- name: "Verify the VPN credentials arrived"
if: matrix.chart == 'freesailarr'
env:
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
run: |-
: "${FREESAILARR_WG_KEY:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/credential}"
: "${FREESAILARR_WG_ADDRESSES:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address}"
echo "Both VPN values arrived non-empty."
- name: "Install k3d"
run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash
# One cluster per matrix leg, named after the chart. It used to be the
# fixed name "install-test", and that was wrong on GitHub too - it just
# could not show there, because GitHub gives every matrix leg its own VM
# with its own Docker daemon, so two legs never saw each other's
# cluster. The environment hid the bug for two years rather than
# preventing it. Gitea runs all legs of a matrix CONCURRENTLY in ONE job
# on ONE daemon, and there the fixed name collides at once: measured on
# the probe PR of #225, one leg logs "Re-using existing network
# 'k3d-install-test'", the other dies on "The container name
# /k3d-install-test-server-0 is already in use" - and then the first
# leg's rollback deletes the shared network out from under the second,
# which fails with "network k3d-install-test not found". Both legs red,
# neither cause in either message. DO NOT put the fixed name back.
#
# Why the bare chart name and not a prefixed one: k3d rejects a cluster
# name over 32 characters ("Cluster name must be <= 32 characters",
# measured against k3d v5.9.0 - 32 passes, 33 fails), and every
# container is named k3d-<cluster>-server-0 from it. The longest chart
# here is calibre-web-automated at 21, so "install-test-<chart>" would
# be 34 and break exactly that one chart and no other - the kind of
# prefix that is worse than the fixed name because it only sometimes
# breaks. The guard below states the limit instead of leaving it to be
# rediscovered.
- name: "Create k3d cluster"
run: |
CLUSTER="${{ matrix.chart }}"
if [ "${#CLUSTER}" -gt 32 ]; then
echo "::error::chart name '$CLUSTER' is ${#CLUSTER} characters and k3d allows at most 32 for a cluster name."
exit 1
fi
# Does THIS job run inside a container? That, and not which forge we
# are on, is what decides whether k3d's cluster is reachable (#227).
#
# k3d publishes the API server port on the Docker *host*. When the
# job runs straight on the host - GitHub's hosted runners - that is
# the same machine, and the "https://0.0.0.0:<port>" k3d writes into
# the kubeconfig works. When the job itself is a container, as every
# Gitea Actions job is, the host is the dind container and 0.0.0.0
# points at the job container itself, where nothing listens. The
# cluster comes up fine and the first kubectl call is refused.
#
# Deliberately NOT keyed on github.server_url, although that would
# pick the right branch today: a self-hosted GitHub runner in a
# container has exactly this problem, and a condition that says
# "GitHub" while meaning "not containerised" is the kind of lie this
# repo exists to remove. So the state is detected instead of
# inferred, and the detection is the same question the fix needs
# answered anyway: ask the daemon about ourselves. Inside a
# container that the daemon knows, this returns our network and k3d
# joins it, which puts k3s and this job on one bridge where Docker's
# own DNS resolves the container names. On a host it returns nothing,
# because the hostname is not a container, and the old path is taken
# unchanged.
# The "|| true" is load-bearing: this step runs under `bash -e -o
# pipefail`, and on a host `docker inspect` exits non-zero, which
# would abort the step instead of taking the else branch.
JOB_NET="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$(hostname)" 2>/dev/null | awk '{print $1}' || true)"
if [ -n "$JOB_NET" ]; then
echo "Containerised job: $(hostname) on docker network '$JOB_NET' - k3d joins it."
k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --network "$JOB_NET" --wait --timeout 180s
# Same reason: the published port is useless from in here, so the
# kubeconfig is pointed at the load balancer container by name,
# over the shared network. If the API server certificate did not
# carry that name this would fail on the TLS handshake and the
# cluster-info below would say so - it holds without an extra
# --tls-san, measured on the probe PR of #225.
kubectl config set-cluster "k3d-$CLUSTER" --server="https://k3d-$CLUSTER-serverlb:6443"
else
echo "Job runs on the docker host - k3d's published port is reachable as written."
k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s
fi
# Reachability is proven HERE, and the shape of this check is the
# product of getting it wrong twice. Both mistakes are written down
# because both look like something to simplify away.
#
# It is a LOOP and not a single call. k3d's --wait returns when the
# node container reports ready, but the API server can still answer
# discovery with 503 for a moment after that. Measured: a single
# `kubectl cluster-info` right here failed with "ServiceUnavailable:
# the server is currently unable to handle the request" on one of
# two legs of the SAME GitHub run while the other passed. A check
# that is itself racy produces exactly the sporadic red this job
# must never have (#227) - and a test people have learned to re-run
# is one they have stopped believing.
#
# It counts READY NODES and not "did kubectl answer". `kubectl get
# nodes` exits 0 on an EMPTY list, so the first version passed
# against a cluster whose API server was up and whose node had never
# registered: the log said "No resources found", the step went
# green, and the failure surfaced five minutes later as a pod stuck
# in Pending with no node and no scheduling event - nowhere near its
# cause. A check that a broken cluster passes is worse than no
# check. `kubectl wait --for=condition=Ready nodes --all` is not the
# shorter way to write this: it has the identical hole, because with
# zero nodes the condition is vacuously satisfied.
#
# The evidence that this version earns its keep: only after it
# counted nodes did the real cause become visible in the dump below
# - k3s could not start containerd ("error creating fsnotify
# watcher: too many open files", an inotify limit on the host), and
# nothing above this line had ever said so.
for ATTEMPT in $(seq 1 90); do
READY_NODES="$(kubectl get nodes --no-headers 2>/dev/null | awk '$2 == "Ready"' | wc -l | tr -d ' ')"
[ "$READY_NODES" -ge 1 ] && break
if [ "$ATTEMPT" -eq 90 ]; then
echo "::error::the k3d cluster '$CLUSTER' answers but has no Ready node after 180s"
kubectl get nodes -o wide || true
kubectl describe nodes || true
echo "--- k3s server log ---"
docker logs "k3d-$CLUSTER-server-0" 2>&1 | tail -80 || true
exit 1
fi
sleep 2
done
kubectl get nodes -o wide
# Every chart reaching this step is one the exclusion filter let through,
# so the script's SKIP branch is never taken here - it exists for local
# runs. The variables below are empty for every chart but the one that
# declares them in ci/<chart>/settings.env (REQUIRED_ENV); the script
# fails loudly if a declared one is missing, it never skips over it.
- name: "Install chart and wait for readiness"
# The id is read by the run record at the end of this job: its outcome
# is what separates "the chart did not come up" from "the cluster never
# came up", and the two mean opposite things for Gitea #52.
id: install
env:
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
run: ci/run-install-test.sh "${{ matrix.chart }}"
# Must carry the same per-leg name as the create step above: a blanket
# cleanup, or the old fixed name, would tear down the neighbouring leg's
# cluster on a runner that shares one Docker daemon - which is precisely
# the half of the collision that turned one red leg into two.
- name: "Delete k3d cluster"
if: always()
run: k3d cluster delete "${{ matrix.chart }}" || true
# The run record (Gitea #52). A green install-test says "this chart came
# up" and nothing about WHERE, and where is exactly what the question
# needs: a chart that has not been touched since the forge move carries a
# green history from another environment. So every leg writes down the
# four facts that decide what its result means - date, runner label,
# K3S_IMAGE, kubectl - next to its own outcome. A green run nobody notes
# is unknown again next time.
#
# Per leg, and not one table for the whole matrix, because there is no
# place to write one: a matrix leg only ever sees its own
# GITHUB_STEP_SUMMARY, on either forge. The aggregate verdict is written
# by the calling workflow, which does see the matrix as a whole.
#
# It reports steps.install.outcome and not the job status: a leg that died
# in "Create k3d cluster" never installed anything, and recording that as
# a failed chart would blame the chart for the environment - precisely the
# confusion this issue is about.
- name: "Record the result"
if: always()
env:
CHART: ${{ matrix.chart }}
INSTALL_OUTCOME: ${{ steps.install.outcome }}
run: |
case "$INSTALL_OUTCOME" in
success) RESULT="installed, every workload Ready" ;;
failure) RESULT="**FAILED** - the chart did not come up" ;;
*) RESULT="**FAILED before the install** - cluster or tooling, not the chart (install step outcome: '${INSTALL_OUTCOME:-none}')" ;;
esac
{
echo "### Install-test \`$CHART\`: $RESULT"
echo
echo "| Date (UTC) | Runner label | Runner name | K3S_IMAGE | kubectl |"
echo "| --- | --- | --- | --- | --- |"
echo "| $(date -u '+%Y-%m-%d %H:%M') | \`$RUNNER_LABEL\` | \`${RUNNER_NAME:-unknown}\` | \`$K3S_IMAGE\` | \`$KUBECTL_VERSION\` |"
echo
} >> "$GITHUB_STEP_SUMMARY"
+15
View File
@@ -0,0 +1,15 @@
name: Publish Sure Helm Chart
on:
workflow_dispatch:
push:
branches:
- main
paths:
- sure/**
jobs:
publish-sure:
name: Publish Sure Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
with:
chart-name: 'sure'
secrets: inherit
+15
View File
@@ -0,0 +1,15 @@
name: Publish Valkey Helm Chart
on:
workflow_dispatch:
push:
branches:
- main
paths:
- valkey/**
jobs:
publish-valkey:
name: Publish Valkey Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
with:
chart-name: 'valkey'
secrets: inherit
+27 -242
View File
@@ -157,8 +157,9 @@ jobs:
# against - resource quantities formatted with printf "%v", where float64
# flips to exponential notation above ~1e6 - lives in the resources helper,
# and that helper is copied into every chart's templates/_helpers.tpl,
# because charts here have no dependencies. So it comes back by copy: a new
# chart started from an older one, or from template-chart before the fix.
# because sharing it would mean a library chart (README, guideline 14). So
# it comes back by copy: a new chart started from an older one, or from
# template-chart before the fix.
# In that PR the chart is new and its copy of the bug is in no diff anyone
# reads. Running over everything also means there is no list to maintain -
# adding a chart enrolls it. Two renders per chart, no cluster, a few
@@ -227,246 +228,30 @@ jobs:
run: ci/run-trivy-config.sh "${{ matrix.chart }}"
install-test:
name: "Install-test ${{ matrix.chart }}"
name: "Install-test"
needs: [detect-changed-charts, build-chart]
if: needs.detect-changed-charts.outputs.any-install == 'true'
# The one job in this repo that does not simply say ubuntu-latest, and the
# expression is not decoration - the same label means different machines on
# the two forges this repo currently lives on (#225).
# The job itself lives in .github/workflows/install-test.yml and is called
# from here and from install-test-all-charts.yml (Gitea #52). Everything
# that decides HOW a chart is installed moved there unchanged - the runner
# label expression, the pinned kubectl with its skew check, envsubst, the
# containerised-job detection, the per-leg cluster name, the freesailarr VPN
# credentials. What stayed here is WHICH charts are tested and whether the
# job runs at all. A copied job would be a second chance to test
# differently, unnoticed, which is the same argument that gives
# ci/excluded-charts.txt exactly one parser.
#
# This is the only job that needs a Docker socket: it creates a k3d
# cluster, and k3d is a Docker client. On GitHub's hosted runners every
# label carries Docker, so the question never came up. On the Gitea
# instance it was MEASURED on a probe pull request, per job:
# detect-changed-charts, build-chart, the quantity render and the Trivy
# scan all pass on `ubuntu-latest` - none of them needs Docker - while the
# install-test dies there at "Create k3d cluster" with "Cannot connect to
# the Docker daemon at unix:///var/run/docker.sock", an error that points
# at k3d and never at the runner label. `ubuntu-docker` is the label that
# carries a socket, and Gitea does evaluate this expression: the probe job
# was picked up by the docker runner with labels ["ubuntu-docker"].
#
# Why an expression and not a plain `ubuntu-docker`: that label does not
# exist on GitHub, so every chart PR would sit queued forever for as long
# as this repo still builds on github.com - blocking the merge with no
# error anywhere. github.server_url is the one context value that separates
# the two without naming our instance.
#
# KNOWN, and not fixed by this line: `ubuntu-docker`'s daemon is ROOTLESS
# and reports "Cgroup Driver: none" plus "Running in rootless-mode without
# cgroups". k3d then gets as far as starting the k3s node, which restarts
# immediately with an empty log, because k3s needs cgroup delegation. Until
# the instance's Docker-in-Docker provides it, this job cannot pass on
# Gitea - see #225. The label is necessary, it is just not sufficient.
runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
# Blocking since Renovate automerges patch/minor updates: a chart that no
# longer starts must stop the PR before it reaches main and publishes. This
# ends the stabilization phase of issue #22 - the job reported failures
# without blocking while it proved itself across several chart PRs.
timeout-minutes: 30
strategy:
fail-fast: false
# "install-charts", not "charts": the changed charts minus the ones in
# ci/excluded-charts.txt, filtered in detect-changed-charts. Excluded
# charts are named with their reason in that job's summary (issue #231).
#
# DO NOT try to move that decision into an "if" on this job, e.g.
# if: ... && !contains(fromJSON(needs.detect-changed-charts.outputs.excluded-charts), matrix.chart)
# to get a visible "Skipped" per chart instead of an absent entry.
# GitHub rejects it - jobs.<id>.if is evaluated BEFORE the matrix is
# expanded, so the matrix context does not exist there:
# context "matrix" is not allowed here.
# available contexts are "github", "inputs", "needs", "vars".
# Measured on run 33650724109 (PR #233, closed): the whole workflow ended
# in failure with zero jobs created. The per-chart value has to be gone
# from the matrix by the time this job is reached.
matrix:
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }}
env:
K3D_VERSION: v5.9.0
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
# Pinned rather than taken from the runner image. The argument is not
# "Gitea's image has no kubectl" - that is only how it was noticed. A
# kubectl inherited from whatever the image happens to ship drifts
# unannounced with every image rebuild, and an install-test is supposed
# to run against a KNOWN client. That GitHub's hosted image already
# carries one does not make the pin unnecessary; it only makes the drift
# invisible there. The value mirrors K3S_IMAGE above (v1.35.5), which is
# the cluster this client talks to.
# renovate: datasource=github-releases depName=kubernetes/kubernetes
KUBECTL_VERSION: v1.35.5
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
- name: "Install Helm"
uses: azure/setup-helm@v5
- name: "Install kubectl"
run: |
# kubectl and the cluster are a pair: the client talks to the server
# K3S_IMAGE starts, and Kubernetes supports at most one minor of
# skew between them. Renovate updates KUBECTL_VERSION on its own
# while K3S_IMAGE is tracked by nothing, so without this check the
# two drift apart until the client is talking to a server it no
# longer supports - silently, because kubectl only warns. And it is
# not theoretical: measured by dry run, a lookup from v1.34.0
# offers v1.37.0 in ONE step, because Renovate fills the non-major
# bucket with the newest candidate rather than walking the minors.
# So this check WILL fire one day, on purpose, with a message that
# says which of the two pins to move.
#
# Deliberately not solved with an allowedVersions constraint pinning
# kubectl to the k3s minor: that is the nginx trap the README
# describes - a filter that silently stops offering updates the day
# the other side moves. A loud failure beats a quiet freeze.
read -r KMAJ KMIN <<<"$(printf '%s' "$KUBECTL_VERSION" | sed -E 's/^v([0-9]+)\.([0-9]+).*/\1 \2/')"
read -r SMAJ SMIN <<<"$(printf '%s' "$K3S_IMAGE" | sed -E 's/.*:v([0-9]+)\.([0-9]+).*/\1 \2/')"
SKEW=$(( (KMAJ * 100 + KMIN) - (SMAJ * 100 + SMIN) ))
if [ "${SKEW#-}" -gt 1 ]; then
echo "::error::kubectl $KUBECTL_VERSION and k3s $K3S_IMAGE are more than one minor apart. Bump K3S_IMAGE to match, or pin KUBECTL_VERSION back."
exit 1
fi
curl -sSfL -o /tmp/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
mkdir -p "$HOME/.local/bin"
install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/kubectl" version --client
# freesailarr is the one chart whose install-test needs credentials:
# without a real Wireguard key gluetun never completes a handshake, and
# behind its kill switch not a single app container becomes Ready. The key
# comes from 1Password Connect like the registry credentials in
# publish-helm-chart.yml, and it is a CI-ONLY Mullvad key: Mullvad ties an
# address to a key and sends the tunnel to the endpoint it saw last, so
# reusing the production key would let every PR run pull the tunnel out
# from under the running stack - with the symptom in the cluster and the
# cause in a pull request. Loaded only for this chart; no other chart run
# touches the VPN key.
- name: "Configure OP-Connect"
if: matrix.chart == 'freesailarr'
uses: 1password/load-secrets-action/configure@v5
with:
connect-host: https://op-connect.jk-dev.de
- name: "Load freesailarr VPN credentials"
id: load-freesailarr-vpn
if: matrix.chart == 'freesailarr'
uses: 1password/load-secrets-action@v5
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
FREESAILARR_WG_KEY: op://GitHub-Actions/freesailarr-mullvad-test-config/credential
FREESAILARR_WG_ADDRESSES: op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address
# The same trap publish-helm-chart.yml documents at length: v5 of
# load-secrets-action does NOT export the loaded values as environment
# variables for later steps, only its step outputs carry them, and the
# loading step logs "Populating variable" and stays green either way. So
# the values are mapped into each step's own env block via
# steps.<id>.outputs.* and read as quoted shell variables. The ":?" guards
# run before the cluster is even created and name the op:// field that is
# missing - an empty key would otherwise reach gluetun and look like a
# broken chart instead of a missing secret. They print the variable name,
# never its value.
- name: "Verify the VPN credentials arrived"
if: matrix.chart == 'freesailarr'
env:
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
run: |-
: "${FREESAILARR_WG_KEY:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/credential}"
: "${FREESAILARR_WG_ADDRESSES:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address}"
echo "Both VPN values arrived non-empty."
- name: "Install k3d"
run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash
# One cluster per matrix leg, named after the chart. It used to be the
# fixed name "install-test", and that was wrong on GitHub too - it just
# could not show there, because GitHub gives every matrix leg its own VM
# with its own Docker daemon, so two legs never saw each other's
# cluster. The environment hid the bug for two years rather than
# preventing it. Gitea runs all legs of a matrix CONCURRENTLY in ONE job
# on ONE daemon, and there the fixed name collides at once: measured on
# the probe PR of #225, one leg logs "Re-using existing network
# 'k3d-install-test'", the other dies on "The container name
# /k3d-install-test-server-0 is already in use" - and then the first
# leg's rollback deletes the shared network out from under the second,
# which fails with "network k3d-install-test not found". Both legs red,
# neither cause in either message. DO NOT put the fixed name back.
#
# Why the bare chart name and not a prefixed one: k3d rejects a cluster
# name over 32 characters ("Cluster name must be <= 32 characters",
# measured against k3d v5.9.0 - 32 passes, 33 fails), and every
# container is named k3d-<cluster>-server-0 from it. The longest chart
# here is calibre-web-automated at 21, so "install-test-<chart>" would
# be 34 and break exactly that one chart and no other - the kind of
# prefix that is worse than the fixed name because it only sometimes
# breaks. The guard below states the limit instead of leaving it to be
# rediscovered.
- name: "Create k3d cluster"
run: |
CLUSTER="${{ matrix.chart }}"
if [ "${#CLUSTER}" -gt 32 ]; then
echo "::error::chart name '$CLUSTER' is ${#CLUSTER} characters and k3d allows at most 32 for a cluster name."
exit 1
fi
# Does THIS job run inside a container? That, and not which forge we
# are on, is what decides whether k3d's cluster is reachable (#227).
#
# k3d publishes the API server port on the Docker *host*. When the
# job runs straight on the host - GitHub's hosted runners - that is
# the same machine, and the "https://0.0.0.0:<port>" k3d writes into
# the kubeconfig works. When the job itself is a container, as every
# Gitea Actions job is, the host is the dind container and 0.0.0.0
# points at the job container itself, where nothing listens. The
# cluster comes up fine and the first kubectl call is refused.
#
# Deliberately NOT keyed on github.server_url, although that would
# pick the right branch today: a self-hosted GitHub runner in a
# container has exactly this problem, and a condition that says
# "GitHub" while meaning "not containerised" is the kind of lie this
# repo exists to remove. So the state is detected instead of
# inferred, and the detection is the same question the fix needs
# answered anyway: ask the daemon about ourselves. Inside a
# container that the daemon knows, this returns our network and k3d
# joins it, which puts k3s and this job on one bridge where Docker's
# own DNS resolves the container names. On a host it returns nothing,
# because the hostname is not a container, and the old path is taken
# unchanged.
# The "|| true" is load-bearing: this step runs under `bash -e -o
# pipefail`, and on a host `docker inspect` exits non-zero, which
# would abort the step instead of taking the else branch.
JOB_NET="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$(hostname)" 2>/dev/null | awk '{print $1}' || true)"
if [ -n "$JOB_NET" ]; then
echo "Containerised job: $(hostname) on docker network '$JOB_NET' - k3d joins it."
k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --network "$JOB_NET" --wait --timeout 180s
# Same reason: the published port is useless from in here, so the
# kubeconfig is pointed at the load balancer container by name,
# over the shared network. If the API server certificate did not
# carry that name this would fail on the TLS handshake and the
# cluster-info below would say so - it holds without an extra
# --tls-san, measured on the probe PR of #225.
kubectl config set-cluster "k3d-$CLUSTER" --server="https://k3d-$CLUSTER-serverlb:6443"
else
echo "Job runs on the docker host - k3d's published port is reachable as written."
k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s
fi
# Proves reachability here rather than letting it surface two steps
# later as a validation error on the fixtures, which is where it
# showed up before and named nothing useful.
kubectl cluster-info
kubectl get nodes
# Every chart reaching this step is one the exclusion filter let through,
# so the script's SKIP branch is never taken here - it exists for local
# runs. The variables below are empty for every chart but the one that
# declares them in ci/<chart>/settings.env (REQUIRED_ENV); the script
# fails loudly if a declared one is missing, it never skips over it.
- name: "Install chart and wait for readiness"
env:
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
run: ci/run-install-test.sh "${{ matrix.chart }}"
# Must carry the same per-leg name as the create step above: a blanket
# cleanup, or the old fixed name, would tear down the neighbouring leg's
# cluster on a runner that shares one Docker daemon - which is precisely
# the half of the collision that turned one red leg into two.
- name: "Delete k3d cluster"
if: always()
run: k3d cluster delete "${{ matrix.chart }}" || true
# "install-charts", not "charts": the changed charts minus the ones in
# ci/excluded-charts.txt, filtered in detect-changed-charts, which also
# names every excluded chart of this pull request with its documented
# reason in its job summary (issue #231). The called workflow's matrix
# comment carries the rest of that lesson, including why the decision
# cannot be made per matrix leg instead.
uses: ./.github/workflows/install-test.yml
with:
charts: ${{ needs.detect-changed-charts.outputs.install-charts }}
# As the 24 publish-<chart>.yml hulls do, and measured to work on this
# instance (README, "Where this repo lives"). The called workflow declares
# exactly one secret, OP_CONNECT_TOKEN, and only its freesailarr leg reads
# it.
secrets: inherit
+44 -12
View File
@@ -10,35 +10,67 @@ The main Claude session acts as the **task manager** and does not implement issu
2. **Document the final plan on the issue**: Once approved, the plan is posted as a comment on the issue before implementation starts (skip if the issue body already contains the full plan; later decisions go there as comments too).
3. **Delegate implementation**: After approval, the manager launches a dedicated **development agent**. The agent implements the issue completely on its own branch and opens a PR.
4. **PR comment handling by the agent**: After opening the PR, the development agent watches it for review comments. New comments are addressed automatically (code changes, reply on the thread), and addressed threads are marked as **resolved**.
5. **Agent lifetime**: The development agent terminates itself once its PR has been merged (or closed).
6. Only after the PR has been **merged by the user** may work on the next issue begin. The user merges; never merge yourself.
7. **After the merge, verify the linked issue was closed** (auto-close via "Fixes #n"); close it manually if not. Gitea auto-closes on the same keywords, but only for an issue in the same repository — a migrated issue whose number changed is closed by hand.
5. **Update the wiki — mandatory, and before the merge is requested.** The wiki is this repo's knowledge base; a change that lands in `main` while the wiki still describes the old state has not been finished, it has been half-finished in a way nothing fails on. The development agent edits the affected pages as part of its issue, so code and documentation are reviewed together. See "The wiki" below for what obliges an edit and what does not.
6. **Agent lifetime**: The development agent terminates itself once its PR has been merged (or closed).
7. Only after the PR has been **merged by the user** may work on the next issue begin. The user merges; never merge yourself.
8. **After the merge, verify the linked issue was closed** (auto-close via "Fixes #n"); close it manually if not. Gitea auto-closes on the same keywords, but only for an issue in the same repository — a migrated issue whose number changed is closed by hand. Check in the same step that the wiki edit really landed.
Additional rules for all commits, PRs, issues, and comments:
- Do **not** add a `Co-Authored-By` trailer and do **not** append "Generated with Claude Code" footers.
- **Commits must be signed.** Verify the signature before opening the PR; if `op-ssh-sign` fails, stop and report rather than pushing unsigned work.
- **Commits must be signed**, and in the Coder workspace they are: signing runs through its own SSH signer (`gpg.format = ssh`, `gpg.ssh.program = coder-git-ssh-sign`, `commit.gpgsign = true`), so no 1Password step is involved. Verify before opening the PR — `git log --format='%G?' -1` must print `G` — and if signing fails, stop and report rather than pushing unsigned work, because branch protection rejects it at merge time.
## The wiki
The knowledge base is the Gitea wiki at `https://git.jk-dev.de/jlab-cluster/helm-charts/wiki` — one page per chart (installation, required 1Password fields, every configurable value, the chart's traps) plus the long form of this workflow, the pipelines, the style guide and the incidents each rule was bought with. It is a **separate git repository**:
```bash
git clone ssh://git@git.jk-dev.de:30022/jlab-cluster/helm-charts.wiki.git
```
Flat layout, one Markdown file per page, page name = file name, links are relative page names without the extension (`[CI/CD](CI-CD)`). A new page must also be listed in `_Sidebar.md`, or it is reachable only by search.
**There is no PR flow here, so fetch and rebase before every push and never force-push** — two agents have already edited the same page minutes apart, and a force push deletes the other edit silently, with nothing left to report it.
**What obliges a wiki edit** (step 5 of the workflow above, same round of work, never a follow-up issue):
| Change | Page |
| --- | --- |
| a value added, removed, renamed, or its default changed | the chart's page — values table, and the operating notes if behaviour moved |
| runtime behaviour changed (probes, hardening, restart semantics, a value becoming required) | the chart's page, plus `Lessons-Learned` if the reason could be repeated elsewhere |
| a new chart | a new chart page, a row in `Charts`, an entry in `_Sidebar.md` |
| a convention added or changed | `Chart-Style-Guide` (and README, which stays the code-side copy) |
| pipeline, fixture or runner behaviour changed | `CI-CD` |
| this issue workflow changed | `Workflow` — **and** this file, which is the binding copy |
| a Renovate rule added or changed | `Renovate` |
| something broke in a way a rule now prevents | `Lessons-Learned` |
**What does not oblige an edit:** a plain app-version bump. Renovate automerges those continuously and never touches the wiki, which is why chart pages carry no version numbers at all and name `Chart.yaml` as the source of truth. An app update that *does* change values or behaviour is not a plain bump, and the table applies.
Wiki commits follow the same rules as repo commits: no `Co-Authored-By`, no "Generated with Claude Code" footer.
## Which forge, and with which tool
The repository is moving from github.com to the Gitea instance JLab-Git (#225). The move is **not finished**, so both are live and the steps below differ by where the work happens. README, "Where this repo lives", holds the measured differences between the two CIs; this section holds only what changes about the *workflow*.
Everything runs on the Gitea instance JLab-Git: issues, pull requests, CI and Renovate. github.com is a push mirror with Actions and Renovate **switched off**, and it lags until the mirror runs. README, "Where this repo lives", holds the measured differences between a Gitea runner and a GitHub one.
- **Remotes are named `github` and `JLab-Git`** — there is no `origin`. Say which one you mean; `git push` without a remote is ambiguous here.
- **Today the merge flow still runs on GitHub.** Issues, pull requests and merges happen there until the maintainer switches Actions off and turns on the mirror. Until then, **do not push to `JLab-Git/main`**: with publish workflows armed on both sides, two CIs publish into the same registry, and that registry accepts a re-upload of an existing version silently, replacing the artifact (#160). Pushing a *branch* to Gitea is fine — only `main` triggers a publish.
- **`gh` only as `op plugin run -- gh`**, `tea` only as `op plugin run -- tea`. A bare `gh` falls back to the wrong identity. Every call costs a 1Password confirmation, so bundle them and never poll.
- **After the move: no merge queue.** Gitea has none. Its place is taken by **auto-merge on green checks** — the PR is armed once and merges itself when the required checks pass. The manager still decides *whether* to arm it; rule 6 above is unchanged: the user merges, and an agent never merges its own PR.
- **Issue numbers will not survive the move.** Gitea renumbers, and issues and PRs share one counter, so a `#n` written today will not land on the same number over there. The migration itself is the maintainer's one-off job over the API; how the old references are dealt with afterwards is open and decided in #225. Until that is decided, a `#n` anywhere in this repo means the GitHub issue.
- **Renovate is already running on the Gitea side, and not from this repo.** A shared `jlab-renovate-bot` instance serves the other repositories there and has this one enrolled; it reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed (both verified on its first run). **Do not add a Renovate workflow here** — a second runner against the same repo means duplicate PRs and two schedulers rewriting each other's branches. README, "Renovate on Gitea", has the evidence.
- **`tea`, not `gh`.** Work happens in a **Coder workspace, and it has no `op`** — that is the normal case here, not a broken environment, so do not wait for the 1Password CLI or propose installing it. Call `tea` directly; it carries the workspace's stored login and opens PRs and issues under the **bot account**, which is accepted. Say so in the report when something is authored that way. (`op plugin run -- tea`, the older instruction, applies only where `op` actually exists.)
- **The remote is `origin`, and it points at Gitea** (`ssh://git@git.jk-dev.de:30022/jlab-cluster/helm-charts.git`). The checkout has exactly that one, and so does every worktree cut from it, so a bare `git push` goes to the leading copy; the github.com mirror is fed by Gitea itself and is never pushed to by hand. A clone elsewhere may carry the mirror as a second remote named `github` — wherever more than one exists, say which one you mean.
- **Branch protection with a signature requirement is active**, and signatures verify on Gitea. A commit that is not signed does not reach `main` — check before opening a PR, do not discover it at merge time.
- **No merge queue.** Gitea has none, and its place is taken by **auto-merge on green checks**: the PR is armed once and merges itself when the required check passes **and the required review exists** — both are active on `main`, so an armed PR without a review sits there instead of merging. Rule 7 above is unchanged — the user merges, and an agent never merges its own PR or arms auto-merge on it.
- **A required check per chart is not possible.** Gitea runs a whole matrix as one job under one status context, so the install-test reports once for all charts. What is required on `main` is `validate-charts`, as that single context; per chart is the part that cannot be had. Relevant whenever branch protection is touched.
- **Issue numbers changed with the move, and `#n` in this repo's files means the GitHub tracker.** The issues open at the time are Gitea #5–#21 and #24; the mapping is a comment on Gitea #14. Closed ones were not migrated and exist only on GitHub. Beware that Gitea silently resolves a bare `#n` against its own counter, so an old reference can render as a link to something unrelated. When you write a *new* reference, use the Gitea number.
- **Renovate runs outside this repo**, as the shared `jlab-renovate-bot` instance; it reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed there. **Do not add a Renovate workflow here** — a second runner means duplicate PRs and two schedulers rewriting each other's branches. README, "Renovate on Gitea", has the evidence.
## Keep the docs current
- Changes to this issue workflow always go into this CLAUDE.md file.
- General repository information (charts, CI pipelines, conventions, tooling) always lives in README.md. Any PR that changes repo-wide behavior must update README.md in the same PR.
- **Operator-facing knowledge lives in the wiki**, not in README.md: how a chart is installed, what every value does, what its traps are. The division is `CLAUDE.md` binds the agent, `README.md` explains the repository to someone reading the code, the wiki explains the system to whoever runs it, and a chart's `NOTES.txt` explains one release. Where README and the wiki cover the same rule, both are updated in the same PR — see "The wiki" above.
- Runtime verification of chart changes is done in a local, throwaway k3d cluster (k3d is installed on the maintainer's machine): stub CRD for `OnePasswordItem`, dummy secrets, throwaway infrastructure (postgres/redis) where needed.
## What this repo is
A collection of self-made Helm charts, one directory per chart, published to a private, classic (index-based) Helm registry by CI (GitHub Actions today, Gitea Actions after #225; the workflow files are read by both unchanged) — an HTTP `POST` of the packaged `.tgz`, not an OCI `helm push`; the target URL and its credentials live in 1Password and never in this repository. Validation happens on three levels: locally with Helm, in the PR pipeline (`validate-charts.yml`: strict lint, package, version-bump check), and in the PR install-test stage (k3d, fixtures under `ci/` — see README):
A collection of self-made Helm charts, one directory per chart, published to a private, classic (index-based) Helm registry by Gitea Actions — an HTTP `POST` of the packaged `.tgz`, not an OCI `helm push`; the target URL and its credentials live in 1Password and never in this repository. Validation happens on three levels: locally with Helm, in the PR pipeline (`validate-charts.yml`: strict lint, package, version-bump check), and in the PR install-test stage (k3d, fixtures under `ci/` — see README):
```bash
helm lint --strict <chart-name>
+108 -32
View File
@@ -2,17 +2,23 @@
Collection of some self-made helm-charts. Each top-level directory is a standalone Helm chart that gets packaged and uploaded to a private, classic (index-based) Helm registry by CI.
> **The repository is moving from github.com to the Gitea instance JLab-Git (#225).** The code is already on both and identical; what still runs the show is GitHub. The workflow files under `.github/workflows/` are read by both forges unchanged, and "Where this repo lives" below names the handful of places where the two behave differently — each of them measured on a probe pull request, not assumed.
> **This repo lives on the Gitea instance JLab-Git.** Issues, pull requests, CI and Renovate all run there, and `main` there is the leading copy. github.com is a push mirror with Actions and Renovate **switched off**. The workflow files under `.github/workflows/` are read by Gitea unchanged; "Where this repo lives" below names the places where a Gitea runner behaves differently from a GitHub one, each of them measured rather than assumed.
>
> **The knowledge base is the [wiki](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki).** It holds one page per chart — prerequisites, an installation walkthrough, the complete list of configurable values and the chart's operating traps — plus the long form of the workflow, the pipelines, the style guide and the [lessons](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned) each rule was bought with. This README stays the overview for someone reading the code; anything an *operator* needs is in the wiki. Keeping it current is a required step of the workflow (`CLAUDE.md`), not an afterthought.
>
> **A reading note on `#n`.** Issue numbers in this file point at the **GitHub** tracker. Most of them name *closed* issues, which were not migrated and exist only there. Gitea resolves a bare `#n` against its own counter instead — where, for example, `#22` is an unrelated throwaway pull request rather than the install-test issue this file means. The seventeen issues that were still open at the move are Gitea #5–#21 and #24; the mapping is a comment on Gitea #14.
## Charts
Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/<chart>` — carrying the installation walkthrough, the required 1Password fields and the full values reference. The table below is the one-line version.
| Chart | Deploys | Notes |
|---|---|---|
| `apache-tika` | Apache Tika | Deployment + Service only (no ingress) |
| `bentopdf` | BentoPDF | Stateless Deployment + `config.json` ConfigMap, `subPath`-mounted into the web root and always rendered (empty renders as `{}`) |
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
| `cyberchef` | CyberChef | Stateless Deployment |
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service per app; no global `ingress.domain`. Ingress in two shapes that mix: one host per app (default, each app's host required), or one shared `ingress.host` with the apps as sub-paths (documented ingress exception below) |
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr, and mediathekarr off by default) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service per app (mediathekarr's carries both of its ports); no global `ingress.domain`. Ingress in two shapes that mix: one host per app (default, each app's host required), or one shared `ingress.host` with the apps as sub-paths (documented ingress exception below) |
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
@@ -20,15 +26,17 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). **Only the newest Java line is maintained** (chart 5.0.0, #158): the directory carries one line, older ones stay published as frozen artifacts (java8/java17 at `4.0.0`, java21 at `4.1.0`) and receive no further chart changes — publishing once per line out of one directory is what made the lines drift. To run an older JVM with the current chart, set `image.tagOverride`; the chart then checks that `minecraft.version` fits the Java line of the **effective** tag and fails the render with both values named. Two cases pass unchecked on purpose: a tag without a `java<N>` part and a `minecraft.version` that is not a plain numeric `x[.y[.z]]`. `image.tagOverride` is deliberately outside Renovate's reach — `renovate.json5` tracks `appVersion` only. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: <release>-java8` / `-java17` / `-java21` / `-java25`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. Pilot for the strict `values.schema.json` (#68), which is now binding for every chart |
| `outline` | Outline | StatefulSet, separate DB/Redis secrets |
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap |
| `patchmon` | PatchMon | Two Deployments (frontend + backend), OIDC secret |
| `patchmon` | PatchMon | One Deployment (the 2.x server serves frontend and `/api/*` from one process) plus an optional guacd sidecar; OIDC-only sign-in |
| `rallly` | Rallly | Deployment, DB/OIDC/SMTP secrets |
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) |
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
| `searxng` | SearXNG | Stateless Deployment + settings ConfigMap (`use_default_settings: true` plus a free-form `settings.extra` passthrough); the signing key is a required `OnePasswordItem`, an external Valkey an optional second one. `ingress.domain` is read twice — Ingress host and `SEARXNG_BASE_URL` — so it stays required with the Ingress switched off |
| `sure` | Sure (we-promise, the maintained Maybe-finance fork) | One StatefulSet with **two containers**: the Puma web process and a Sidekiq worker, sharing one ReadWriteOnce PVC at `/rails/storage` — that shared volume is the reason they live in one pod. Only the web container migrates (the image entrypoint runs `db:prepare` solely for the `./bin/rails server` command, so the worker is started through `args:` and skips it). Three 1Password items (app/database/redis); Postgres and Redis stay external. Readiness deliberately probes `/` rather than Rails' `/up`, which is a boot check and touches no database; the worker's three probes ask `sidekiqmon` for this pod's own heartbeat. `ingress.domain` is read for `APP_DOMAIN` and the WebAuthn relying party, so it stays required with the Ingress switched off |
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
| `urlaubsverwaltung` | Urlaubsverwaltung | Deployment, DB + OIDC secrets |
| `valkey` | Valkey | One instance per application (cache or queue backend), replacing the shared `cluster-redis` whose single ACL file coupled six applications together. StatefulSet with one replica + ClusterIP Service, no cluster mode, no Ingress and no value to add one. The password arrives as a **finished configuration line** (`requirepass <secret>`) in the 1Password field `auth-conf`, is mounted as a file and `include`d by the config — never in argv, never in the environment; an init container refuses to start the pod unless that line is really there, because the alternative failure is an open server that looks healthy. `maxmemory` is **derived from the computed memory limit** and a configured value above it aborts the render. Persistence and eviction are a pair, not two switches (queue = PVC + `noeviction`, the default; cache = emptyDir + `allkeys-lru`). First chart carrying a **NetworkPolicy** (on by default, Gitea #5), and the first whose probes had to be proven capable of failing: `valkey-cli` exits 0 on an error reply unless `-e` is given |
| `voucher-vault` | VoucherVault | StatefulSet, DB + OIDC secrets |
| `ytdlp-web-player` | ytdlp_web_player | Stateless Deployment, video cache and tool cache as emptyDirs (no PVC). **The only chart with a dependency**: an optional `oauth2-proxy` subchart, off by default, wired in front of the app because the application ships no authentication at all — see "Chart dependencies" below |
| `zipline` | Zipline | StatefulSet, DB secret |
@@ -51,23 +59,57 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
Note: the registry **accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact, so a republish of an existing version is still silent — see #160.
- `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list.
- `ci/helm-dependencies.sh <chart-dir>` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but one). It runs in **five** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in `publish-helm-chart.yml`, and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs.
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix. The **resource-quantity render test** is the exception to that rule: it runs over every chart on every PR — see below.
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix, or run the all-charts install-test after the merge. The **resource-quantity render test** is the exception to that rule: it runs over every chart on every PR — see below.
- `install-test.yml` — reusable workflow (`workflow_call`) holding the install-test **job** itself: the runner-label expression, the pinned `kubectl` with its skew check, `envsubst`, the containerised-job detection, the per-leg k3d cluster and the `freesailarr` VPN credentials. Its one input is a JSON array of chart names; its callers decide *which* charts are tested and whether the job runs at all, never *how*. Two callers: `validate-charts.yml` and `install-test-all-charts.yml`. **It is not copied for the second caller** — a second copy is a second chance to test differently, unnoticed, the same argument that gives `ci/excluded-charts.txt` exactly one parser.
- `install-test-all-charts.yml` — manually dispatched **all-charts install-test**: the same job, with a matrix over every installable chart instead of the changed ones. Optional input for a subset of charts (empty = all). See below for when to run it.
### Where this repo lives, and what Gitea Actions does differently
### Where this repo lives, and what a Gitea runner does differently
The move to the Gitea instance JLab-Git (#225) is in progress. Order matters and is not negotiable: **CI provably green on Gitea → GitHub Actions off → mirror on.** Any other order leaves a window in which either no CI exists or two of them publish into the same registry — and that registry accepts a re-upload of an existing version silently, replacing the artifact (#160).
Everything below was measured on this instance, not taken from documentation, and it is the reason for every deviation in `validate-charts.yml`. How the move got here is not repeated — that is Gitea #14.
Everything below was measured on a throwaway pull request against the Gitea instance, not taken from documentation. It is the reason for the two deviations now in `validate-charts.yml`.
**Reusable workflows with `secrets: inherit` work**, so the 24 thin `publish-<chart>.yml` hulls run unchanged. One quirk worth knowing before debugging the wrong thing: a *missing* secret does not fail the job, it fails job **creation**, so the run shows no steps at all.
- **Reusable workflows with `secrets: inherit` work.** The 23 thin `publish-<chart>.yml` hulls survive the move unchanged. (A missing secret does not fail the job — Gitea fails at *job creation*, so the run shows no steps at all, which is worth knowing before debugging the wrong thing.)
- **`ubuntu-latest` on this instance has no Docker socket; `ubuntu-docker` has.** Same label, different meaning than on GitHub's hosted runners, and only one job in this repo cares: the install-test creates a k3d cluster, and k3d is a Docker client. Measured, per job: `detect-changed-charts`, `build-chart`, the quantity render and the Trivy config scan all pass on `ubuntu-latest` — none of them needs Docker. The install-test on `ubuntu-latest` dies at `Create k3d cluster` with `Cannot connect to the Docker daemon at unix:///var/run/docker.sock`, an error that names k3d and never the runner label.
- **`ubuntu-docker` is necessary but not yet sufficient.** Its daemon (8 vCPU, 64 GB, Docker 29.5.2) is **rootless** and reports `Cgroup Driver: none` plus `WARNING: Running in rootless-mode without cgroups`. k3d gets as far as creating the network, pulling the images and starting the node; the k3s server container then restarts immediately with an empty log. k3s needs cgroup delegation. Until the instance's Docker-in-Docker gets it (rootful, or rootless with systemd delegation), **the install-test cannot run on Gitea at all** — see #225.
- **`aquasecurity/setup-trivy` cannot be used.** It runs `actions/checkout` against `aquasecurity/trivy` on github.com with `github.token`, which on Gitea is a *Gitea* token; github.com answers 401 and checkout retries twice before ending on `fatal: could not read Username for 'https://github.com'`. Its `actions/cache` step is inert on the instance as well (`Cache action is only supported on GHES version >= 3.5`), and fetching the nested actions cost 4m30s before the job started. The workflow now downloads the release tarball instead — one request, identical behaviour on both forges, and the `# renovate:` comment above `TRIVY_VERSION` still keeps it current (proved by dry run: pinned to `v0.72.0`, the lookup offers `v0.74.0` as a minor).
- **Matrix jobs lose their name in the status list.** Gitea does not expand `${{ matrix.chart }}` in a job `name:`; it substitutes the matrix *expression*, so the commit status reads `Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)` and **all legs of a matrix share that one context**. Consequence for branch protection: a per-chart required check is not possible on Gitea, and the required-check names differ from GitHub's — they have to be read off a real run rather than copied.
- **Actions are fetched from github.com anonymously and that works.** `actions/checkout`, `azure/setup-helm` and the 1Password action need no replacement; only an action that *itself* authenticates against github.com breaks (see Trivy above).
- **No merge queue.** Gitea has none. Its place is taken by auto-merge on green checks, which for a single maintainer is equivalent in effect and different in one way worth remembering: auto-merge merges as soon as the checks it knows about are green, so it is only as strong as the *required* checks configured on `main`. With none configured it merges immediately.
**Runner labels mean different machines than on GitHub.** `ubuntu-latest` here has **no Docker socket**; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — and its `runs-on` is therefore an expression rather than a fixed label, so the same file still works on a GitHub runner where `ubuntu-docker` does not exist. Every other job (lint/package, the quantity render, the Trivy scan) needs no socket and stays on `ubuntu-latest`.
Still on the maintainer's side and not delegable: branch protection plus the commit-signature requirement on the Gitea repo, the runner's cgroup problem above, and switching GitHub Actions off before enabling the mirror. The bot token gets `403` on `actions/runners`, `actions/secrets` and `branch_protections`, so none of that can be verified from here.
**A matrix runs concurrently inside ONE job, under ONE status context.** This is the single most consequential difference and it has two halves. Operationally: all legs share one Docker daemon, which is what makes a per-leg resource name mandatory (see the cluster name below). For branch protection: **a required check per chart is not possible**, because there is only one context for the whole matrix — and its name carries the *unexpanded* matrix expression, e.g. `Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)`. Required-check names have to be read off a real run; they cannot be copied from GitHub. That example name is from before the install-test job moved into the reusable `install-test.yml` (Gitea #52) — the matrix expression now reads `${{ fromJSON(inputs.charts) }}` and the context may carry the calling job's name as well, so **read the current name off the next chart PR before using it anywhere**. What *is* required on `main` today is **`validate-charts`**, as the single context it reports under, alongside a **review**: per chart is the part that is impossible, not required checks as such. Both are what makes Renovate's `platformAutomerge: true` safe — see "PR policy".
**Expected runtimes**, so that a later deviation is visible as one:
| | measured here | GitHub, same commit |
|---|---|---|
| `Create k3d cluster` | 19–25 s | 22–23 s |
| `install-test` job, two charts in the matrix (cyberchef + freesailarr) | 210–216 s over three runs, spread 6 s | ~145 s wall, two parallel VMs |
| `install-test` job, freesailarr alone, ten containers and a real tunnel | 225 s (measured on the MediathekArr change, Gitea #20) | not comparable — Actions there were already off |
| `install-test` job, **20 charts** in the matrix — the accidental all-charts run of Gitea PR #48 | **12m41s**, one job, all legs concurrently on one Docker daemon; 19 of 20 charts Ready, the twentieth is Gitea #51 | never ran there |
The runner is **not slower** — cluster creation is a touch faster. The difference in total time is **structural, not performance**: two matrix legs share one runner here, while GitHub gives each leg its own VM. Reading it as "Gitea is slower" leads to the wrong conclusion.
**`aquasecurity/setup-trivy` cannot be used.** It runs `actions/checkout` against `aquasecurity/trivy` on github.com and hands it `github.token` — which here is a *Gitea* token. github.com answers 401 and checkout ends on `fatal: could not read Username for 'https://github.com'`, naming neither the action nor the token. Its `actions/cache` step is inert here as well. The workflow downloads the release tarball instead: one request, same behaviour on either forge.
**Tools are installed, not inherited — and the two cases differ on purpose.** `kubectl` is **pinned** to match `K3S_IMAGE` and guarded against version skew, because a client's distance to the API server changes what the test *means*, and a version that arrives with the runner image drifts with every image rebuild. `envsubst` is only **checked for and fetched if absent**, because it substitutes text and the release doing so does not change the result; pinning it would be upkeep without value. That GitHub's image happens to ship both does not make either unnecessary — it only makes the drift invisible there.
**Containerisation is detected, not inferred from the platform.** k3d publishes the API server port on the Docker *host*; when the job is itself a container — as every Gitea Actions job is — that host is the dind container, so the `https://0.0.0.0:<port>` k3d writes into the kubeconfig points at the job container, where nothing listens. The workflow asks the daemon about its own container instead of asking which forge it is on: a self-hosted **GitHub** runner in a container has the identical problem, and a condition that says "GitHub" while meaning "not containerised" is the kind of lie this repo exists to remove.
Still unmeasured, because they never stood in the way: `/dev/fuse` and user namespaces. Said as unmeasured rather than as fine.
#### What a GitHub runner had been providing quietly
Getting the install-test to run here surfaced six assumptions that had never been written down anywhere. Five of them say something about the **runner**, one about **our code**, and the split is the point.
The five environment ones are the harmless kind: they fail loudly, immediately, and are fixed on the node side (`cluster-config`), not here — Docker bridges built at MTU 1500 against a pod network of 1450 (small responses fit in one packet, a 17 MB tarball does not, which is why it looked like a blocked host); `fs.inotify` limits too low for k3s, so containerd never started and the node never registered; no `kubectl` and no `envsubst` in the image; and the kubeconfig pointing at `0.0.0.0`.
The sixth is different, and it is the one to remember.
> **The k3d cluster name was hard-coded as `install-test` — and that was wrong on GitHub too.** It simply could never show there: each matrix leg gets its own VM with its own Docker daemon, so two legs never saw each other's cluster. Here, where all legs share one daemon, it collides immediately — one leg logs `Re-using existing network`, the other dies on `The container name /k3d-install-test-server-0 is already in use`, and the first leg's rollback then deletes the shared network out from under the second. Two red legs, and neither message names the cause.
>
> For two years the roomier environment carried that bug instead of showing it. **A generous environment is no friend when you want to know whether something is correct.**
#### Two rules for every check added here
Both were learned by getting them wrong in this repo, and both apply to any new verification step, not just to clusters.
1. **A check that a broken system passes is worse than no check.** `kubectl get nodes` exits **0 on an empty list**, so a readiness check written that way went green against a cluster whose node had never registered — and the failure then surfaced five minutes later, somewhere unrelated. `kubectl wait --for=condition=Ready nodes --all` is not the shorter way to write it: with zero nodes the condition is vacuously satisfied. Assert the thing you mean (*at least one node, and Ready*), not that the command answered.
2. **Green does not mean checked.** A passing job earns exactly as much trust as the assertions inside it. The open case of this kind is Gitea #24: the images in `ci/<chart>/fixtures.yaml` are not followed by Renovate, so the install-test can keep passing against fixture infrastructure that is years stale. **Not solved** — named here so it is not mistaken for solved.
### Resource-quantity render test (all charts, every PR)
@@ -91,6 +133,8 @@ Two details worth knowing before touching it:
For every chart a PR changes, `ci/run-install-test.sh <chart>` runs against a throwaway k3d cluster: it applies the stub `OnePasswordItem` CRD plus the chart's fixtures, `helm install`s the chart with its CI values, and waits for all Deployments/StatefulSets to roll out (per-chart timeout). Since probes and security hardening are active on all workloads, "Ready" means the app actually answers. On failure the job dumps pod descriptions, events and container logs.
The job itself is the reusable workflow **`install-test.yml`**; `validate-charts.yml` calls it with the changed charts, `install-test-all-charts.yml` with all of them. Everything about *how* a chart is installed is in that one file, deliberately — see "All-charts install-test" below.
Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the chart directories, so they are neither packaged into the chart tgz nor trigger the publish workflows on merge):
- `ci/common/` — fixtures applied for every chart (schema-less stub CRD for `OnePasswordItem`)
@@ -106,9 +150,23 @@ Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the ch
Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to dodge runner rate limits. The job **blocks the PR** on failure — the stabilization phase of #22 ended when Renovate started automerging patch/minor updates, since a chart that no longer starts must not reach `main` and publish unattended. When adding a new chart, add its `ci/<chart>/` fixtures (or an entry in `ci/excluded-charts.txt`) — the install-test fails if both are missing. The script also runs locally against any existing cluster: `k3d cluster create t && ci/run-install-test.sh <chart> && k3d cluster delete t`.
### All-charts install-test (on demand)
`install-test-all-charts.yml` runs **the same job** as the PR pipeline — the same `install-test.yml`, the same pinned `kubectl`, the same fixtures — with a matrix over **every installable chart** instead of the charts a PR changed. `workflow_dispatch` only, **no schedule**: a daily run would spend compute on a question that changes rarely, and a red run nobody asked for is a red run nobody reads.
It answers a different question from the PR pipeline. The PR install-test guards against what a change breaks; this one asks whether the environment under an **unchanged** chart still holds. The install-test matrix follows the charts a PR touches, so a chart nobody has edited since the forge move carries a green history from a *different runner* — and that green says nothing about today. **Run it after a change to the runner, to `K3S_IMAGE`, to the pinned `KUBECTL_VERSION`, to the fixtures under `ci/` — and after a forge move.** It is also the answer for a PR that only changes the harness and therefore tests nothing by itself: run it once the change is on `main`.
Why it exists at all: the answer arrived once by accident. Gitea PR #48 corrected a *comment* in 21 charts, which made all of them "changed" and turned the run into the first all-charts install-test after the move — **19 of 20 matrix charts came up in 12m41s** in one job, and exactly one did not (`rss-bridge`, whose last green run was from a GitHub runner; Gitea #51). Worth having, and nobody had planned it.
- **Input `charts`** — space- or comma-separated chart names, empty means all. That is what lets a single chart be tested against the runner, which is what Gitea #51 needs. A name that is not a chart directory, or one listed in `ci/excluded-charts.txt`, is a **loud error**: someone typed it, and a green run over zero charts would be the worst possible answer. An empty matrix fails the run for the same reason.
- **One throwaway cluster per chart, no reuse.** Reuse would save time and change what the test proves — leftovers from the previous install, a warm image cache, fixtures already applied. "Came up from nothing" is the statement.
- **Exclusions come from the one parser**, `ci/run-install-test.sh --list-excluded`, and the excluded charts appear in the job summary **with their justification**, exactly as in the PR pipeline.
- **The result is written down.** Every matrix leg records its own outcome next to the four facts that decide what the outcome means — date, runner label, `K3S_IMAGE`, `kubectl` version — and a final job records the verdict over the whole matrix. A green run nobody notes is unknown again next time. Per leg and not as one table, because a matrix leg only ever sees its own job summary.
- **Expected runtime: 20 charts in 12m41s** (Gitea PR #48, one job, all legs concurrently on one Docker daemon). The figure is there so a later deviation is visible as one; compare it against the chart count the run prints in its own summary — 24 at the time of writing, and one more with every new chart. The job timeout is 30 minutes per leg and lives in `install-test.yml`, i.e. it is the same for both callers on purpose.
## Dependency updates (Renovate)
Renovate runs as the self-hosted `jlab-renovate` GitHub App while the repo is on github.com, and on Gitea as the shared `jlab-renovate-bot` instance that already serves the other repositories there — see "Renovate on Gitea" below. **There is no Renovate workflow in this repo, and none should be added.** Which of the two runs it changes nothing about *what* gets updated: its config is **`renovate.json5`** in the repo root (JSON5 for the inline reasoning; requires Renovate >= 41 for `managerFilePatterns`/`customType`). There must be exactly one config file — do not add a second `renovate.json` or `.github/renovate.json`.
Renovate runs as the shared `jlab-renovate-bot` instance on the Gitea side, which also serves the other repositories there — see "Renovate on Gitea" below. **There is no Renovate workflow in this repo, and none should be added.** (The `jlab-renovate` GitHub App that used to do this is switched off along with the rest of GitHub Actions.) What updates is unaffected either way: the config is **`renovate.json5`** in the repo root (JSON5 for the inline reasoning; requires Renovate >= 41 for `managerFilePatterns`/`customType`). There must be exactly one config file — do not add a second `renovate.json` or `.github/renovate.json`.
What Renovate keeps current:
@@ -116,15 +174,15 @@ What Renovate keeps current:
| --- | --- |
| Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository |
| Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager |
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block |
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block |
| GitHub Actions | Built-in `github-actions` manager |
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `searxng`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one.
One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), so a non-numeric commit hash captured as `build` becomes whatever digits lead it — `9fea41204` is `9`, `777ba8fa4` is `777`, and a hash starting with a letter is `NaN`. Measured on renovate 41.173.1 and 44.48.0, that makes Renovate offer an image pushed *earlier the same day* as an update to a newer one, unattended under the patch/minor automerge rule. Parts of a tag that carry no ordering belong **outside** every group: the anchored pattern still requires them to be there, they simply take no part in the comparison. `prerelease` is not the way out either — it sorts correctly but marks every tag `isStable === false`, which hands the whole chart to `ignoreUnstable`. `freesailarr` has no block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image.
One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), so a non-numeric commit hash captured as `build` becomes whatever digits lead it — `9fea41204` is `9`, `777ba8fa4` is `777`, and a hash starting with a letter is `NaN`. Measured on renovate 41.173.1 and 44.48.0, that makes Renovate offer an image pushed *earlier the same day* as an update to a newer one — a downgrade wearing the clothes of a routine date bump, which is exactly what a review waves through and automerge then carries to `main`. Parts of a tag that carry no ordering belong **outside** every group: the anchored pattern still requires them to be there, they simply take no part in the comparison. `prerelease` is not the way out either — it sorts correctly but marks every tag `isStable === false`, which hands the whole chart to `ignoreUnstable`. `freesailarr` has no **app-version** block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image. It does carry one `customManagers` block, for `mediathekarr`: that image is pinned by digest because its tags carry no version (`latest` is a 2024 build, `beta` moves), and the built-in `helm-values` manager reads only `repository`/`tag` and skips it (`skipReason: invalid-value`), so the two cannot collide. Digest rules need the same guard as versioning rules and for a sharper reason: while the pin is current, a matching rule and a broken one produce the identical empty result. Verified with two dry-run fixtures — the real digest yields no update, a deliberately stale one yields exactly one `digest` update back to the pinned digest.
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all nine images alike — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all ten images alike, the digest-pinned one included — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
Two dependencies are constrained beyond their versioning. **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning.
@@ -150,28 +208,34 @@ Everything is derived from the base commit, so the script is idempotent across t
>
> Without that entry Renovate skips the task without an error and every chart PR stays red on the version check.
If the dependency dashboard reports `Failed to look up github-releases package …: no-result` for third-party repos, that is the instance's GitHub credentials, not this config: a GitHub App installation token is not a good fit for looking up repos the app is not installed on. Give the instance a separate read-only PAT as `GITHUB_COM_TOKEN` for github.com datasource lookups.
If the dependency dashboard reports `Failed to look up github-releases package …: no-result` for third-party repos, that is the instance's github.com credentials, not this config. The instance needs a read-only PAT as `GITHUB_COM_TOKEN` for github.com datasource lookups; its own platform token is a Gitea token and is worth nothing there.
### Renovate on Gitea
**Do not add a Renovate workflow to this repository.** It is the obvious thing to reach for — Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — and it is wrong here: a **shared `jlab-renovate-bot` instance already runs on the Gitea side** and already has this repository enrolled. A second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches, which is not a safety net but a fault. This paragraph exists so the next reader who finds no workflow knows why instead of building one.
**Do not add a Renovate workflow to this repository.** It is the obvious thing to reach for — Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — and it is wrong here: a **shared `jlab-renovate-bot` instance** runs outside this repository and has it enrolled. A second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches, which is not a safety net but a fault. This paragraph exists so the next reader who finds no workflow knows why instead of building one.
The instance is outside this repository, which has two consequences worth writing down:
- **It reads our `renovate.json5` and the per-chart rules do fire.** Measured, not assumed: its first run on the migrated repo opened PRs for `searxng` and `gotenberg` plus the dependency dashboard. Those two are `customManagers` targets of ours — no built-in manager finds an `appVersion:` in a `Chart.yaml` — so the config is demonstrably in effect.
- **`allowedCommands` is already granted there, and the version-bump hook runs.** This was the expensive unknown: `postUpgradeTasks` only executes when the command is allow-listed in the *instance* config, and a missing entry is skipped **silently**, leaving every chart PR red on the version check. The proof is in that searxng PR's diff, which carries `version: 1.0.4+up2026.9.11.ffe96f8a6` → `1.0.5+up2026.9.13.96479fff6` alongside the `appVersion` change. Nothing but `ci/renovate-bump-chart.sh` writes `version:`. Nothing has to be configured for the move — but the allow-list is not visible from this repo either, so if it is ever dropped, the symptom here will be red chart PRs with no explanation.
- **It reads our `renovate.json5` and the per-chart rules do fire.** Measured, not assumed: its first run here opened PRs for `searxng` and `gotenberg` plus the dependency dashboard. Those two are `customManagers` targets of ours — no built-in manager finds an `appVersion:` in a `Chart.yaml` — so the config is demonstrably in effect.
- **`allowedCommands` is already granted there, and the version-bump hook runs.** This was the expensive unknown: `postUpgradeTasks` only executes when the command is allow-listed in the *instance* config, and a missing entry is skipped **silently**, leaving every chart PR red on the version check. The proof is in that searxng PR's diff, which carries `version: 1.0.4+up2026.9.11.ffe96f8a6` → `1.0.5+up2026.9.13.96479fff6` alongside the `appVersion` change. Nothing but `ci/renovate-bump-chart.sh` writes `version:`. The allow-list is not visible from this repo, though, so if it is ever dropped the symptom here will be red chart PRs with no explanation.
One item does not come for free on either side and is named in "The version-bump hook" above: a `GITHUB_COM_TOKEN` for github.com datasource lookups. Under the GitHub App those rode on the installation token; a Gitea token is worth nothing at github.com, and unauthenticated lookups hit the rate limit inside a single pass. The symptom is a `no-result` line on the dependency dashboard, not an error.
One item is easy to miss and is named in "The version-bump hook" above: the instance needs a `GITHUB_COM_TOKEN` for github.com datasource lookups. Every `github-releases`/`github-tags` lookup and the whole built-in `github-actions` manager resolve against github.com, the platform token is a Gitea one and worth nothing there, and unauthenticated lookups hit the rate limit inside a single pass. The symptom is a `no-result` line on the dependency dashboard, not an error.
### PR policy
- **Automerge** for `patch`, `minor` and `digest` updates, after a `minimumReleaseAge` of 3 days and only once the PR pipeline is green — install-test included, which is why that job no longer runs with `continue-on-error`. Minor is in scope because the sidecar images barely move in patch steps (`alpine 3.22` → `3.24`, `nginx 1.30.4` → `1.31.4` are both minor), so a patch-only rule would practically never fire. Renovate merges itself (`platformAutomerge: false`) rather than handing the PR to the forge's own auto-merge, which on **both** GitHub and Gitea would merge immediately as long as `main` has no required status checks. That is a statement about the branch protection, not about the platform: once required checks exist on `main`, `platformAutomerge: true` becomes the better setting on either side. Majors are merged by hand.
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires.
- `prConcurrentLimit: 10` / `prHourlyLimit: 4` keep the k3d install-tests from all starting at once.
The config is aligned with the one in `cluster-config`, because the same `jlab-renovate-bot` instance serves both and two repos that behave differently under one bot is a source of surprises rather than of safety.
- **Automerge is global** (`automerge: true`, `automergeType: "pr"`, `platformAutomerge: true`) — every update type, **majors included**. The safeguard is not the absence of automerge, it is the branch protection: `main` requires a **review** and requires `validate-charts` to pass. `platformAutomerge` arms the PR on the forge, and Gitea merges it when *both* conditions are met, so an armed PR without a review simply sits there. This is the setting the older `platformAutomerge: false` comment in `renovate.json5` named as the better one "once required checks exist on `main`" — they now do. Same reasoning as `cluster-config`, which retired its dashboard-approval step for it.
- **`minimumReleaseAge: "3 days"`** for `patch`, `minor` and `digest` updates. Renovate does not even open the PR before then. A merge here is never just a merge — a chart publish follows it straight away — so a release that gets yanked hours after it appeared must not reach the registry. Majors carry no age gate: they are held at the review, not by a delay.
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates.
- **No PR caps** (`prConcurrentLimit: 100`, `prHourlyLimit: 0`), as in `cluster-config`. The known cost, written down rather than discovered: the first run after a quiet week can open many PRs at once, and every chart PR starts a k3d install-test — which on a Gitea runner queues inside one job rather than fanning out (see "Where this repo lives"). That queue is the accepted trade for not having updates trickle in four per hour.
- **`config:best-practices`** is in `extends`. Two of its members matter here: `helpers:pinGitHubActionDigests` moves the workflow files from `actions/checkout@v7` to SHA pins (wanted), and `docker:pinDigests` is switched off again for `custom.regex` and `helm-values` — an `appVersion:` is a tag by construction (`<repo>:{{ .Chart.AppVersion }}`, and the `+up` suffix is built from the same string), so a digest has nowhere to go. Sidecar digests in `values.yaml` *could* be pinned; that is a separate decision, since each digest change would be a chart patch plus a publish.
- The manually dispatched `change-app-version.yml` still exists for one-off bumps, but the normal path for app updates is now a Renovate PR.
## Creating a new chart
The wiki carries the same procedure as a worked checklist: [Creating a new chart](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/New-Chart). A new chart also needs a wiki page of its own before its PR is merged.
1. Copy `template-chart/` to `<chart-name>/` and replace every `xxx` placeholder: template file names (`xxx.deployment.yaml` → `<app>.deployment.yaml`), `Chart.yaml`, the `values.yaml` keys, the `.Values.xxx.*` references inside the templates, **and both the `xxx.` helper prefixes and the values paths in `values.schema.json`**. Then walk the six points in "Values that must not lie" below — the schema and the null-safe fallbacks are copied with the template, but their content is chart-specific and wrong until you adapt it. The same goes for `templates/NOTES.txt`: it comes with the template as a worked example and is placeholder text until you replace it with what an operator of *your* app needs after the install (see "Everything else" below); its leading `{{/* … */}}` block explains the bar and is meant to be deleted.
2. Add `.github/workflows/publish-<chart-name>.yml` (copy an existing one, e.g. `publish-outline.yml`).
3. Add the chart name to the choice list in `.github/workflows/change-app-version.yml`.
@@ -191,7 +255,7 @@ For runtime verification (do the workloads actually start, do probes/security se
## Chart style guidelines
These guidelines are **binding for every current and future chart** in this repo. They are the consolidated end state of the conventions overhaul (#32, batches 0–4); `template-chart/` is the reference implementation.
These guidelines are **binding for every current and future chart** in this repo. They are the consolidated end state of the conventions overhaul (#32, batches 0–4); `template-chart/` is the reference implementation. The wiki mirrors them as the [chart style guide](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Chart-Style-Guide), with the incident behind each rule collected in [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned) — change a rule here and update that page in the same PR.
### 1. Naming
@@ -212,7 +276,7 @@ These guidelines are **binding for every current and future chart** in this repo
### 3. Images
- The main app image tag comes from `{{ .Chart.AppVersion }}` — never hardcoded.
- **Documented exception — freesailarr**: the chart bundles nine independently versioned upstreams, so none of them is "the" app version. **No** tag comes from `appVersion`; all nine images are pinned as `<app>.image.repository` / `<app>.image.tag` in `values.yaml`, which is also what puts every one of them under the built-in `helm-values` manager. Its `appVersion` is the chart's own version marker, freely chosen and tied to no upstream — raised by hand when a new edition of the chart is marked as a whole. Consequently the chart has **no** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list.
- **Documented exception — freesailarr**: the chart bundles ten independently versioned upstreams (nine of them enabled by default), so none of them is "the" app version. **No** tag comes from `appVersion`; every image is pinned as `<app>.image.repository` / `<app>.image.tag` in `values.yaml`, which is also what puts them under the built-in `helm-values` manager. Its `appVersion` is the chart's own version marker, freely chosen and tied to no upstream — raised by hand when a new edition of the chart is marked as a whole. Consequently the chart has **no app-version** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list. `mediathekarr` adds a second deviation on top: its tags carry no version at all, so it is pinned by `image.digest` as well and is the one image in this chart with a `customManagers` block — a `repository`/`tag` pair alone would leave it without any update path.
- **Documented exception — minecraft-server**: the only chart whose main image tag can be overridden, through `image.tagOverride` (#158, empty by default — the tag still comes from `appVersion`). `appVersion` is the tag and therefore fixes the JVM, and since chart 5.0.0 only the **newest** Java line is maintained out of this one directory; the older lines stay published as frozen chart versions (java8 and java17 at 4.0.0, java21 at 4.1.0) and never receive another change. Without the override, running an older JVM would mean installing a frozen chart and giving up every later fix — so the line is a decision at rollout time rather than a property of the chart. The override is named `tagOverride`, not `tag`, because it decouples the running image from the chart version, and it is deliberately outside Renovate's reach (`renovate.json5` tracks the `appVersion` in `Chart.yaml` and nothing else). The chart checks the pairing itself: the Java line of the **effective** tag against `minecraft.version`, failing the render with both values named — a mismatch used to install silently and only break the container afterwards.
- Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round.
@@ -224,7 +288,7 @@ These guidelines are **binding for every current and future chart** in this repo
- The TLS secret is named `tls-<release>-<chart>-ingress`.
- **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field).
- **A shared domain with sub-paths is the second admissible ingress shape.** Where a chart exposes several apps, they may either each take a host of their own or share one host and be told apart by path (`domain.tld/sonarr`, `domain.tld/radarr`, …) — one DNS name and one certificate for the whole bundle. The rules for the shared shape: the domain is a single value (`ingress.host`), each app's path is its own value defaulting to `/<app>`, the paths render in one Ingress object `<release>-<chart>-ingress` with the standard TLS secret `tls-<release>-<chart>-ingress`, and an app that carries a host of its own always wins and keeps its own object — so both shapes mix without extra values. An app that needs a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the same TLS secret name and **omitting** the cert-manager annotation, so that exactly one certificate is requested. Apps that cannot serve a base path must be rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr is pinned to `/`. `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr as well as mediathekarr are pinned to `/` — mediathekarr because its UI links assets with root-absolute paths, which a rewrite cannot fix; with both of them on one shared host the duplicate-path guard fires and one of them needs a host of its own (its ingress is therefore off by default). `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
### 5. Security hardening
@@ -301,6 +365,12 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
**2. Probes, security contexts and resources go through the null-safe helper, never behind an `if`.** An override written without children is YAML null and erases the block: the container renders `securityContext: null` (unhardened, healthy, silent), a probe disappears, or `resources` loses its requests and computed limits. The `defaultedDict` helper defines the opposite semantics — an empty block means "chart defaults apply" — while an explicitly set value still wins, **including `false` and `0`** (hence the `kindIs "invalid"` check; `default` and `mergeOverwrite` both swallow zero values). Do **not** wrap these blocks in `{{- if }}`: a guard does not render a visible `null`, it removes the probe entirely, which is the same bug with no evidence left.
The rule is about the **shape, not about those three block kinds**. `satisfactory` shipped 7.0.0 with `game` null-safe and its two neighbours not (Gitea #16): `--set server=null` aborted the render, and a single erased key was the quieter half — a nulled `server.gamePort` rendered `port:` and `nodePort:` empty into the Service and an empty `containerPort` into the StatefulSet, rejected by nothing until the API server saw it. Every chart-owned block a template dereferences belongs behind the helper, and a chart where only *some* of them do is worse than one where none do: whoever sees `game=null` fall back cleanly assumes `server=null` does too.
**A deliberate exception is fine; an undocumented one is the bug.** `satisfactory.storage` is left un-defaulted on purpose — an erased size yields an invalid PVC that fails at apply time rather than a volume quietly sized by the chart — so `server: null` still renders a PVC without a size. That is a defensible trade and it is *not* the null-safety bug next to it; what makes it safe to leave standing is that the schema, `values.yaml` and the chart page all name it as an exception and point at the issue where it is argued (Gitea #39). Note also where the trade actually bites: only on a **new** install. For an existing release a fallback to the default size would fail too, because a PVC cannot shrink. Do not overturn a reasoned exception inside a bugfix patch — the patch then stops being one.
**And `helm template` does not render `NOTES.txt`.** A value read there — `server` is read in four files in that chart — stays broken after a fix that looks green in every render, until someone runs a real `helm install`. `helm install --dry-run=client` is not the way around it either: it still wants a reachable API server (measured on helm v3.22.0). What works without a cluster: in a throwaway copy of the chart, wrap the verbatim `NOTES.txt` body in a `{{- define }}` and `include` it into a scratch ConfigMap, which `helm template` *does* render — same engine, same root context, only the sink differs.
**3. Do not hang liveness on an external dependency where a dependency-free path exists.** `zipline` deliberately probes `/` for liveness and `/api/healthcheck` only for readiness and startup, so a database outage cannot restart the pod. Where no such path exists — `outline` returns 500 on `/` when the database is down — all three probes may share the endpoint, but the reason belongs in a comment on the helper, or the next reader will "fix" the inconsistency.
The other half of the same rule: readiness **must** hang on the dependency, and a separate path is not proof that it does. `urlaubsverwaltung` probed `/actuator/health/liveness` and `/actuator/health/readiness` for two years and still stayed 1/1 Ready through a total database outage (#218), because Spring Boot's `readiness` group contains `readinessState` **alone** — the split paths were right, the group content was empty of anything external. Whatever aggregates behind a probe path (Spring health groups, a `/healthz` a framework assembles for you) has to be read off the **running** endpoint, not off the path name; the chart names the members explicitly (`MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db`) so the composition is in the template rather than in an upstream default.
@@ -313,12 +383,18 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
**Two things about the mechanics that are easy to lose:**
- **`null` reaches the schema in one case and not the other.** For a key **with** a chart default, Helm's coalescing treats the `null` as a deletion and removes it *before* schema validation — the schema never sees it, and only the helper can catch it. For a key **without** a default (e.g. `resources.limits`), there is nothing to delete, the `null` survives into validation, and a strict `"type": "object"` aborts the render. That is why the helper **and** the widened `["object", "null"]` types are both needed: each covers a case the other cannot. Everything else stays strictly typed.
- **`null` reaches the schema in two cases out of three, and which one you are in depends on the chart's default, not on what the user wrote.** Measured on Helm 3.22.0 with a probe chart, because the wording here was wrong before and was applied to exactly the case it got wrong:
- a user `null` over a **non-null** chart default (`--set x=null`, or `x: null` in a values file) is treated as a deletion and the key is removed *before* schema validation — the schema never sees it, and only the helper can catch it;
- a **`null` chart default** survives into validation with or without a user `null` on top of it, because there is nothing to delete it against, and the schema sees the key present with value `null`;
- a key **without any default** (e.g. `resources.limits`) likewise survives, and a strict `"type": "object"` aborts the render.
That is why the helper **and** the widened `["object", "null"]` / `["string", "null"]` types are both needed: each covers a case the other cannot. Everything else stays strictly typed.
- **A value that has no valid default cannot be enforced by `values.schema.json` at all, and `required()` is the whole enforcement.** The chain is short and it has no exit (#18): the key ships as `null`, so it is *present* at validation time, and JSON Schema's `required` tests presence and not null-ness — the array never fires, under any input. The two ways to make it fire both break the rule that `helm lint --strict` passes on chart defaults, since lint validates those defaults against the schema: dropping the `null` default gives `missing property '<key>'`, and narrowing `["string", "null"]` to `"string"` gives `got null, want string` — each a red `validate-charts` run on a chart nobody touched. So such a key is guarded with `required()` in the template and **described** as required in the schema; a `required` array next to it would be a guard that cannot fire, which is the same defect as a value that does nothing, dressed as a safeguard. This is why no chart in this repo carries one.
- **Validate every schema against the real values, not only against chart defaults.** Defaults are valid by construction, so they prove nothing. Render each chart against its bundle in `cluster-config/fleet-cd/` **and** against `helm get values` — the two differ more often than expected (four of ten bundles in one wave, always with the repo file being the worse one). This step caught a schema of mine that would have broken three live paperless releases, because all three write `tikaGotenberg.enabled` as the string `"true"` and the first draft demanded a boolean. Result per chart is a **list of rejected keys**, which is the cluster side's work order before the version is pulled up.
### 13. Everything else
- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one**: the rule everywhere else is "the icon is this chart's one upstream's logo", and a chart bundling nine independently versioned projects has no such upstream — picking one of the nine would imply a "main app" the stack deliberately does not have. That is the whole criterion, and it applies to no other chart in the repo: a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict` (`icon is recommended`), which still exits 0.
- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one**: the rule everywhere else is "the icon is this chart's one upstream's logo", and a chart bundling ten independently versioned projects has no such upstream — picking one of the ten would imply a "main app" the stack deliberately does not have. That is the whole criterion, and it applies to no other chart in the repo: a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict` (`icon is recommended`), which still exits 0.
- `helm lint --strict` must pass without findings.
- **Every chart ships a `templates/NOTES.txt`** (#43), `template-chart` included — so this is a requirement for a new chart, not a nice-to-have it can defer. No list of names here on purpose: a count goes stale the moment the next chart lands, which is exactly how the previous wording came to claim a number it never matched. Reference implementation: `template-chart/templates/NOTES.txt`, a placeholder-carrying template rather than finished prose, so a new chart adapts it instead of starting from an empty file. The bar it sets: print what an operator would otherwise have to look up in the templates (the rendered access URL, **which fields the 1Password item must carry**, the options actually active in this release, and the chart's known traps), conditional on the values really set. A `NOTES.txt` that only repeats the values is skimmed once and never read again. **No real domain anywhere** — the URL renders from `ingress.domain`, and where an example is unavoidable it is `example.com`. A `NOTES.txt` renders no Kubernetes object, so writing or reworking one is purely additive: a minor bump.
- CI: every installable chart has fixtures under `ci/<chart>/` (`test-values.yaml`, optionally `fixtures.yaml`/`fixtures.env.yaml`/`settings.env`) or a justified entry in `ci/excluded-charts.txt` — the install-test fails if both are missing.
+1 -1
View File
@@ -5,5 +5,5 @@ icon: https://www.apache.org/logos/res/tika/default.png
type: application
version: 10.0.1+up3.3.1.0.full
version: 10.0.2+up3.3.1.0.full
appVersion: "3.3.1.0-full"
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "apache-tika.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "apache-tika.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "apache-tika.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/alam00000/bentopdf/main/public/images/fa
type: application
version: 1.0.2+up2.8.8
version: 1.0.3+up2.8.8
appVersion: "2.8.8"
maintainers:
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "bentopdf.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "bentopdf.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "bentopdf.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+1 -1
View File
@@ -5,7 +5,7 @@ name: calibre-web-automated
description: A Helm chart to deploy calibere-web-automated
icon: https://raw.githubusercontent.com/crocodilestick/Calibre-Web-Automated/main/README_images/cwa-logo-round-dark.png
version: 6.1.0+upv4.0.6
version: 7.0.1+upv4.0.6
appVersion: v4.0.6
maintainers:
+4 -7
View File
@@ -61,7 +61,7 @@ THE FIRST START BUILDS A LIBRARY, AND THE STARTUP PROBE IS TIMING IT
kubectl -n {{ .Release.Namespace }} logs {{ $pod }} -f
SECRETS — THE FIELDS THE 1PASSWORD ITEM MUST CARRY
{{ if .Values.secrets.cwaSecretRef }}{{ .Values.secrets.cwaSecretRef }}{{ else }}(secrets.cwaSecretRef is unset — see below){{ end }}
{{ .Values.secrets.cwaSecretRef }}
→ Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
hardcover-api-token becomes the HARDCOVER_TOKEN environment variable
calibre-plugins mounted as /config/.config/calibre/customize.py.json,
@@ -74,13 +74,10 @@ SECRETS — THE FIELDS THE 1PASSWORD ITEM MUST CARRY
hardcover-api-token parks the pod in CreateContainerConfigError; a missing
calibre-plugins fails the mount instead ("couldn't find key calibre-plugins"),
and the container is never created at all.
{{- if not .Values.secrets.cwaSecretRef }}
secrets.cwaSecretRef IS UNSET. The OnePasswordItem has been created with an
empty itemPath, the operator has nothing to resolve, and the Secret above will
never appear — the pod cannot start. Set it to the item's op:// path and
upgrade.
{{- end }}
secrets.cwaSecretRef itself is required: the template guards it with
required(), so a release without it does not render at all rather than
creating a OnePasswordItem with an empty itemPath.
CHANGING THE ITEM'S CONTENT RESTARTS NOTHING
Helm never sees what is inside a OnePasswordItem, so it cannot hash it into the
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "calibre-web-automated.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "calibre-web-automated.resources" -}}
{{- $resources := . | default dict -}}
@@ -167,8 +169,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "calibre-web-automated.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
@@ -3,4 +3,4 @@ kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
spec:
itemPath: {{ .Values.secrets.cwaSecretRef }}
itemPath: {{ required "A 1Password item reference must be provided (secrets.cwaSecretRef)" .Values.secrets.cwaSecretRef }}
+1
View File
@@ -17,6 +17,7 @@
"additionalProperties": false,
"properties": {
"cwaSecretRef": {
"description": "op:// item path carrying \"hardcover-api-token\" and \"calibre-plugins\". Required - the OnePasswordItem, the HARDCOVER_TOKEN env reference and the plugin volume are all rendered unconditionally. Enforced by required() in the template, not here: the null default keeps this type widened, and a schema \"required\" entry could never fire against a key that is present with value null.",
"type": ["string", "null"]
}
}
+9
View File
@@ -5,6 +5,15 @@
scaleDown: false
secrets:
# REQUIRED - op:// item path for the 1Password operator, carrying the fields
# "hardcover-api-token" and "calibre-plugins". Both are needed even if you
# never use Hardcover: the OnePasswordItem is created unconditionally, and so
# are the environment reference and the volume. Without this value the render
# aborts - required() in calibre-web-automated.secret.yaml is the whole
# enforcement, and values.schema.json cannot help here (README, guideline 12).
# Before the guard existed it produced an OnePasswordItem with an empty
# itemPath, the operator had nothing to resolve, the Secret never appeared and
# the pod could not start.
cwaSecretRef: null
cwa:
+1 -1
View File
@@ -5,7 +5,7 @@
# (REQUIRED_ENV), and pipes the result straight into kubectl.
#
# The values come from a 1Password item holding a CI-only Mullvad key, loaded in
# the install-test job of .github/workflows/validate-charts.yml. A CI-only key
# the install-test job of .github/workflows/install-test.yml. A CI-only key
# on purpose: Mullvad ties an address to a key and sends the tunnel to the
# endpoint it saw last, so a shared key would let every pull request pull the
# tunnel out from under the running stack.
+6 -3
View File
@@ -1,8 +1,11 @@
# Nine containers in one pod, several GB of images to pull, and the app
# Ten containers in one pod, several GB of images to pull, and the app
# containers only start once gluetun has completed its Wireguard handshake -
# each of them then runs its own startup probe (150s budget, 300s for seerr's
# first-boot migrations). The default of 300s is not enough for that.
INSTALL_TIMEOUT=900
# first-boot migrations). mediathekarr adds ~850 MB of image and waits for
# gluetun's health server BEFORE its process starts (its own start gate), so its
# 300s startup budget only begins once the tunnel is up. The default of 300s is
# not enough for any of that.
INSTALL_TIMEOUT=1200
# The gluetun credentials for ci/freesailarr/fixtures.env.yaml. Missing or empty
# values fail the install-test loudly instead of skipping it - see
+9 -1
View File
@@ -1,4 +1,4 @@
# freesailarr: the whole stack in one pod - gluetun plus eight app containers
# freesailarr: the whole stack in one pod - gluetun plus nine app containers
# that share its network namespace and sit behind its kill switch. Nothing here
# is a shortcut around that: the install-test brings up a real Wireguard tunnel
# with a CI-only Mullvad key (see ci/freesailarr/fixtures.env.yaml), because a
@@ -37,3 +37,11 @@ ingress:
# in the CI cluster, and the install-test checks readiness, not routing.
host: freesailarr.example.com
clusterIssuer: ci
mediathekarr:
# On in CI although it is off by default, so the install-test actually covers
# the tenth container: its digest-pinned image (single-arch amd64, ~850 MB),
# its start gate against gluetun's health server, and its probes on the two
# ports. Its ingress stays off - MediathekArr can only be served at the root
# of a host, and the root of the shared host above belongs to seerr.
enabled: true
+10 -7
View File
@@ -21,9 +21,11 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="$ROOT/ci"
# --- The exclusion list, parsed in exactly one place ------------------------
# Two consumers read ci/excluded-charts.txt: this script, and the install-test
# matrix in .github/workflows/validate-charts.yml, which drops the excluded
# charts before the matrix is built (issue #231). Two parsers would be two
# The consumers of ci/excluded-charts.txt are this script and the two jobs that
# build an install-test matrix - detect-changed-charts in
# .github/workflows/validate-charts.yml and select-charts in
# .github/workflows/install-test-all-charts.yml - both of which drop the
# excluded charts before the matrix exists (issue #231). Two parsers would be two
# chances to select different charts - and the failure would be silent in the
# worst direction, a chart the workflow believes excluded and the script
# believes testable, or the reverse. So there is one parser, here, and the
@@ -47,9 +49,10 @@ excluded_reason() {
list_excluded | awk -F'\t' -v chart="$1" '$1 == chart { print $2; exit }'
}
# Emits "<chart><TAB><reason>" for every excluded chart. Read by the detect job
# of validate-charts.yml before any chart is picked, which is why this runs
# before the argument is required.
# Emits "<chart><TAB><reason>" for every excluded chart. Read by both jobs that
# assemble an install-test matrix (detect-changed-charts in validate-charts.yml,
# select-charts in install-test-all-charts.yml) before any chart is picked,
# which is why this runs before the argument is required.
if [ "${1:-}" = "--list-excluded" ]; then
list_excluded
exit 0
@@ -100,7 +103,7 @@ fi
# REQUIRED_ENV="FREESAILARR_WG_KEY FREESAILARR_WG_ADDRESSES"
#
# The values come from the environment - in CI from 1Password Connect, see the
# install-test job in .github/workflows/validate-charts.yml.
# install-test job in .github/workflows/install-test.yml.
#
# A missing or empty value is a hard error on purpose. Skipping the chart
# instead would restore exactly the green-but-meaningless signal this mechanism
+129
View File
@@ -0,0 +1,129 @@
# Fixtures for the sure install-test: throwaway PostgreSQL 16 (the version
# upstream's compose file uses) and Redis, plus the secrets the 1Password
# operator would otherwise materialize (release name: ci).
#
# Both are mandatory for this chart, and for different reasons: the web
# container migrates the schema at start and its readiness probe runs a query,
# while all three probes of the worker container ask Redis whether the Sidekiq
# process still has a heartbeat. A missing Redis therefore does not show up as
# "a background job is late" but as a pod that never turns Ready.
apiVersion: v1
kind: Secret
metadata:
name: ci-sure-secret
stringData:
# 64 bytes hex, as upstream's "openssl rand -hex 64" produces. Any value
# works for a throwaway database - it is the key the app derives its Active
# Record encryption keys from, and nothing here survives the run.
secret-key-base: 3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed7e6dc881de23001b3f786850
---
apiVersion: v1
kind: Secret
metadata:
name: ci-sure-db-secret
stringData:
host: postgres
port: "5432"
database: sure
username: ci
password: ci
---
apiVersion: v1
kind: Secret
metadata:
name: ci-sure-redis-secret
stringData:
connection-url: redis://:cipass@redis:6379/0
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: public.ecr.aws/docker/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: ci
- name: POSTGRES_PASSWORD
value: ci
- name: POSTGRES_DB
value: sure
ports:
- containerPort: 5432
readinessProbe:
# -d names the database the chart actually uses. Without it
# pg_isready asks for a database named after the user and the
# server logs 'FATAL: database "ci" does not exist' every five
# seconds - noise that reads like a chart problem in the diagnostics
# dump when something else fails.
exec:
command: ["pg_isready", "-U", "ci", "-d", "sure"]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: public.ecr.aws/docker/library/redis:7-alpine
args: ["redis-server", "--requirepass", "cipass"]
ports:
- containerPort: 6379
readinessProbe:
tcpSocket:
port: 6379
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
+6
View File
@@ -0,0 +1,6 @@
# The first start of the web container creates and migrates the whole Rails
# schema ("./bin/rails db:prepare", run by the image entrypoint) before Puma
# binds its port, and the worker container only registers with Sidekiq once
# that schema exists. Both startup probes budget 600s for it, so the rollout
# wait has to be longer than a single startup budget, not shorter.
INSTALL_TIMEOUT=900
+10
View File
@@ -0,0 +1,10 @@
secrets:
appSecretRef: op://ci/sure-app
dbSecretRef: op://ci/sure-db
redisSecretRef: op://ci/sure-redis
ingress:
domain: sure.ci.local
# clusterIssuer is strictly required (no default); the Ingress just has to
# apply - there is no cert-manager in the CI cluster.
clusterIssuer: ci
+14 -1
View File
@@ -24,7 +24,7 @@ misconfigurations:
statement: "No CPU limits, on purpose: CPU is compressible, so a limit throttles where the missing limit merely shares - see README 'Resources (auto-limits)'. The chart helper renders a CPU limit only when one is set explicitly."
- id: AVD-KSV-0020
statement: "UIDs are pinned to the value the image expects, not raised above 10000: minecraft-server 1000 (gosu default), teamspeak-server 9987, voucher-vault 33, the matrix-synapse delegation nginx 101. calibre-web-automated shows the risk - a runtime uid moving from 1000 to 911 makes existing volume data inaccessible, and every chart with a PVC carries that same risk class."
statement: "UIDs are pinned to the value the image expects, not raised above 10000: minecraft-server 1000 (gosu default), teamspeak-server 9987, voucher-vault 33, the matrix-synapse delegation nginx 101, sure 1000 (its Dockerfile declares USER 1000:1000). calibre-web-automated shows the risk - a runtime uid moving from 1000 to 911 makes existing volume data inaccessible, and every chart with a PVC carries that same risk class."
- id: AVD-KSV-0021
statement: "GIDs follow the pinned UIDs above; the same image expectation and the same PVC ownership risk apply."
@@ -109,6 +109,19 @@ misconfigurations:
paths: ["**/homeassistant.yaml"]
statement: "NET_RAW is the capability in question and is added on purpose for device discovery; see AVD-KSV-0022."
# --- valkey: the two keyword findings on its ConfigMap ---------------------
# Both are keyword matches on the COMMENTS and the shell script in the
# ConfigMap, not on data. Worth suppressing with a reason rather than leaving
# standing, because "ConfigMap stores secrets" on this chart of all charts
# says the opposite of what it does: the whole design exists to keep the
# password out of everything Helm renders.
- id: AVD-KSV-0109
paths: ["**/valkey.yaml"]
statement: "False positive on the rendered ConfigMap. The matched strings are the comment '# Nothing here prints the password' and the line pass=\"$(sed -n 's/^requirepass...' \"$AUTH_FILE\")\" of the probe script - i.e. the code that READS the password out of the mounted Secret file at probe time. The ConfigMap contains no credential: the password lives only in the 1Password item, is materialized into a Secret by the operator, mounted as a file and 'include'd by the configuration, which is precisely why no requirepass line is rendered here. Verify by rendering the chart - no value from the vault appears in it."
- id: AVD-KSV-01010
paths: ["**/valkey.yaml"]
statement: "Same class as AVD-KSV-0109 above: the matched keys are 'valkey.conf' and an 'echo \"valkey...' line of the validation script. Both are chart-authored configuration and diagnostics text; nothing user- or credential-derived is stored in this ConfigMap."
# --- rss-bridge: port 80 is fixed by the image ----------------------------
- id: AVD-KSV-0117
paths: ["**/rss-bridge.yaml"]
+25
View File
@@ -0,0 +1,25 @@
# Fixtures for the valkey install-test: the one Secret the 1Password operator
# would otherwise materialize from the OnePasswordItem the chart renders
# (release name: ci, stub CRD in ci/common/).
#
# The field name matters as much as the value. The chart mounts exactly the key
# "auth-conf" and its content has to be a COMPLETE Valkey configuration line,
# because the configuration "include"s the file - so this fixture is also what
# keeps the init container's check honest: give it anything that is not a
# "requirepass <password>" line and the pod refuses to start, which is the
# failure this chart exists to make loud.
#
# No throwaway infrastructure here: Valkey depends on nothing. What this
# install-test does exercise, and why it is the persistent variant, is the risky
# path - a provisioned PVC, the pod's fsGroup against it, and an append-only
# file being created and replayed on a volume the container did not create.
apiVersion: v1
kind: Secret
metadata:
name: ci-valkey-secret
stringData:
# A throwaway password. Nothing here survives the run - but it is a real
# password rather than an empty string on purpose: an empty one would produce
# exactly the open server the chart refuses to start, and the run would fail
# for the right reason at the wrong place.
auth-conf: requirepass ci-valkey-throwaway-password
+37
View File
@@ -0,0 +1,37 @@
# CI values for the valkey install-test. The PERSISTENT variant on purpose: it is
# the chart's default and the only risky path there is (a provisioned PVC, the
# pod's fsGroup against it, an append-only file created on a volume the container
# did not create). The machinery can run one variant per chart, so the
# non-persistent path is covered by a local render only - named as a known gap in
# the PR that introduced this chart.
secrets:
authSecretRef: op://ci/valkey
persistence:
enabled: true
appendonly: true
# k3d's dynamic provisioner. The chart's default, longhorn-encrypted-ns, does
# not exist on the runner, and a pre-bound hostPath PV would be worse than this
# one: the kubelet does not apply fsGroup to a hostPath volume at all, so the
# test would silently stop exercising the ownership path it is here for.
storageClass: local-path
size: 128Mi
valkey:
# Small, so the whole thing stays inside the runner's budget. It still
# exercises the derivation: 64Mi x limitFactor 3 = 192Mi limit, maxmemory =
# 0.75 x that = 144Mi - and the render aborts if a configured maxmemory ever
# ends up above the limit.
resources:
requests:
cpu: 50m
memory: 64Mi
ephemeral-storage: 32Mi
# Left ON, although nothing in the test connects from another namespace: the
# point here is that the object is accepted and does not keep the pod's own
# traffic (the exec probes are local to the pod and unaffected) from working.
# Whether the policy actually FILTERS is a property of the cluster's CNI and is
# not claimed by this test.
networkPolicy:
enabled: true
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/gchq/CyberChef/master/src/web/static/ima
type: application
version: 9.0.1+up11.4.0
version: 9.0.2+up11.4.0
appVersion: "11.4.0"
maintainers:
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "cyberchef.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "cyberchef.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "cyberchef.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+12 -10
View File
@@ -1,23 +1,25 @@
apiVersion: v2
name: freesailarr
description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher and request front end in one pod, all reaching the internet only through the tunnel
description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher, request front end and an optional public-broadcaster indexer in one pod, all reaching the internet only through the tunnel
# No icon on purpose: the stack has no single upstream, and every other chart's
# icon is its one project's logo. Picking any of the nine here would suggest
# icon is its one project's logo. Picking any of the ten here would suggest
# the same false "main app" the versioning deliberately avoids. The field is
# optional, so it is omitted rather than filled with an arbitrary choice.
type: application
version: 3.0.5+up1.0.0
version: 3.1.1+up1.0.0
# The chart's own version marker, freely chosen and tied to no upstream. The
# stack bundles nine independently versioned projects, so there is no single
# "app version" to track here.
# stack bundles ten independently versioned projects (nine of them on by
# default), so there is no single "app version" to track here.
#
# It is NOT the source of any image tag: all nine images are pinned in
# values.yaml, where Renovate's helm-values manager proposes them. Nothing
# rewrites this field automatically - the chart has no app-version block in
# renovate.json5 and is not in the change-app-version.yml choice list. Raise it
# by hand when you want to mark a new edition of the chart as a whole.
# It is NOT the source of any image tag: all ten images are pinned in
# values.yaml, where Renovate's helm-values manager proposes them - except
# mediathekarr, which is pinned by digest and has its own customManagers block
# in renovate.json5, because its tags carry no version. Nothing rewrites this
# field automatically - the chart has no app-version block in renovate.json5
# and is not in the change-app-version.yml choice list. Raise it by hand when
# you want to mark a new edition of the chart as a whole.
appVersion: "1.0.0"
maintainers:
+96 -1
View File
@@ -11,7 +11,7 @@
{{- $sharedNames := list -}}
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
freesailarr — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
Nine containers in ONE pod, sharing gluetun's network namespace.
{{ if .Values.mediathekarr.enabled }}Ten{{ else }}Nine{{ end }} containers in ONE pod, sharing gluetun's network namespace.
NOTHING IS READY UNTIL THE TUNNEL IS UP
gluetun's readiness probe is its health server, which answers 200 only while
@@ -134,6 +134,101 @@ QBITTORRENT HAS NO SAVE PATH SET (qbittorrent.savePath is empty)
WebUI before starting any download.
{{- end }}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
{{- $dir := .Values.mediathekarr.downloads.directory }}
{{- $gate := .Values.mediathekarr.startGate }}
{{- $mediaStartup := fromYaml (include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "startupProbe" "given" .Values.mediathekarr.startupProbe)) }}
MEDIATHEKARR: TWO PORTS, AND THE SABNZBD API IS NOT AT /api
Every container shares one network namespace, so both consumers reach it over
127.0.0.1 — no service name, no DNS, no ingress:
Prowlarr -> Newznab indexer, http://127.0.0.1:5008
Sonarr/Radarr -> SABnzbd host 127.0.0.1, port 5007, API path /download/api
A request to /api on 5007 answers with a Newznab validation error and sends
you looking at the wrong half of the app — the upstream documentation only
mentions port 5007 at all.
Sonarr and Radarr must import from
/downloads/{{ $dir }}/complete (one sub-directory per category below it)
which is exactly the path the chart hands MediathekArr and which it reports
back as the SABnzbd complete_dir. Any deviation — a remote path mapping, a
different mount — imports NOTHING, without an error.
The UI (setup wizard and download queue) has no ingress{{ if and .Values.mediathekarr.ingress.enabled .Values.mediathekarr.ingress.host }}, other than the one
you gave it on {{ .Values.mediathekarr.ingress.host }}{{ end }}:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $name }}-mediathekarr-service 5007:5007
{{- if $gate.enabled }}
IT DELIBERATELY STARTS LATE, AND THAT IS THE POINT
The container waits (up to {{ $gate.timeoutSeconds }}s) for gluetun to report healthy before
it starts MediathekArr{{ if gt (int $gate.settleSeconds) 0 }}, plus {{ $gate.settleSeconds }}s of settling time{{ end }}. Started before the tunnel,
MediathekArr fails its one ruleset fetch, never retries it, and then answers
EVERY search with 0 results — container up, Ready, nothing in the log. That
is why a green pod proves nothing here and why the gate exits instead of
starting anyway on timeout: a crash-looping container is visible, a silently
empty indexer is not.
kubectl -n {{ .Release.Namespace }} logs {{ $name }}-sfs-0 -c mediathekarr | head -3
The startup probe has to outlast the gate ({{ mul (int $mediaStartup.periodSeconds) (int $mediaStartup.failureThreshold) }}s budget against a {{ $gate.timeoutSeconds }}s gate),
otherwise the kubelet kills the container while it is still waiting.
{{- else }}
THE START GATE IS OFF (mediathekarr.startGate.enabled: false)
MediathekArr may then start before the Wireguard tunnel is up. Its ruleset
fetch fails once, is never retried, and the indexer answers every search
with 0 results afterwards — measured over eleven minutes, with the container
up, Ready and no error anywhere. Nothing reports this state. Check a search
by hand after every restart, or switch the gate back on.
{{- end }}
A FAILED MEDIATHEKARR DOWNLOAD NEVER SAYS WHY
MediathekArr does not check the HTTP status code. On a geo-blocked fetch it
writes the ~476-byte HTML error page out as .mp4 and logs "Download
completed"; only mkvmerge then refuses the file and the status flips to
Failed — with fail_message: null. Measured A/B with one identical NZB:
direct 94,235,525 bytes and Completed, through a foreign exit 0 bytes and
Failed, fail_message null in BOTH cases.
So a geo block is indistinguishable from a broken server, an expired URL or a
full disk, in Sonarr and in the container log alike. And the protection is an
accident: it rests entirely on mkvmerge rejecting an HTML page. Anything a
geo endpoint returns that mkvmerge accepts is reported as Completed and walks
into your library.
WHETHER A SHOW WORKS THROUGH YOUR EXIT: READ THE MARKER, NOT A LIST
The tunnel currently exits {{ with .Values.gluetun.vpn.serverCities }}in {{ . }}{{ else }}{{ with .Values.gluetun.vpn.serverCountries }}in {{ . }}{{ else }}wherever gluetun picks{{ end }}{{ end }}. ARD and ZDF block parts of their
catalogue outside Germany, and every container hangs in gluetun's namespace —
there is no per-container exit.
A table of series goes stale; the marker in the media URL does not. It
predicted the outcome in 584 of 584 measured cases:
/de/, /geo/de/, /deutschlandweit/, geoavailability_D -> blocked outside DE
/weltweit/ -> open
/none/ (ZDF) -> open
/dach/, deChAt (WDR variant) -> open
The rule of thumb "own production = available worldwide" is MEASURABLY WRONG
(an ARD own production was blocked, a licensed series was open). Only the
marker counts.
The escape route is one value — gluetun.vpn.serverCountries/serverCities set
to a German exit — and its price is that it moves the WHOLE stack, all ten
containers, qBittorrent included, into that jurisdiction and onto those
servers. That is a deployment decision, not a chart setting to flip lightly.
OPEN, EXPLICITLY UNMEASURED: HLS ENTRIES
Part of the catalogue is served as an HLS playlist instead of a progressive
MP4. The image carries an ffmpeg-based M3U8 path, but whether such an entry
completes end to end has not been measured. If it does not, those entries fail
regardless of any geo block — and, per the paragraph above, without a reason.
{{- end }}
EVERY SERVICE POINTS AT THE SAME POD IP
One ClusterIP service per app{{ if .Values.recyclarr.enabled }} (recyclarr has none — no port in daemon mode){{ end }}, but all
+78 -17
View File
@@ -19,9 +19,10 @@ Usage:
resources:
{{- include "freesailarr.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. The reference implementation lives in template-chart.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. The reference implementation lives in template-chart.
*/}}
{{- define "freesailarr.resources" -}}
{{- $resources := . | default dict -}}
@@ -135,7 +136,7 @@ implementation lives in template-chart.
{{/*
Render additive env entries (the repo-wide <app>.env convention: tpl-rendered,
supporting the value, secretKeyRef and configMapKeyRef forms). Shared as a
helper here because this chart has nine containers, each with its own env block.
helper here because this chart has ten containers, each with its own env block.
Usage:
{{- include "freesailarr.env" (dict "root" $ "env" .Values.<app>.env) | nindent 12 }}
@@ -176,6 +177,14 @@ recyclarr is deliberately absent: in daemon mode it serves no port, so it gets
no container port, no service and no ingress. flaresolverr is present - it
listens on 8191 and therefore gets a service - but it has no ingress block in
values and is never exposed from outside the cluster.
"port" is the app's PRIMARY port: the one its service is named after and the one
an ingress points at. mediathekarr is the only app with a second one, because it
runs two processes in one container - the SABnzbd role on 5007 (the UI, hence
the primary) and the Newznab role Prowlarr consumes on 5008. Optional
"extraPorts" entries ({name, port}) are additional ports of the SAME service and
are declared to gluetun's firewall alongside the primary; they never take part
in the ingress, which addresses a service port by name.
*/}}
{{- define "freesailarr.portApps" -}}
{{- $apps := list -}}
@@ -186,7 +195,8 @@ values and is never exposed from outside the cluster.
(dict "name" "radarr" "port" 7878)
(dict "name" "sonarr" "port" 8989)
(dict "name" "seerr" "port" 5055)
(dict "name" "bazarr" "port" 6767) -}}
(dict "name" "bazarr" "port" 6767)
(dict "name" "mediathekarr" "port" 5007 "extraPorts" (list (dict "name" "ma-newznab" "port" 5008))) -}}
{{- if (index $.Values $app.name).enabled -}}
{{- $apps = append $apps $app -}}
{{- end -}}
@@ -214,8 +224,9 @@ ingress block with enabled: true. Used by the ingress template.
{{/*
The sub-path an app is served under in the shared-domain mode, e.g. "/sonarr".
Null-safe by construction: an absent or erased <app>.ingress.path falls back to
"/<app>", and to "/" for seerr, which has no base-path support at all and can
therefore only live at the root.
"/<app>", and to "/" for the two apps that can only live at the root - seerr,
which has no base-path support at all, and mediathekarr, whose UI links its
assets with root-absolute paths (see the guard in sharedIngressApps).
Input: dict "root" $ "app" <app name>.
*/}}
@@ -226,7 +237,7 @@ Input: dict "root" $ "app" <app name>.
{{- $path = $config.path -}}
{{- end -}}
{{- if or (kindIs "invalid" $path) (eq (toString $path) "") -}}
{{- if eq .app "seerr" -}}
{{- if has .app (list "seerr" "mediathekarr") -}}
{{- $path = "/" -}}
{{- else -}}
{{- $path = printf "/%s" .app -}}
@@ -250,9 +261,12 @@ Two guards live here rather than in the ingress template, so that every
consumer - the ingress objects, the UrlBase env vars and NOTES.txt - sees the
same validated list:
1. seerr under any path other than "/". seerr has no base-path support
(upstream request open since May 2022), so a chart that rendered it under
a sub-path would produce something that provably cannot work.
1. seerr or mediathekarr under any path other than "/". seerr has no base-path
support (upstream request open since May 2022) and MediathekArr's UI links
its assets with root-absolute paths, so a chart that rendered either under
a sub-path would produce something that provably cannot work. Both at "/"
on one host is caught by guard 2 below - one of them then needs a host of
its own.
2. Two apps claiming the same path on the shared host. Only paths on the
shared host can collide; apps with their own host are on a different host
and are therefore not part of this check.
@@ -269,11 +283,14 @@ same validated list:
{{- $config := (index $root.Values $app.name).ingress -}}
{{- if not ($config.host | default "") -}}
{{- $path := include "freesailarr.appPath" (dict "root" $root "app" $app.name) -}}
{{- if and (eq $app.name "seerr") (ne $path "/") -}}
{{- fail (printf "seerr.ingress.path is %q, but seerr has no base-path support and can only be served at \"/\" (https://github.com/Fallenbagel/jellyseerr/issues/97). Either leave seerr.ingress.path at \"/\" or give seerr its own seerr.ingress.host." $path) -}}
{{- $rootOnly := dict
"seerr" "seerr has no base-path support (https://github.com/Fallenbagel/jellyseerr/issues/97)"
"mediathekarr" "MediathekArr links its UI assets with root-absolute paths (/download/css/..., measured), so a sub-path stays broken even behind a prefix-stripping rewrite - the browser then asks for those assets at the host root" -}}
{{- if and (hasKey $rootOnly $app.name) (ne $path "/") -}}
{{- fail (printf "%s.ingress.path is %q, but %s and it can therefore only be served at \"/\". Either leave %s.ingress.path at \"/\" or give it its own %s.ingress.host." $app.name $path (index $rootOnly $app.name) $app.name $app.name) -}}
{{- end -}}
{{- if hasKey $seen $path -}}
{{- fail (printf "%s.ingress.path and %s.ingress.path are both %q on the shared host %q - every app under ingress.host needs its own path" (index $seen $path) $app.name $path $host) -}}
{{- fail (printf "%s.ingress.path and %s.ingress.path are both %q on the shared host %q - every app under ingress.host needs its own path. Where neither can move (seerr and mediathekarr can only be served at \"/\"), give one of them its own <app>.ingress.host." (index $seen $path) $app.name $path $host) -}}
{{- end -}}
{{- $_ := set $seen $path $app.name -}}
{{- $apps = append $apps (dict "name" $app.name "port" $app.port "path" $path) -}}
@@ -356,6 +373,9 @@ alive. It is not what opens the ports today.
{{- $ports := list -}}
{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
{{- $ports = append $ports (int $app.port) -}}
{{- range $extra := $app.extraPorts | default list -}}
{{- $ports = append $ports (int $extra.port) -}}
{{- end -}}
{{- end -}}
{{- $ports = append $ports 9999 -}}
{{- join "," $ports -}}
@@ -366,7 +386,7 @@ Whether a media category is mounted at all, i.e. whether at least one app that
mounts it is enabled. "true" when it is, the empty string when it is not (an
empty string is falsy in a template "if", so the result can be used directly).
downloads: qbittorrent, radarr, sonarr
downloads: qbittorrent, radarr, sonarr, mediathekarr
movies: radarr, bazarr
tv: sonarr, bazarr
@@ -375,7 +395,7 @@ Input: dict "root" $ "category" <"downloads"|"movies"|"tv">.
{{- define "freesailarr.mediaCategoryEnabled" -}}
{{- $v := .root.Values -}}
{{- if eq .category "downloads" -}}
{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled -}}true{{- end -}}
{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled $v.mediathekarr.enabled -}}true{{- end -}}
{{- else if eq .category "movies" -}}
{{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}}
{{- else if eq .category "tv" -}}
@@ -885,6 +905,47 @@ bazarr:
- FOWNER
- SETGID
- SETUID
mediathekarr:
livenessProbe:
exec:
command:
- /bin/bash
- -c
- >-
wget -q -T 5 -O /dev/null "http://127.0.0.1:5008/api?t=caps" &&
wget -q -T 5 -O /dev/null "http://127.0.0.1:5007/download/api?mode=version&output=json&apikey=x"
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /download/
port: 5007
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
startupProbe:
httpGet:
path: /download/
port: 5007
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 60
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
securityContext:
runAsUser: {{ .Values.puid }}
runAsGroup: {{ .Values.pgid }}
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
configInit:
resources:
limitFactor: 3
@@ -905,7 +966,7 @@ configInit:
{{/*
The effective pod security context. Separate from the per-container table
because it is the one block shared by all nine containers.
because it is the one block shared by all containers of the pod.
*/}}
{{- define "freesailarr.podSecurityContext" -}}
{{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}}
@@ -21,5 +21,16 @@ spec:
port: {{ int $app.port }}
targetPort: {{ $app.name }}
name: {{ $app.name }}
{{- /* An app with more than one port keeps them in ONE service rather than
getting a second object: they belong to the same container and the
same pod IP, and only the primary port is ever addressed by an
ingress. mediathekarr is the only such app today - 5007 (SABnzbd
role, primary) and 5008 (Newznab role). */}}
{{- range $extra := $app.extraPorts | default list }}
- protocol: TCP
port: {{ int $extra.port }}
targetPort: {{ $extra.name }}
name: {{ $extra.name }}
{{- end }}
type: ClusterIP
{{- end }}
+110 -3
View File
@@ -521,6 +521,109 @@ spec:
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
# MediathekArr, the tenth container and the only one off by default: a fake
# Usenet indexer for Prowlarr (Newznab, port 5008) and a fake SABnzbd
# for Sonarr/Radarr (port 5007), fetching from the ARD and ZDF media
# libraries. Both consumers sit in this pod and reach it over
# 127.0.0.1, so it needs neither a service nor DNS to do its job.
- name: mediathekarr
# Tag AND digest: the tag stays readable, the digest is what is
# actually pulled. "beta" moves, and "latest" is a 2024 build - see
# the values comment.
image: "{{ .Values.mediathekarr.image.repository }}:{{ .Values.mediathekarr.image.tag }}@{{ required "mediathekarr.image.digest is required - the tag alone is a moving reference" .Values.mediathekarr.image.digest }}"
imagePullPolicy: IfNotPresent
{{- if .Values.mediathekarr.startGate.enabled }}
# THE START GATE. Kubernetes cannot order two regular containers, and
# an initContainer waiting for gluetun would deadlock (gluetun only
# starts once the init containers are done). So the wait happens here,
# in front of the image entrypoint: poll gluetun's health server -
# 200 only while the tunnel actually works - and exec the entrypoint
# afterwards.
#
# Why this is not optional decoration: MediathekArr fetches its
# ruleset once at start and never retries. Started before the tunnel,
# it answered every Newznab search with 0 results for over eleven
# minutes - container up, Ready, nothing in the log. The failure case
# looks exactly like the healthy one, which is why the gate exits
# instead of starting anyway when the timeout passes: a crash-looping
# container is visible, a silently empty indexer is not.
#
# wget comes with the (Debian) image, bash is its entrypoint's own
# interpreter - no extra tooling and no shell built into the chart.
command:
- /bin/bash
- -c
- |
set -eu
deadline=$(( SECONDS + {{ int .Values.mediathekarr.startGate.timeoutSeconds }} ))
echo "mediathekarr: waiting for gluetun to report healthy (127.0.0.1:9999)"
until wget -q -T 5 -O /dev/null http://127.0.0.1:9999/; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "mediathekarr: gluetun did not report healthy within {{ int .Values.mediathekarr.startGate.timeoutSeconds }}s - exiting instead of starting without a tunnel, because a MediathekArr started too early answers every search with 0 results and reports nothing" >&2
exit 1
fi
sleep {{ int .Values.mediathekarr.startGate.intervalSeconds }}
done
echo "mediathekarr: gluetun healthy after ${SECONDS}s"
{{- if gt (int .Values.mediathekarr.startGate.settleSeconds) 0 }}
sleep {{ int .Values.mediathekarr.startGate.settleSeconds }}
{{- end }}
exec /app/docker_start.sh
{{- end }}
env:
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Both download paths are set by the chart, not left to the
image: complete/ has to be visible to Sonarr/Radarr under
the SAME absolute path (they are handed this value as the
SABnzbd complete_dir, and a deviation imports nothing
without an error), and incomplete/ has to live on the media
claim rather than in an emptyDir, which would count against
the container's ephemeral-storage limit and let one large
download evict the whole pod. Setting them also keeps the
image's built-in /data/mediathek default out of play, which
the read-only root filesystem does not provide. */}}
- name: DOWNLOAD_INCOMPLETE_PATH
value: {{ printf "/downloads/%s/incomplete" .Values.mediathekarr.downloads.directory | quote }}
- name: DOWNLOAD_COMPLETE_PATH
value: {{ printf "/downloads/%s/complete" .Values.mediathekarr.downloads.directory | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.mediathekarr.env) | nindent 12 }}
ports:
- containerPort: 5007
protocol: TCP
name: mediathekarr
- containerPort: 5008
protocol: TCP
name: ma-newznab
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "livenessProbe" "given" .Values.mediathekarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "readinessProbe" "given" .Values.mediathekarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "startupProbe" "given" .Values.mediathekarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /app/config
name: mediathekarr-config
{{- /* The same claim, the same mount path as sonarr/radarr - that
is what makes the complete_dir path identical on both
sides. */}}
- mountPath: /downloads
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
# Not a measured requirement: the container ran through with a
# read-only root and without a writable /tmp. Kept as cheap
# insurance for the .NET runtime, which is the kind of consumer that
# reaches for /tmp on a code path nobody exercised yet.
- mountPath: /tmp
name: mediathekarr-tmp
{{- end }}
restartPolicy: Always
volumes:
# The tun device gluetun needs to create the Wireguard tunnel.
@@ -542,11 +645,11 @@ spec:
setup with three separate claims still renders byte-identically to
2.0.1 and the diff itself proves the change is inert there.
*/}}
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }}
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled .Values.mediathekarr.enabled }}
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }}
- name: media-downloads
persistentVolumeClaim:
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr, sonarr or mediathekarr is enabled" .Values.media.downloads.existingClaim }}
{{- end }}
{{- end }}
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
@@ -579,12 +682,16 @@ spec:
- name: seerr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
- name: mediathekarr-tmp
emptyDir: {}
{{- end }}
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
# are immutable - toggling an app's enabled flag later requires recreating
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
# before upgrading; existing PVCs and their data survive).
volumeClaimTemplates:
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" }}
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" "mediathekarr" }}
{{- $config := index $.Values $app }}
{{- if or (eq $app "gluetun") $config.enabled }}
- metadata:
+91
View File
@@ -535,6 +535,76 @@
"$ref": "#/definitions/containerSecurityContext"
}
}
},
"mediathekarr": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"image": {
"$ref": "#/definitions/pinnedImage"
},
"ingress": {
"$ref": "#/definitions/appIngress"
},
"storage": {
"$ref": "#/definitions/appStorage"
},
"startGate": {
"description": "Delays the container until gluetun reports healthy. NOT null-safe on purpose and not merged through the fallback helper: this block decides whether the gate is rendered at all, and an erased block would silently produce a container that starts before the tunnel - the failure this chart writes three paragraphs about, and one that looks green.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"intervalSeconds": {
"type": "integer",
"minimum": 1
},
"timeoutSeconds": {
"description": "Total wait before the container exits instead of starting without a tunnel. Keep the startup probe's budget (periodSeconds x failureThreshold) above it, or the kubelet kills the container while the gate is still waiting.",
"type": "integer",
"minimum": 1
},
"settleSeconds": {
"type": "integer",
"minimum": 0
}
}
},
"downloads": {
"type": "object",
"additionalProperties": false,
"properties": {
"directory": {
"description": "Sub-directory on the shared downloads claim holding incomplete/ and complete/. The chart derives DOWNLOAD_INCOMPLETE_PATH and DOWNLOAD_COMPLETE_PATH from it; complete/ is what Sonarr/Radarr are told to import from, under the identical absolute path.",
"type": "string",
"minLength": 1
}
}
},
"env": {
"$ref": "#/definitions/env"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"resources": {
"$ref": "#/definitions/resources"
},
"securityContext": {
"$ref": "#/definitions/containerSecurityContext"
}
}
}
},
"definitions": {
@@ -746,6 +816,27 @@
"object",
"null"
]
},
"pinnedImage": {
"description": "Like image, plus the digest mediathekarr is pinned by: its tags are unusable (\"latest\" is a 2024 build, \"beta\" moves), so the tag stays as the readable label and the digest is what is actually pulled. Deliberately a separate definition instead of adding an optional digest to image: on every other image in this chart a digest key would be a value the templates never read - exactly the silent no-op the schema exists to reject (#68).",
"type": "object",
"additionalProperties": false,
"properties": {
"repository": {
"type": "string"
},
"tag": {
"type": [
"string",
"number"
]
},
"digest": {
"description": "Image digest, sha256:<64 hex>. Required - the template refuses to render without it.",
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
}
}
}
}
}
+261 -4
View File
@@ -4,10 +4,11 @@
# the Wireguard tunnel.
#
# Image pinning (documented deviation from the Chart.AppVersion standard):
# NO image tag comes from appVersion. All nine images, gluetun included, are
# NO image tag comes from appVersion. All ten images, gluetun included, are
# pinned here via <app>.image.repository / <app>.image.tag, because the stack
# combines nine independently versioned upstream projects and none of them is
# "the" one app version.
# combines ten independently versioned upstream projects and none of them is
# "the" one app version. mediathekarr additionally carries an image.digest -
# see the reasoning in its own block at the end of this file.
#
# That is also what puts every image under Renovate: the built-in helm-values
# manager reads image.repository/image.tag pairs out of this file and proposes
@@ -84,7 +85,7 @@ storage:
# Media libraries as pre-existing PVCs (shared with the separately deployed
# Jellyfin) - the chart mounts but never creates them. Each claim is required
# as soon as an app that mounts it is enabled:
# downloads: qbittorrent, radarr, sonarr
# downloads: qbittorrent, radarr, sonarr, mediathekarr
# movies: radarr, bazarr
# tv: sonarr, bazarr
#
@@ -874,3 +875,259 @@ bazarr:
- FOWNER
- SETGID
- SETUID
# MediathekArr: the tenth container, and the only one that is OFF by default. It fakes a Usenet indexer towards Prowlarr and a SABnzbd downloader
# towards Sonarr/Radarr, and fetches the content over plain HTTP from the ARD
# and ZDF media libraries. Both consumers already sit in this pod and share
# gluetun's network namespace, so they reach it over 127.0.0.1 - no service, no
# DNS, no ingress detour.
#
# Off by default because it is additive and nobody's existing release should
# grow a container on an upgrade: enabling it creates a new
# volumeClaimTemplate, which a StatefulSet cannot take without being recreated
# (see the NOTES and the volumeClaimTemplates comment in the StatefulSet).
#
# ----------------------------------------------------------------------------
# THE GEO CAVEAT, AND HOW TO CHECK IT YOURSELF
# ----------------------------------------------------------------------------
# The tunnel leaves through Zurich by this chart's default, and ARD and ZDF
# block parts of their catalogue outside Germany. Every container hangs in
# gluetun's namespace; there is no bypass for a single container.
#
# Measured, 480 URLs across 12 channels: 104 of 473 loadable URLs were blocked
# through Zurich (22.0%), none of them on the German side. The spread is very
# uneven - ONE 97.5%, ZDFneo 47.5%, ZDFinfo 37.5%, Das Erste 20.5%, ZDF 10.3%,
# BR 0% - so a single number tells you little about the show you actually want.
#
# THE PROCEDURE, NOT A TABLE OF SERIES. A list of titles is stale in half a
# year; the marker in the CDN path is not. Over two measurement rounds it
# predicted the outcome perfectly - 584 of 584 cases in the full survey, not a
# single misclassification. Look at the media URL of an entry and read the
# path:
#
# /de/, /geo/de/, /deutschlandweit/, geoavailability_D -> blocked outside DE
# /weltweit/ -> open
# /none/ (ZDF) -> open
# /dach/, deChAt (WDR variant) -> open, Switzerland
# is part of DACH
#
# The obvious rule of thumb "own production = available worldwide" is MEASURABLY
# WRONG and is named here so nobody re-invents it: an ARD own production was
# blocked, a licensed series was open. Only the marker counts.
#
# THE ESCAPE ROUTE AND ITS PRICE. The exit is a deployment decision, not a chart
# property: set gluetun.vpn.serverCountries/serverCities to a German exit and
# the blocks disappear. The price is that this moves the WHOLE stack, all ten
# containers - qBittorrent included - into that jurisdiction and onto those
# servers. There is no per-container exit, and this chart deliberately does not
# pretend otherwise.
#
# ----------------------------------------------------------------------------
# THE fail_message: null TRAP - READ THIS BEFORE DEBUGGING A FAILED DOWNLOAD
# ----------------------------------------------------------------------------
# MediathekArr never checks the HTTP status code. Measured A/B with an identical
# NZB: direct (DE) -> status Completed, 94,235,525 bytes, fail_message null;
# through Zurich -> status Failed, 0 bytes, fail_message ALSO null.
#
# On a geo-blocked fetch it writes the ~476-byte HTML error page out as .mp4 and
# logs "Download completed". Only mkvmerge then refuses the file (exit code 2)
# and flips the status to Failed. Two consequences:
#
# 1. Sonarr is told "failed" without a reason. A geo block looks exactly like
# a broken server, an expired URL or a full disk - and the container log
# does not mention the 403 either.
# 2. That protection is an accident, not error handling. It rests entirely on
# mkvmerge rejecting an HTML page. Any geo response mkvmerge would accept
# is reported as Completed, and then a broken file walks into the library.
# It did not happen in either measurement round (Akamai consistently
# returns HTML), but that is not something to rely on.
#
# ----------------------------------------------------------------------------
# OPEN QUESTION, EXPLICITLY UNMEASURED: HLS
# ----------------------------------------------------------------------------
# Part of the catalogue is served as an HLS playlist (application/x-mpegURL)
# instead of a progressive MP4. The image carries an ffmpeg-based M3U8 path
# (log lines "Starting M3U8 download" / "M3U8 download completed", and ffmpeg is
# present in the image), so the code is there - but whether such an entry
# actually completes end to end has NOT been measured. If it does not, those
# entries fail regardless of any geo block. Stated as an open question on
# purpose: no assumption in either direction.
mediathekarr:
enabled: false
# Pinned BY DIGEST, unlike every other image in this chart, because the tags
# are unusable: "latest" is from 2024-11-12 (= v0.2.5) and the upstream README
# points at "beta", which is a moving tag. tag + digest together keep the
# human-readable line and the immutable reference.
#
# The image is SINGLE-ARCH, linux/amd64 only - there is no arm64 variant. The
# target cluster is amd64 throughout, so this is a note, not a problem; on a
# mixed cluster the pod would need a nodeSelector this chart does not offer.
#
# Renovate: the built-in helm-values manager cannot follow this pair (the tag
# carries no version), so renovate.json5 has a customManagers block that
# matches exactly these three lines and proposes digest updates for the beta
# tag. Renaming or reordering the keys silently switches that off - the rule
# would still be valid, it just would not match any more (#158).
image:
repository: docker.io/pcjones/mediathekarr
tag: beta
digest: sha256:3ed7e1487810e1519301bd56a0c420e260e428295b029a47806c0397e4a52a33
# TWO ports, not one, and the upstream documentation only mentions the first:
# 5007 MediathekArrDownloader - the SABnzbd role and the web UI
# 5008 MediathekArrServer - the Newznab role Prowlarr talks to
# The SABnzbd API lives under /download/api, NOT under /api: a request to
# /api on 5007 answers with a Newznab validation error and sends you looking
# in the wrong place. Both ports are declared to gluetun's firewall
# automatically (see the firewallInputPorts helper).
#
# No ingress by default: MediathekArr's UI links its assets with root-absolute
# paths (/download/css/..., measured), so it can only be served at the ROOT of
# a host - a sub-path is broken even behind a prefix-stripping rewrite,
# because the browser then requests the assets at the host root. On the shared
# domain the root belongs to seerr, so enabling this ingress there needs
# either seerr switched off or a host of its own here. Both consumers reach
# MediathekArr over localhost and need no ingress at all; the UI is only
# needed to run the setup wizard, for which a port-forward is enough.
ingress:
enabled: false
host: null
# Read only in the shared-domain mode. "/" is the only value that works;
# anything else fails the render rather than producing a broken UI.
path: /
# clusterIssuer: null
# className: null
# annotations: {}
storage:
# Size of the MediathekArr config volume (/app/config, a small JSON).
size: 1Gi
# THE START GATE - the sharpest point of this container, and the one whose
# failure mode looks green.
#
# If MediathekArr starts before the tunnel is up, its ruleset fetch fails once
# and is never retried: the instance then answered every Newznab search with 0
# results for over eleven minutes, while the container was up, Ready and
# logged nothing wrong. A green pod proves nothing here. With the start
# delayed until gluetun reports healthy, the ruleset loads and the search
# matches an instance running with direct egress (the search itself is not
# geo-blocked - 56 hits through either exit).
#
# Kubernetes cannot express "start after that container": gluetun is a regular
# container, and an initContainer waiting for it would deadlock. So the gate
# lives in this container's command - it polls gluetun's health server on
# 127.0.0.1:9999 (the same endpoint gluetun's own probes use, 200 only while
# the tunnel works) and only then execs the image entrypoint.
#
# On timeout the container EXITS instead of starting anyway. That is
# deliberate: a crash-looping container is visible, a silently empty indexer
# is not.
#
# Keep startupProbe budget > timeoutSeconds + app start, or the kubelet kills
# the container while the gate is still waiting. Defaults: 180s gate,
# 5s * 60 = 300s startup budget.
startGate:
enabled: true
# Poll interval and total budget for gluetun becoming healthy. gluetun's own
# startup budget is 150s, so 180s covers a normal handshake with room.
intervalSeconds: 3
timeoutSeconds: 180
# Extra pause after gluetun reports healthy, before the app starts. NOT a
# measured requirement - gluetun's 200 is the actual condition - but a cheap
# buffer for DNS and routing to settle. 0 disables it.
settleSeconds: 10
# WHERE THE DOWNLOADS GO. Both directories live on the SHARED downloads claim
# (media.downloads), mounted at /downloads exactly like sonarr, radarr and
# qbittorrent mount it, under this sub-directory:
#
# /downloads/<directory>/incomplete work in progress
# /downloads/<directory>/complete finished, plus one sub-dir per category
#
# Two reasons, both of them the kind that fails silently:
#
# 1. complete/ MUST be visible to Sonarr/Radarr under the SAME absolute
# path. The chart hands that path to MediathekArr
# (DOWNLOAD_COMPLETE_PATH), which reports it to Sonarr as the SABnzbd
# complete_dir. Every container here mounts the claim at /downloads, so
# the paths are identical by construction - a deviation imports nothing,
# without an error.
# 2. incomplete/ must NOT be an emptyDir. An emptyDir counts against the
# container's ephemeral-storage limit, and one large download makes the
# kubelet evict the WHOLE pod - all ten containers. Same trap as the one
# documented in ytdlp-web-player.
#
# Do not set DOWNLOAD_COMPLETE_PATH or DOWNLOAD_INCOMPLETE_PATH in the env
# block below: the chart always sets both, and a second entry of the same name
# is not a defined state (see README, "Environment variables").
downloads:
directory: mediathekarr
# Extra environment variables, appended to the ones the chart always sets.
# Useful ones the image reads: CATEGORIES, MAX_PARALLEL_DOWNLOADS,
# MEDIATHEKARR_API_BASE_URL.
env: []
# Probes on measured 200 endpoints - MediathekArr has NO health endpoint at
# all (/health, /healthz, /api/health, /download/health, /status and /ping all
# answer 404).
#
# Liveness is an exec on purpose: one container runs TWO processes, and the
# kubelet allows one probe of each kind. The Newznab half can wedge while the
# downloader half still answers, and that is exactly the failure that makes
# searches come back empty. The command checks both ports and restarts the
# container when either stops answering. (The entrypoint already exits when
# one of the two processes CRASHES - the exec probe covers the case where it
# is alive but not serving.)
#
# Readiness and startup use the downloader port: that is the one the Service
# and any ingress point at, and /download/ answers 200 without a query string.
livenessProbe:
exec:
command:
- /bin/bash
- -c
- >-
wget -q -T 5 -O /dev/null "http://127.0.0.1:5008/api?t=caps" &&
wget -q -T 5 -O /dev/null "http://127.0.0.1:5007/download/api?mode=version&output=json&apikey=x"
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /download/
port: 5007
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness AND has to outlast the start gate
# (see startGate above): 5s * 60 = 300s against a 180s gate.
startupProbe:
httpGet:
path: /download/
port: 5007
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 60
resources:
limitFactor: 3
# ephemeral-storage stays small on purpose: downloads land on the media
# claim, not on the container filesystem (see downloads above).
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# Measured, including a real download and the mkvmerge remux: the container
# runs fully hardened - non-root, uid/gid from puid/pgid,
# readOnlyRootFilesystem: true - as long as its writable paths are volumes.
# Those are /app/config (the config claim) and the two download directories on
# the media claim; the image's built-in default /data/mediathek is never
# touched, because the chart overrides both paths by environment (measured:
# read-only root, no /data/mediathek mount, no error).
# PUID/PGID are NOT needed (the image's gosu path is only taken when they are
# set), and the
# commented-out non-root block in its Dockerfile is outdated for the beta.
# runAsUser/runAsGroup come from the global puid/pgid values in the template -
# do not set them here.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
+2 -2
View File
@@ -5,8 +5,8 @@ icon: https://gotenberg.dev/img/logo.png
type: application
version: 8.0.1+up8.36.0
appVersion: "8.36.0"
version: 8.0.3+up8.37.0
appVersion: "8.37.0"
maintainers:
- name: jklein
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "gotenberg.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "gotenberg.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "gotenberg.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+1 -1
View File
@@ -4,7 +4,7 @@ description: A Helm chart to deploy Home Assistant on Kubernetes
icon: https://www.home-assistant.io/images/home-assistant-logo.svg
type: application
version: 6.1.0+up2026.8.3
version: 6.1.1+up2026.8.3
appVersion: "2026.8.3"
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "homeassistant.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "homeassistant.resources" -}}
{{- $resources := . | default dict -}}
@@ -167,8 +169,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "homeassistant.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+1 -1
View File
@@ -3,7 +3,7 @@ name: matrix-synapse
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
type: application
version: 7.1.0+upv1.159.0
version: 7.1.1+upv1.159.0
appVersion: "v1.159.0"
maintainers:
- name: jklein
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "matrix-synapse.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "matrix-synapse.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "matrix-synapse.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+2 -2
View File
@@ -5,8 +5,8 @@ icon: https://avatars.githubusercontent.com/u/988985
type: application
version: 4.1.1+up1.46.5
appVersion: "1.46.5"
version: 4.1.3+up1.47.1
appVersion: "1.47.1"
maintainers:
- name: jklein
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "minecraft-router.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "minecraft-router.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "minecraft-router.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+1 -1
View File
@@ -23,7 +23,7 @@ type: application
# continuously, so a pod restart could pull a different image than the last one
# without anything being recorded anywhere. The dated tags are immutable, so the
# image is now pinned by the chart version like every other chart in this repo.
version: 5.0.0+up2026.8.2.java25
version: 6.0.1+up2026.8.2.java25
appVersion: "2026.8.2-java25"
maintainers:
+4 -2
View File
@@ -204,9 +204,11 @@ OPERATORS ARE SYNCHRONIZED FROM VALUES, NOT FROM THE GAME
SECRETS — WHICH FIELDS THE 1PASSWORD ITEM MUST CARRY
The OnePasswordItem CR {{ $secret }} is rendered unconditionally, so
secrets.minecraftServerRef has to be set even when nothing consumes a key from
it; it has no default and would otherwise render an itemPath of null.
it; it has no default and would otherwise render an itemPath of null. The
template guards it with required(), so a release without it does not render at
all — this note tells you why the guard is there, not that you got past it.
secrets.minecraftServerRef → {{ .Values.secrets.minecraftServerRef | default "NOT SET" }}
secrets.minecraftServerRef → {{ .Values.secrets.minecraftServerRef }}
The operator copies the item's FIELDS into the Kubernetes Secret, one key per
field name. This release consumes:
+7 -6
View File
@@ -19,9 +19,10 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "minecraft-server.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. The reference implementation lives in template-chart.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. The reference implementation lives in template-chart.
*/}}
{{- define "minecraft-server.resources" -}}
{{- $resources := . | default dict -}}
@@ -346,9 +347,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies. The reference implementation
lives in template-chart.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14). The reference implementation lives in template-chart.
*/}}
{{- define "minecraft-server.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
@@ -3,4 +3,4 @@ kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
spec:
itemPath: {{ .Values.secrets.minecraftServerRef }}
itemPath: {{ required "A 1Password item reference must be provided (secrets.minecraftServerRef) - the OnePasswordItem is rendered unconditionally, so it is needed even with RCON and CurseForge both off" .Values.secrets.minecraftServerRef }}
+1
View File
@@ -28,6 +28,7 @@
"additionalProperties": false,
"properties": {
"minecraftServerRef": {
"description": "op:// item path for the 1Password operator. Required even when RCON and the CurseForge downloader are both off: the OnePasswordItem is rendered unconditionally. The item's CONTENT may be empty in that case, the reference may not. Enforced by required() in the template, not here: the null default keeps this type widened, and a schema \"required\" entry could never fire against a key that is present with value null.",
"type": ["string", "null"]
}
}
+9 -3
View File
@@ -37,9 +37,15 @@ image:
tagOverride: ""
secrets:
# op:// item path for the 1Password operator. Only needs to carry a
# "curseforge-api-key" key when curseforge.enabled is true and an
# "rcon-password" key when server.rcon.enabled is true.
# REQUIRED - op:// item path for the 1Password operator. The OnePasswordItem
# CR is rendered unconditionally, so this reference has to be set even when
# nothing consumes a key from it; it has no default and would otherwise render
# an itemPath of null. The item's CONTENT may be empty in that case: it only
# needs to carry a "curseforge-api-key" key when curseforge.enabled is true
# and an "rcon-password" key when server.rcon.enabled is true. Without the
# value the render aborts - required() in minecraft-server.secret.yaml is the
# whole enforcement, and values.schema.json cannot help here (README,
# guideline 12).
#
# Documented limit (chart 3.0.0): changing this path rolls the pod (the
# StatefulSet hashes the rendered OnePasswordItem into checksum/secret-ref),
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon-
type: application
version: 7.1.2+up1.10.1
version: 7.1.3+up1.10.1
appVersion: 1.10.1
maintainers:
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "outline.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "outline.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "outline.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+2 -2
View File
@@ -5,9 +5,9 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s
type: application
version: 12.1.1+up3.1.0
version: 12.1.3+up3.2.1
appVersion: 3.1.0
appVersion: 3.2.1
maintainers:
- name: jklein
+5 -3
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "paperless-ngx.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "paperless-ngx.resources" -}}
{{- $resources := . | default dict -}}
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/PatchMon/PatchMon/main/frontend/public/a
type: application
version: 7.0.1+up2.1.3
version: 7.0.2+up2.1.3
appVersion: "2.1.3"
maintainers:
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "patchmon.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "patchmon.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "patchmon.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+2 -2
View File
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi
type: application
version: 8.1.3+up4.15.0
appVersion: "4.15.0"
version: 8.1.6+up4.15.2
appVersion: "4.15.2"
maintainers:
- name: jklein
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "rallly.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "rallly.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "rallly.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+176 -33
View File
@@ -16,15 +16,45 @@
// Commit subjects in this repo are plain sentences, not conventional
// commits. Renovate's own merged PRs already look like that.
":semanticCommitsDisabled",
// Same preset as cluster-config, so both repos behave identically on the
// shared jlab-renovate-bot instance. Two of the things it pulls in are
// load-bearing here: `helpers:pinGitHubActionDigests` (wanted — the
// workflow files move from `actions/checkout@v7` to SHA pins) and
// `docker:pinDigests`, which is turned off again for the two managers that
// cannot carry a digest — see the pinDigests rule below. The
// npm-flavoured members (`:pinDevDependencies`,
// `security:minimumReleaseAgeNpm`, `:maintainLockFilesWeekly`) are inert
// in a repo without a package manager.
"config:best-practices",
],
timezone: "Europe/Berlin",
labels: ["dependencies"],
labels: ["renovate"],
dependencyDashboard: true,
// 21 charts plus the workflows: without a cap the first run after a quiet
// week opens a wall of PRs, and every chart PR spins up a k3d install-test.
prConcurrentLimit: 10,
prHourlyLimit: 4,
// No caps, as in cluster-config. The first run after a quiet week can open
// many PRs at once and every chart PR starts a k3d install-test; on Gitea a
// whole matrix runs inside one job on one runner, so those queue instead of
// fanning out. That queue is the accepted price for not having updates
// trickle in four per hour — nothing merges without a review anyway.
prConcurrentLimit: 100,
prHourlyLimit: 0,
// Minor and patch arrive as separate PRs rather than one combined branch.
// With 24 charts a shared branch means one red install-test blocks unrelated
// updates, and the separation is what lets a patch be reviewed in seconds.
separateMinorPatch: true,
// Every update automerges once the PR is mergeable, majors included, and
// `platformAutomerge` hands that decision to the forge instead of letting
// Renovate poll: Gitea arms the PR and merges it when the required check
// (`validate-charts`) passes AND the required review exists. Both are active
// on `main`, which is why this is not unattended merging — the review is the
// gate, and without it the armed PR simply sits there. Same reasoning as
// cluster-config, where the dashboard approval was retired for it.
automerge: true,
automergeType: "pr",
platformAutomerge: true,
// template-chart is the scaffold. Its image is the literal placeholder
// ghcr.io/ORG/IMAGE and its appVersion is "xxx" — nothing to update, and the
@@ -80,26 +110,43 @@
},
{
description: [
"Patch, minor and digest updates merge themselves once the PR pipeline is green.",
"Minor is included because the sidecar images barely move in patch steps —",
"alpine 3.22 -> 3.24 and nginx 1.30.4 -> 1.31.4 are both minor, so a patch-only",
"rule would practically never fire. Majors stay manual.",
"Routine updates wait three days before Renovate even opens the PR. Automerge is",
"global now (top of this file), so this rule no longer decides WHAT merges by itself —",
"it decides how fresh a release may be when it does. A merge here is never just a",
"merge: every chart change publishes a chart straight away, and a release that is",
"yanked hours after it appeared should not reach the registry at all.",
"cluster-config spends the same three days on the dependencies where a merge is",
"expensive (authentik, rancher); here that is every one of them.",
"",
"Majors deliberately carry no age gate: they are not the updates that slip through",
"unnoticed, they are the ones held at the review, and separateMultipleMajor makes",
"them arrive one line at a time anyway.",
],
matchUpdateTypes: ["patch", "minor", "digest"],
automerge: true,
automergeType: "pr",
// Renovate waits for the checks itself instead of handing the PR to the
// forge's own auto-merge. Both of them — GitHub's, and the auto-merge
// that takes the merge queue's place on Gitea, which has none — merge
// immediately when the branch has no required status checks configured,
// and validate-charts is not (yet) a required check on either side. This
// is a statement about the branch protection, not about the platform:
// once required checks exist on `main`, true is the better value.
platformAutomerge: false,
// A release that is hours old should not walk into main unattended; a
// chart publish follows the merge straight away.
minimumReleaseAge: "3 days",
},
{
description: [
"`config:best-practices` brings `docker:pinDigests`, and two of this repo's managers",
"have nowhere to put a digest. custom.regex reads `appVersion:` out of a Chart.yaml,",
"and that value is a TAG by construction: the templates render",
"`<repo>:{{ .Chart.AppVersion }}`, and the chart version's `+up<appVersion>` suffix is",
"built from the same string — an `@sha256:...` fits into neither. cluster-config makes",
"the identical opt-out for its fleet manager, for the identical reason.",
"",
"helm-values is opted out too, but as a decision rather than a necessity: sidecar",
"images there COULD be pinned by digest, and every digest change would then be a chart",
"patch plus a publish. Whether that is worth it is its own question, not part of this",
"alignment.",
"",
"Unaffected: the freesailarr/mediathekarr block below, which extracts a digest that is",
"written in values.yaml on purpose (pinDigests only ADDS digests where none exists),",
"and the GitHub Actions pinning from `helpers:pinGitHubActionDigests` — a different",
"datasource, and wanted.",
],
matchManagers: ["custom.regex", "helm-values"],
pinDigests: false,
},
{
description: [
"Chart app versions. Kept out of the automerge grouping decisions above only by label,",
@@ -121,10 +168,10 @@
"This rule adds only a label. It exists because the update matters more here than",
"its size suggests: a subchart is invisible to everything downstream that watches",
"Helm release versions, so this PR is the ONLY way a new proxy version - a security",
"fix included - ever reaches a cluster. Under the automerge rule above, patch and",
"minor updates do that on their own once the pipeline is green, and",
"ci/renovate-bump-chart.sh bumps the chart patch that publishes it (appVersion is",
"unchanged, so it takes the patch branch). A major arrives as a PR to merge by hand.",
"fix included - ever reaches a cluster. Automerge carries that PR to main once it is",
"reviewed and the check is green, and ci/renovate-bump-chart.sh bumps the chart patch",
"that publishes it (appVersion is unchanged, so it takes the patch branch). A major",
"goes the same way - it is the review, not a separate policy, that gives it a look.",
],
matchManagers: ["helmv3"],
addLabels: ["subchart"],
@@ -212,14 +259,49 @@
//
// When adding a chart, add a block here — see README "Creating a new chart".
//
// Exception, deliberately no block: `freesailarr`. It bundles nine
// independently versioned upstreams, so none of them is "the" app version
// and no image tag comes from appVersion — all nine are pinned as
// Exception, deliberately no APP-VERSION block: `freesailarr`. It bundles
// ten independently versioned upstreams, so none of them is "the" app
// version and no image tag comes from appVersion — they are pinned as
// image.repository/image.tag in its values.yaml, where the built-in
// helm-values manager already finds them and proposes one update per
// image. Adding a block here would either duplicate those updates or
// rewrite an appVersion that selects nothing. Its appVersion is a
// hand-maintained marker; see freesailarr/Chart.yaml.
// image. Adding an appVersion block here would either duplicate those
// updates or rewrite an appVersion that selects nothing. Its appVersion is
// a hand-maintained marker; see freesailarr/Chart.yaml. The one image the
// built-in manager cannot follow — mediathekarr, pinned by digest — has its
// own block directly below.
{
description: [
"freesailarr / mediathekarr: the only image in this repo pinned by DIGEST, because its",
"tags carry no version at all — 'latest' is a 2024 build (v0.2.5) and the upstream README",
"points at the moving 'beta'. The built-in helm-values manager reads repository/tag and",
"would have nothing to compare, so this manager captures all three keys and lets Renovate",
"propose digest updates for the beta tag (currentDigest + currentValue = a digest-only",
"lookup; it arrives as a 'digest' update, which the three-day age gate above covers and",
"automerge then carries once the PR is reviewed and green).",
"",
"The pattern is anchored on the three keys in their values.yaml order: repository, tag,",
"digest. Reordering or renaming them does not fail anything — it silently stops matching,",
"and the image then never gets an update again (#158: a valid rule is not a matching rule).",
"",
"Verified by dry-run (renovate 44.69.11, --platform=local --dry-run=lookup, real registry),",
"two fixtures, because 'no update' is what BOTH a working and a broken rule look like while",
"the pin is current:",
" currentDigest = the pinned one -> extracted with currentValue 'beta', updates [] (the",
" beta tag really does still point at it).",
" currentDigest = sha256:0000... -> updates [{updateType: digest, newValue: beta,",
" newDigest: sha256:3ed7e14...}] on branch renovate/docker.io-pcjones-mediathekarr-beta.",
"The same run also shows the built-in helm-values manager finding this image and skipping it",
"(skipReason invalid-value, 'no currentDigest or pinDigests' — it does not read the digest",
"key), so the two managers cannot produce competing PRs.",
],
customType: "regex",
managerFilePatterns: ["freesailarr/values.yaml"],
matchStrings: [
"repository: (?<depName>docker\\.io/pcjones/mediathekarr)\\s+tag: (?<currentValue>[^\\s]+)\\s+digest: (?<currentDigest>sha256:[0-9a-f]{64})",
],
datasourceTemplate: "docker",
},
{
description: "apache-tika: four-part version plus flavour suffix (3.3.1.0-full); the compatibility group keeps the chart on -full.",
@@ -373,8 +455,9 @@
// isGreaterThan("2026.8.22-777ba8fa4", current) -> false, equals -> true
// 777ba8fa4 was pushed at 03:06 and 9fea41204 at 05:38 the SAME day, so
// the build group makes Renovate offer the older image as an update -
// a downgrade, unattended, because date updates land in the patch/minor
// automerge rule. With NaN the pair is neither equal nor ordered at all.
// a downgrade, and one that automerge would carry to main as soon as the
// PR is waved through as the routine date bump it looks like. With NaN
// the pair is neither equal nor ordered at all.
//
// Uncaptured, the hash still has to be THERE (the pattern is anchored),
// it just takes no part in the comparison: two builds of one day are
@@ -402,6 +485,33 @@
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
},
{
description: [
"sure: the registry carries four kinds of tag side by side - releases (0.7.4), the",
"prereleases -alpha.N / -rc.N / -hotfix.N, the moving names stable/latest/nightly,",
"and one sha-<commit> per build. semver versioning is the whole filter: it parses",
"the release line, marks every prerelease isStable === false so `ignoreUnstable`",
"never offers one, and rejects the moving names and the sha- tags as unparseable",
"rather than ever selecting one. That matters more here than the tag list suggests,",
"because automerge is global in this config (top of the file) and majors carry no",
"age gate: an update this manager offers is one that merges as soon as the required",
"check and the required review are there, so the filtering has to happen at the",
"lookup, not at the merge. It is also the reason NOT to write a capture-group regex",
"here - the moving tags have to stay outside whatever pattern is used, and 'outside'",
"is free with semver.",
"",
"Upstream calls 'latest' the alpha channel and 'stable' the latest release; at the",
"time of writing 'stable' resolves to the same digest as 0.7.4, so pinning the",
"version tag loses nothing and keeps the chart's appVersion honest.",
],
customType: "regex",
managerFilePatterns: ["sure/Chart.yaml"],
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
depNameTemplate: "ghcr.io/we-promise/sure",
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
versioningTemplate: "semver",
},
{
description: "teamspeak-server: the 6.0.0 line only ships beta tags (6.0.0-beta12.1). Loose versioning compares the numeric parts left to right, so beta12.1 sorts above beta9; semver would rank every beta as an equal prerelease.",
customType: "regex",
@@ -428,6 +538,39 @@
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
},
{
description: [
"valkey: the simplest block in this file, and deliberately so. docker versioning",
"splits the tag at the first dash, reads 9.1.2 as the version and 'alpine' as the",
"compatibility suffix, and only ever offers candidates carrying that same suffix -",
"which is exactly what is wanted here, because upstream keeps building an -alpine",
"tag for every release. No versioningTemplate, no capture groups, nothing to prove.",
"",
"That is the SECOND design for this block. The first pinned the fully numbered",
"9.1.2-alpine3.24 and needed a regex versioning to see a move to alpine3.25, since",
"upstream builds exactly one Alpine base per release and the next base arrives as",
"9.1.3-alpine3.25 rather than 9.1.2-alpine3.25. It was dropped because the premise",
"was wrong: that tag is not immutable either - upstream rebuilds the whole matrix",
"periodically (9.1-alpine and 9.0.6-alpine3.24 were pushed minutes apart on",
"2026-09-21, two different release lines). Only a digest is immutable. Given two",
"equally moving tags, the one whose suffix never changes is the safer pin, and its",
"rule needs no proof - which mattered, because a Renovate dry run is impossible in",
"the work environment (no node, see #34).",
"",
"The price, stated rather than discovered later: a rebuild of 9.1.2-alpine changes",
"the running image with no commit here and nothing reports it. The remedy exists if",
"it ever matters - pinDigests for this one image. It is off globally for",
"custom.regex since #34 because an appVersion is a tag by construction, but a",
"per-package exception is possible, and it would then be a chart patch plus a",
"publish per digest change.",
],
customType: "regex",
managerFilePatterns: ["valkey/Chart.yaml"],
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
depNameTemplate: "docker.io/valkey/valkey",
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
},
{
description: [
"ytdlp-web-player: the registry carries exactly two kinds of tag - four versioned",
+1 -1
View File
@@ -5,6 +5,6 @@ icon: https://avatars.githubusercontent.com/u/13281359
type: application
version: 7.1.0+up4.23.10
version: 7.1.1+up4.23.10
appVersion: 4.23.10
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "samba.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "samba.resources" -}}
{{- $resources := . | default dict -}}
@@ -167,8 +169,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "samba.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+1 -1
View File
@@ -3,7 +3,7 @@ name: satisfactory
description: A Helm chart to deploy a Satisfactory Gameserver on Kubernetes
icon: https://raw.githubusercontent.com/wolveix/satisfactory-server/main/.github/logo.png
type: application
version: 7.0.0+upv1.9.10
version: 7.0.2+upv1.9.10
appVersion: "v1.9.10"
maintainers:
- name: jklein
+9 -7
View File
@@ -1,17 +1,19 @@
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) -}}
{{- $gamePort := int .Values.server.gamePort -}}
{{- $msgPort := int .Values.server.messagingPort -}}
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
{{- $dev := fromYaml (include "satisfactory.dev" .Values.dev) -}}
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" (include "satisfactory.formatNumber" $server.replicas)) -}}
{{- $gamePort := int $server.gamePort -}}
{{- $msgPort := int $server.messagingPort -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
{{- $pvc := printf "%s-%s-vc" .Release.Name .Chart.Name -}}
{{- $storage := .Values.server.storage | default "unset" -}}
{{- $res := fromYaml (include "satisfactory.effectiveResources" .Values.server.resources) -}}
{{- $storage := $server.storage | default "unset" -}}
{{- $res := fromYaml (include "satisfactory.effectiveResources" $server.resources) -}}
{{- $lim := $res.limits | default dict -}}
{{- $req := $res.requests | default dict -}}
{{- $eph := index $lim "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
{{- $mem := index $lim "memory" | default "the computed memory limit" -}}
{{- $factor := (.Values.server.resources | default dict).limitFactor | default 2 -}}
{{- $factor := ($server.resources | default dict).limitFactor | default 2 -}}
{{- $startup := fromYaml (include "satisfactory.startupProbe" .Values.startupProbe) -}}
{{- $budget := "" -}}
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
@@ -21,7 +23,7 @@
{{- $seasonalEnv := include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents -}}
{{- $seasonalOff := eq $seasonalEnv "true" -}}
{{- $beta := eq (lower (printf "%v" $game.experimental)) "true" -}}
{{- $debug := eq (lower (printf "%v" .Values.dev.debug)) "true" -}}
{{- $debug := eq (lower (printf "%v" $dev.debug)) "true" -}}
{{- $outOfRange := or (lt $gamePort 30000) (gt $gamePort 32767) (lt $msgPort 30000) (gt $msgPort 32767) -}}
{{- $skipRaw := "" -}}
{{- range $entry := .Values.env -}}
+65 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "satisfactory.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "satisfactory.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "satisfactory.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
@@ -308,6 +311,63 @@ DISABLESEASONALEVENTS at "true", the value this chart has always rendered.
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "game") -}}
{{- end -}}
{{/*
The effective dev block (Gitea issue #16, the rest of #99).
"--set dev=null" — or a leftover "dev:" whose last sub-key was deleted — used
to abort the render with a nil-pointer error at .Values.dev.debug, in the
StatefulSet and in NOTES.txt. It now falls back to the default values.yaml
documents, the same way the game block has since 7.0.0.
The defaults below MUST stay in sync with the dev block in values.yaml, which
stays the documented, user-facing place for them; this copy is only the
fallback for the case where an override has erased the block.
*/}}
{{- define "satisfactory.dev" -}}
{{- $defaults := dict
"debug" false -}}
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "dev") -}}
{{- end -}}
{{/*
The effective server block (Gitea issue #16, the rest of #99).
"--set server=null" used to abort the render at .Values.server.replicas, and a
single erased sub-key was worse than that: it rendered silently. A nulled
server.gamePort produced a Service with "port:" and "nodePort:" empty and a
StatefulSet with an empty containerPort — a manifest the API server rejects
only at apply time. Those now fall back to the documented defaults instead.
TWO keys are deliberately NOT defaulted here.
server.resources, because it already runs through
satisfactory.effectiveResources, which carries its own defaults and is
null-safe on its own; repeating limitFactor/4Gi/1/5Gi here would create a
third place for them to drift apart.
server.storage, because values.schema.json documents the opposite decision
for it on purpose: an erased size must yield an invalid PVC that fails at
apply time rather than a workload quietly sized by the chart. An erased
"server:" therefore still renders a PVC without a size — deliberately, not by
oversight. That exception is worth re-examining, but it is someone's reasoned
decision and not a bugfix's to overturn; it is negotiated in Gitea issue #39.
Both rely on the same mechanic: keys the defaults do not mention are passed
through by satisfactory.defaultedDict unchanged, so an erased "server:" leaves
them unset and whatever consumes them downstream decides what that means.
The defaults below MUST stay in sync with the server block in values.yaml,
which stays the documented, user-facing place for them; this copy is only the
fallback for the case where an override has erased the block.
*/}}
{{- define "satisfactory.server" -}}
{{- $defaults := dict
"replicas" 1
"gamePort" 30777
"messagingPort" 30888 -}}
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "server") -}}
{{- end -}}
{{/*
Render the DISABLESEASONALEVENTS env value from game.enableSeasonalEvents
(issue #215).
+2 -1
View File
@@ -1,3 +1,4 @@
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -7,5 +8,5 @@ spec:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.server.storage }}
storage: {{ $server.storage }}
storageClassName: longhorn
@@ -1,3 +1,6 @@
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
{{- $gamePort := include "satisfactory.formatNumber" $server.gamePort -}}
{{- $msgPort := include "satisfactory.formatNumber" $server.messagingPort -}}
apiVersion: v1
kind: Service
metadata:
@@ -8,19 +11,19 @@ spec:
type: NodePort
ports:
- name: "api"
port: {{ .Values.server.gamePort }}
port: {{ $gamePort }}
protocol: TCP
nodePort: {{ .Values.server.gamePort }}
targetPort: {{ .Values.server.gamePort }}
nodePort: {{ $gamePort }}
targetPort: {{ $gamePort }}
- name: "game"
port: {{ .Values.server.gamePort }}
port: {{ $gamePort }}
protocol: UDP
nodePort: {{ .Values.server.gamePort }}
targetPort: {{ .Values.server.gamePort }}
nodePort: {{ $gamePort }}
targetPort: {{ $gamePort }}
- name: "messaging"
port: {{ .Values.server.messagingPort }}
port: {{ $msgPort }}
protocol: TCP
nodePort: {{ .Values.server.messagingPort }}
targetPort: {{ .Values.server.messagingPort }}
nodePort: {{ $msgPort }}
targetPort: {{ $msgPort }}
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
+10 -8
View File
@@ -1,4 +1,6 @@
{{- $game := fromYaml (include "satisfactory.game" .Values.game) -}}
{{- $dev := fromYaml (include "satisfactory.dev" .Values.dev) -}}
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
apiVersion: apps/v1
kind: StatefulSet
metadata:
@@ -10,7 +12,7 @@ spec:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) }}
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" (include "satisfactory.formatNumber" $server.replicas)) }}
template:
metadata:
labels:
@@ -24,7 +26,7 @@ spec:
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
env:
- name: DEBUG
value: "{{ .Values.dev.debug }}"
value: "{{ $dev.debug }}"
- name: MAXPLAYERS
value: "{{ include "satisfactory.formatNumber" $game.maxPlayers }}"
# PUID/PGID are only used by the image's root entrypoint path;
@@ -42,9 +44,9 @@ spec:
- name: DISABLESEASONALEVENTS
value: "{{ include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents }}"
- name: SERVERGAMEPORT
value: "{{ .Values.server.gamePort }}"
value: "{{ include "satisfactory.formatNumber" $server.gamePort }}"
- name: SERVERMESSAGINGPORT
value: "{{ .Values.server.messagingPort }}"
value: "{{ include "satisfactory.formatNumber" $server.messagingPort }}"
{{- range $value := .Values.env }}
{{- if $value.value }}
- name: {{ $value.name }}
@@ -67,13 +69,13 @@ spec:
{{- end }}
ports:
- name: "api"
containerPort: {{ .Values.server.gamePort }}
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
protocol: TCP
- name: "game"
containerPort: {{ .Values.server.gamePort }}
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
protocol: UDP
- name: "messaging"
containerPort: {{ .Values.server.messagingPort }}
containerPort: {{ include "satisfactory.formatNumber" $server.messagingPort }}
protocol: TCP
livenessProbe:
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
@@ -82,7 +84,7 @@ spec:
startupProbe:
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
resources:
{{- include "satisfactory.effectiveResources" .Values.server.resources | nindent 12 }}
{{- include "satisfactory.effectiveResources" $server.resources | nindent 12 }}
volumeMounts:
- name: satisfactory-volume
mountPath: /config
+13 -8
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "satisfactory values",
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key that the chart does not read is rejected at render time instead of being silently ignored. The only objects that stay open are the ones passed verbatim into the Kubernetes pod spec (probes, security contexts, global) - the chart does not interpret their contents, so it cannot judge which keys are meaningful. The hardening, probe and resources blocks additionally accept null, because an erased block falls back to the chart defaults (issue #99). Note that this chart keeps env, probes and securityContext at the TOP level rather than under a chart-named key.",
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key that the chart does not read is rejected at render time instead of being silently ignored. The only objects that stay open are the ones passed verbatim into the Kubernetes pod spec (probes, security contexts, global) - the chart does not interpret their contents, so it cannot judge which keys are meaningful. The hardening, probe and resources blocks additionally accept null, because an erased block falls back to the chart defaults (issue #99); since 7.0.1 that holds for every chart-owned block, dev, game and server included (Gitea issue #16), so no block aborts the render any more. The one key that stays un-defaulted is server.storage, and it says so itself (Gitea issue #39). Note that this chart keeps env, probes and securityContext at the TOP level rather than under a chart-named key.",
"type": "object",
"additionalProperties": false,
"properties": {
@@ -13,11 +13,12 @@
"type": "boolean"
},
"dev": {
"type": "object",
"description": "May be null / empty: the chart then falls back to its documented dev defaults (Gitea issue #16, the rest of issue #99) instead of aborting the render with a nil-pointer error, which is what an erased block used to do.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"debug": {
"description": "Passed to the image as DEBUG.",
"description": "Passed to the image as DEBUG. Null means the chart default (false).",
"type": ["boolean", "string", "null"]
}
}
@@ -41,25 +42,29 @@
}
},
"server": {
"type": "object",
"description": "May be null / empty: the chart then falls back to its documented server defaults (Gitea issue #16, the rest of issue #99) instead of aborting the render with a nil-pointer error, which is what an erased block used to do. Two keys are exceptions. server.resources is not defaulted here but passed through to the resources fallback, which carries the defaults for it. server.storage is not defaulted at all, deliberately - see its own description.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"replicas": {
"type": "integer",
"description": "Null means the chart default (1). An explicit 0 is kept and scales the StatefulSet down.",
"type": ["integer", "null"],
"minimum": 0
},
"gamePort": {
"type": "integer",
"description": "Null means the chart default (30777). Up to 7.0.0 an erased port rendered an empty port number into the Service and the StatefulSet, which only the API server rejected (Gitea issue #16).",
"type": ["integer", "null"],
"minimum": 1,
"maximum": 65535
},
"messagingPort": {
"type": "integer",
"description": "Null means the chart default (30888). Same erased-port trap as gamePort above.",
"type": ["integer", "null"],
"minimum": 1,
"maximum": 65535
},
"storage": {
"description": "Size of the data PVC. Not null-safe on purpose: an erased value yields an invalid PVC rather than a silently degraded workload.",
"description": "Size of the data PVC. Not null-safe on purpose: an erased value yields an invalid PVC rather than a silently degraded workload. This stays the documented exception after Gitea issue #16 made every other block in this chart null-safe, so \"server: null\" still renders a PVC without a size; whether failing loudly at apply time beats falling back to 20Gi is negotiated in Gitea issue #39, not inside a bugfix patch.",
"type": ["string", "null"]
},
"resources": {
+14
View File
@@ -4,6 +4,9 @@
# Services stay in place; scaleDown: false brings the workloads back up.
scaleDown: false
# Erasing this block (dev: with no sub-keys, or --set dev=null) falls back to
# the defaults below instead of aborting the render — same for game, server and
# the hardening/probe/resources blocks (Gitea issue #16, issue #99).
dev:
debug: false
@@ -28,12 +31,23 @@ game:
# again. Set enableSeasonalEvents: true to actually switch the events on.
enableSeasonalEvents: false
# Erasing this block, or any single key in it, falls back to the defaults below
# (Gitea issue #16) — with the documented exception of storage, see there.
# Before 7.0.1 "server: null" aborted the render, and a single erased key was
# the quieter half: a nulled gamePort rendered an empty port number into the
# Service and the StatefulSet, rejected by the API server at apply time and by
# nothing before it.
server:
# Replica count for this workload; 0 is allowed and scales it down.
# The chart-global scaleDown flag takes precedence.
replicas: 1
gamePort: 30777
messagingPort: 30888
# The size of the data PVC, and the ONE key of this block that is NOT
# null-safe — deliberately, not by oversight. An erased storage (including
# "server: null") renders a PVC without a size, which fails at apply time
# instead of quietly sizing the volume from the chart. Whether that is still
# the right trade is negotiated in Gitea issue #39, not here.
storage: 20Gi
resources:
# Limits are optional: memory and ephemeral-storage limits default to
+2 -2
View File
@@ -9,8 +9,8 @@ type: application
# (2026.8.22-9fea41204) and "latest" moves daily. appVersion is therefore the
# exact tag, and the +up suffix normalizes the dash to a dot as the versioning
# scheme requires - the same shape rss-bridge and minecraft-server use.
version: 1.0.4+up2026.9.11.ffe96f8a6
appVersion: "2026.9.11-ffe96f8a6"
version: 1.0.6+up2026.9.13.3b8009065
appVersion: "2026.9.13-3b8009065"
maintainers:
- name: jklein
+7 -6
View File
@@ -19,9 +19,10 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "searxng.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. template-chart holds the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. template-chart holds the reference implementation.
*/}}
{{- define "searxng.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,9 +169,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies. template-chart holds the
reference implementation.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14). template-chart holds the reference implementation.
*/}}
{{- define "searxng.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+4 -1
View File
@@ -6,6 +6,9 @@ metadata:
spec:
# Optional, and only meaningful with settings.limiter: true. The item must
# carry a "valkey-url" field pointing at an EXTERNAL Valkey/Redis; the chart
# deploys none of its own, because charts in this repo have no dependencies.
# deploys none of its own. Chart dependencies are admissible since #214
# (README, guideline 14), but this chart declares none: an external connection
# through a secret is the same shape outline and paperless-ngx use for their
# databases.
itemPath: {{ .Values.secrets.valkeySecretRef }}
{{- end }}
+4 -4
View File
@@ -32,10 +32,10 @@ secrets:
# unless the bot detection below is switched on.
#
# Setting it renders a second OnePasswordItem and passes the URL to the
# container as SEARXNG_VALKEY_URL. The chart ships NO Valkey of its own -
# charts in this repo have no dependencies (README, guideline 13), so an
# external connection through a secret is the same shape outline and
# paperless-ngx use for their databases.
# container as SEARXNG_VALKEY_URL. The chart ships NO Valkey of its own.
# Chart dependencies are admissible since #214 (README, guideline 14), but
# this chart declares none, so an external connection through a secret is the
# same shape outline and paperless-ngx use for their databases.
valkeySecretRef: null
#
# Documented limitation (README, guideline 9): the CONTENT of these items is
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v2
name: sure
description: A Helm chart to deploy Sure (we-promise) on Kubernetes
icon: https://raw.githubusercontent.com/we-promise/sure/main/public/android-chrome-512x512.png
type: application
version: 1.0.0+up0.7.4
appVersion: "0.7.4"
maintainers:
- name: jklein
+165
View File
@@ -0,0 +1,165 @@
{{- $replicas := include "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.replicas) -}}
{{- $webStartup := fromYaml (include "sure.webStartupProbe" .Values.sure.web.startupProbe) -}}
{{- $workerStartup := fromYaml (include "sure.workerStartupProbe" .Values.sure.worker.startupProbe) -}}
{{- $workerLiveness := fromYaml (include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe) -}}
Sure {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS
https://{{ .Values.ingress.domain }}
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
cluster issuer. Until it has been issued the browser shows a warning; follow it with
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
The first account created on a fresh instance becomes the super admin — sign
in yourself before handing the URL to anyone.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate.
ingress.domain is still required: it becomes APP_DOMAIN (the host in every
mail link) and the WebAuthn relying party the app binds passkeys to. Reach
the instance inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
and open http://localhost:8080. With the Ingress off the chart also renders
RAILS_ASSUME_SSL and RAILS_FORCE_SSL as "false", which is what makes that
plain-HTTP session able to log in at all; mail links still point at
https://{{ .Values.ingress.domain }}/.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}sure.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so neither the web nor
the worker process is running. PVC, Secrets, Service{{ if .Values.ingress.enabled }}, Ingress and the
certificate stay{{ else }} stay{{ end }} in place; set it back and upgrade to bring both containers up.
{{ end }}
WHAT THE THREE 1PASSWORD ITEMS MUST CONTAIN
The chart creates OnePasswordItem CRs; the operator turns each into a
Kubernetes Secret whose keys are the FIELD NAMES of the 1Password item. A
missing field does not fail the install — the release goes through and the
pod then hangs in CreateContainerConfigError naming the key it could not
find (kubectl -n {{ .Release.Namespace }} describe pod).
secrets.appSecretRef → {{ .Values.secrets.appSecretRef }}
Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret, field:
secret-key-base SECRET_KEY_BASE, 64 random bytes hex
(openssl rand -hex 64)
secrets.dbSecretRef → {{ .Values.secrets.dbSecretRef }}
Secret {{ .Release.Name }}-{{ .Chart.Name }}-db-secret, fields:
host DB_HOST, the PostgreSQL hostname
port DB_PORT, usually 5432
database POSTGRES_DB
username POSTGRES_USER
password POSTGRES_PASSWORD
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
Secret {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret, field:
connection-url REDIS_URL, e.g. redis://:password@host:6379/0
ROTATING SECRET_KEY_BASE DESTROYS MORE THAN SESSIONS
In self-hosted mode Sure derives its Active Record encryption keys from
SECRET_KEY_BASE (config/initializers/active_record_encryption.rb: three
SHA-256 digests over that one value) unless
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY and its two companions are set. Every
encrypted column in the database — provider tokens, OAuth credentials, MFA
secrets — is therefore readable only with the SECRET_KEY_BASE it was written
under. Replacing that value does not just log everyone out: it makes the
existing encrypted data undecryptable, and no rotation of the item brings it
back. Treat this field as permanent for the life of the database, or pin the
three encryption keys explicitly through sure.web.env / sure.worker.env
before the first start.
CHANGING A SECRET'S CONTENT RESTARTS NOTHING
Helm never sees the content of a OnePasswordItem, so it cannot hash it. The
pod template carries checksum/secret-ref annotations over the rendered
OnePasswordItems, so pointing a *SecretRef at a DIFFERENT item rolls the
pod — but editing a value INSIDE the same item does not: the operator
updates the Kubernetes Secret while both containers keep the old value in
their environment. After such a change:
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
The operator's auto-restart annotation would not help here and is
deliberately not set: it restarts Deployments only, and this is a
StatefulSet.
POSTGRES AND REDIS ARE NOT PART OF THIS CHART
Both must exist and be reachable before the first start. Redis is not a
cache the app can do without — it holds the Sidekiq queues, the Rails cache
store and the rate limiter.
What the probes do with that, per container:
web liveness and startup use /up, which is Rails' boot check and
touches no database — a Postgres outage therefore does not restart
the pod. Readiness uses "/", which runs a User.count query, so the
pod leaves the Service while the database is gone and returns on
its own afterwards.
worker all three probes run "sidekiqmon processes" and look for this
pod's own entry, i.e. a live Sidekiq heartbeat in Redis. There is
no HTTP and no dependency-free alternative: with Redis gone the
worker has nothing to do, and liveness only restarts after
{{ $workerLiveness.failureThreshold }} x {{ $workerLiveness.periodSeconds }}s = {{ mul $workerLiveness.failureThreshold $workerLiveness.periodSeconds }}s of missing heartbeat.
A pod that never turns Ready is usually a connection problem, not a chart
problem: read the container logs, not the pod events.
kubectl -n {{ .Release.Namespace }} logs statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs -c {{ .Release.Name }}-{{ .Chart.Name }}-web
THE FIRST START MIGRATES, AND THE STARTUP PROBE IS THE CLOCK
The image entrypoint runs "./bin/rails db:prepare" — but only when the
command is "./bin/rails server". That is the web container, and only the web
container: the worker runs "bundle exec sidekiq" and never migrates, so the
two can never race. Puma binds the port after the migration, which means the
whole schema creation happens inside the startup probe's budget:
web {{ $webStartup.failureThreshold }} x {{ $webStartup.periodSeconds }}s = {{ mul $webStartup.failureThreshold $webStartup.periodSeconds }}s
worker {{ $workerStartup.failureThreshold }} x {{ $workerStartup.periodSeconds }}s = {{ mul $workerStartup.failureThreshold $workerStartup.periodSeconds }}s (it waits for the schema the web container creates)
Raise sure.web.startupProbe.failureThreshold BEFORE a large app-version jump
or a first import, not after the crash loop has started — a restart lands in
the middle of a migration. Back up the database before any app-version bump:
migrations are one-way, and a helm rollback returns the old image to a
database the new one has already rewritten.
ONE POD, ONE VOLUME, TWO CONTAINERS
Uploads, imports and exports live on the ReadWriteOnce PVC
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, mounted at /rails/storage in both containers. That is
the reason web and worker share a pod at all: a background job writes the
same files the web process serves, and one RWO volume cannot be attached to
two pods.
{{- if gt (int $replicas) 1 }}
sure.replicas is {{ $replicas }}. Expect the extra pods to stay Pending on
"Multi-Attach error for volume" unless the storage class is genuinely RWX.
{{- end }}
Backup is two things at the same time: the database holds the accounts and
one row per attachment, the PVC holds the bytes. Restoring one without the
other gives an app full of broken references. Because the PVC is rendered by
the chart, helm uninstall deletes it — the external database survives that,
which makes an accidental uninstall look recoverable while the files are
already gone.
THE CHART CONFIGURES NO MAIL AND NO AI
Sure sends invitations and notifications over SMTP and can run an assistant
against an OpenAI-compatible endpoint. This chart sets neither: no SMTP_*
variable, no OPENAI_* variable. Mails are therefore quietly never sent, and
the AI features stay off — nothing in the UI says so.
Both are plain environment variables, so they go into sure.web.env and, for
anything a background job needs (the assistant runs in the worker), into
sure.worker.env as well. The secretKeyRef form is available for the API key
and the SMTP password. Note that upstream sizes AI usage at 1 GB of memory
per process; the chart's computed memory limit is 3x its request.
{{- if or .Values.sure.web.env .Values.sure.worker.env }}
Currently set: {{ len .Values.sure.web.env }} extra variable(s) on the web container,
{{ len .Values.sure.worker.env }} on the worker, rendered after the ones the chart sets itself.
An entry repeating a name the chart already sets wins at install time and is
silently lost on the next helm upgrade — read it back off the StatefulSet.
{{- end }}
+451
View File
@@ -0,0 +1,451 @@
{{/*
Render the body of a container "resources:" block from a values dict of the form
resources:
limitFactor: 3 # optional, default 3
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
limits: { ... } # optional, every entry optional
Rules (variant B, see issue #19):
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
to the Kubernetes key "ephemeral-storage").
- an explicitly set limit always wins.
- a missing memory / ephemeral-storage limit is computed as
limitFactor x the corresponding request.
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
unlimited; CPU is compressible, so this cannot kill the workload).
NOTE: this helper is copied into every chart's templates/_helpers.tpl with the
chart's name as define prefix rather than shared through a library chart; the
reference implementation is template-chart/templates/_helpers.tpl.
*/}}
{{- define "sure.resources" -}}
{{- $resources := . | default dict -}}
{{- $factor := 3.0 -}}
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
{{- $factor = float64 $resources.limitFactor -}}
{{- end -}}
{{- $requests := dict -}}
{{- range $key, $value := ($resources.requests | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $requests (include "sure.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "sure.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "sure.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
{{- end -}}
{{- end -}}
{{- $out := dict -}}
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Normalize a resource name from values to its Kubernetes key.
*/}}
{{- define "sure.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer
results are printed without a decimal point.
*/}}
{{- define "sure.multiplyQuantity" -}}
{{- $quantity := include "sure.formatNumber" .quantity -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
{{- end -}}
{{- $product := mulf (float64 $number) .factor -}}
{{- if eq $product (floor $product) -}}
{{- printf "%d%s" (int64 $product) $suffix -}}
{{- else -}}
{{- printf "%s%s" (include "sure.formatNumber" $product) $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render a number as a plain decimal string, never in exponential notation.
Why this exists (issue #157): 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 became "1.048576e+06" - not a valid Kubernetes quantity, and the
render aborted. Strings ("512Mi") are passed through untouched.
*/}}
{{- define "sure.formatNumber" -}}
{{- if kindIs "float64" . -}}
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
{{- else -}}
{{- printf "%v" . -}}
{{- end -}}
{{- end -}}
{{/*
Render the replica count for the StatefulSet.
Input: dict
"scaleDown" the chart-global .Values.scaleDown flag (may be nil)
"replicas" the workload's replicas value (may be nil)
Rules (issue #25):
- scaleDown: true takes precedence and always yields 0.
- otherwise an explicitly set replicas value is used as given, including an
explicit 0 - Helm's "default" would swallow 0, hence the explicit
kindIs "invalid" nil check.
- a missing replicas value defaults to 1.
*/}}
{{- define "sure.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
1
{{- end -}}
{{- end -}}
{{/*
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
null / empty override falls back to the defaults instead of erasing them.
Input: dict "defaults" <dict> "given" <dict|nil> "path" <string, optional
values path used in error messages>. Output: YAML of the merged dict (consume
it with fromYaml).
Why this exists (issues #61 and #99): Helm merges values maps recursively, but
an override key written without children - e.g. a leftover
securityContext:
after deleting its last remaining sub-key - is YAML null. Where the chart
defines a default, Helm treats that null as a deletion of the default; the
template then renders "securityContext: null": no readOnlyRootFilesystem, no
dropped capabilities, no allowPrivilegeEscalation: false. The workload starts
and reports healthy while being unhardened, so nothing points at the mistake.
The same shape erases a probe (it disappears) or the resources block (no
requests, no computed limits). This helper defines the opposite semantics: an
empty block means "chart defaults apply".
*/}}
{{- define "sure.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
{{- $given := .given -}}
{{- $path := .path | default "value" -}}
{{- if kindIs "invalid" $given -}}
{{- $given = dict -}}
{{- end -}}
{{- if not (kindIs "map" $given) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
{{- end -}}
{{- $out := deepCopy $defaults -}}
{{- range $key, $value := $given -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $default := index $defaults $key -}}
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
{{- $_ := set $out $key (fromYaml (include "sure.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
{{- else if kindIs "map" $default -}}
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
{{- else -}}
{{- $_ := set $out $key $value -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Pick one sub-block out of a values block that may itself be null.
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
non-mapping block with a message that names the values path. Needed because
".Values.sure.web.securityContext" can itself be erased, and indexing into nil
inside a template is not an error but silently yields nothing.
*/}}
{{- define "sure.subBlock" -}}
{{- $block := .block -}}
{{- if kindIs "invalid" $block -}}
{{- $block = dict -}}
{{- end -}}
{{- if not (kindIs "map" $block) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
{{- end -}}
{{- toYaml (dict "value" (index $block .key)) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
The defaults below MUST stay in sync with the corresponding blocks in
values.yaml, which stays the documented, user-facing place for them; this copy
is only the fallback for the case where an override has erased the block. Every
reason behind a value is written down there, including what each probe actually
proves - a probe default without its path, port name or exec command would
leave the workload with no working probe at all (README, guideline 12.5).
*/}}
{{- define "sure.podSecurityContext" -}}
{{- $given := (fromYaml (include "sure.subBlock" (dict "block" . "key" "pod" "path" "sure.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 1000
"runAsGroup" 1000
"fsGroup" 1000
"fsGroupChangePolicy" "Always"
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" "sure.securityContext.pod") -}}
{{- end -}}
{{- define "sure.containerSecurityContext" -}}
{{- $given := (fromYaml (include "sure.subBlock" (dict "block" .block "key" "container" "path" .path))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL")) -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" (printf "%s.container" .path)) -}}
{{- end -}}
{{/*
Web container probes. /up is Rails' boot check and touches neither Postgres nor
Redis, so liveness and startup use it; readiness uses "/", which runs
User.count through authenticate_user! in self-hosted mode and therefore fails
while the database is unreachable. The long startup budget covers the
"./bin/rails db:prepare" the entrypoint runs before Puma binds the port.
*/}}
{{- define "sure.webLivenessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/up" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.livenessProbe") -}}
{{- end -}}
{{- define "sure.webReadinessCommand" -}}
{{- list "/bin/bash" "-c" "code=\"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/)\"; test \"$code\" -ge 200 -a \"$code\" -lt 400" | toJson -}}
{{- end -}}
{{- define "sure.webReadinessProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "sure.webReadinessCommand" . | fromJsonArray))
"periodSeconds" 10
"timeoutSeconds" 10
"failureThreshold" 3 -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.readinessProbe") -}}
{{- end -}}
{{- define "sure.webStartupProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/up" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 60 -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.startupProbe") -}}
{{- end -}}
{{/*
Worker container probes. Sidekiq serves no HTTP, so all three ask its own
monitor whether THIS process still holds a heartbeat in Redis; the exec command
is part of the default, not decoration (README, guideline 12.5).
*/}}
{{- define "sure.workerProbeCommand" -}}
{{- list "/bin/bash" "-c" "bundle exec sidekiqmon processes | grep -q \"^${HOSTNAME}:\"" | toJson -}}
{{- end -}}
{{- define "sure.workerLivenessProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
"periodSeconds" 60
"timeoutSeconds" 15
"failureThreshold" 5 -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.livenessProbe") -}}
{{- end -}}
{{- define "sure.workerReadinessProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
"periodSeconds" 30
"timeoutSeconds" 15
"failureThreshold" 3 -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.readinessProbe") -}}
{{- end -}}
{{- define "sure.workerStartupProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
"periodSeconds" 20
"timeoutSeconds" 15
"failureThreshold" 30 -}}
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.startupProbe") -}}
{{- end -}}
{{/*
The effective resources blocks, fed into the resources helper above so that an
erased "resources:" still yields the chart's requests and computed limits
instead of a container without any resource accounting. Both containers run the
same image and carry the same request; see values.yaml for where the number
comes from.
*/}}
{{- define "sure.webResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
{{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.resources")) -}}
{{- include "sure.resources" $merged -}}
{{- end -}}
{{- define "sure.workerResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
{{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.resources")) -}}
{{- include "sure.resources" $merged -}}
{{- end -}}
{{/*
The environment both containers share: the same image, the same database, the
same Redis, the same secrets. Only the command differs.
Input: the root context.
ingress.domain is read HERE, outside the Ingress template, three times over
(APP_DOMAIN, WEBAUTHN_RP_ID, WEBAUTHN_ALLOWED_ORIGINS), so it carries its own
required() and stays mandatory when ingress.enabled is false - README,
guideline 12.1. An empty value would not fail the render: it would register
passkeys against an empty relying party and build mail links without a host.
SELF_HOSTED=true is not optional for this chart: it is what turns Sure into the
single-family, no-subscription mode, and it is also what makes the readiness
probe on "/" reach the database at all (see values.yaml).
*/}}
{{- define "sure.commonEnv" -}}
{{- $domain := required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain -}}
- name: SELF_HOSTED
value: "true"
- name: RAILS_ENV
value: "production"
# Not cosmetic and not optional: /tmp is an emptyDir, which Kubernetes creates
# mode 0777 without a sticky bit, and Ruby skips exactly that shape when it
# looks for a temporary directory. Without this the app aborts during boot with
# "could not find a temporary directory". The directory is created by the
# init container in the StatefulSet, which explains the whole case.
- name: TMPDIR
value: "/tmp/ruby"
# The image's home directory (/home/rails) is part of the read-only root
# filesystem, and Bundler reacts to that by creating a throwaway home under
# TMPDIR on EVERY invocation ("`/home/rails` is not writable. Bundler will use
# `/tmp/ruby/bundler2026...` as your home directory temporarily", measured in
# the install-test). That is one directory per `bundle exec` - including every
# worker probe, every 30 seconds. Pointing HOME at a writable directory turns
# that into one stable path.
- name: HOME
value: "/tmp/home"
- name: PORT
value: "3000"
# Sure's production.rb defaults BOTH of these to true. The chart renders them
# explicitly from ingress.enabled instead: with the Ingress terminating TLS,
# assume_ssl makes Rails treat the forwarded request as secure (so force_ssl
# adds HSTS and secure cookies without redirecting), while without an Ingress
# both have to be off - a port-forwarded plain-HTTP session would otherwise
# either be redirected to https or hand out cookies the browser never returns.
- name: RAILS_ASSUME_SSL
value: "{{ .Values.ingress.enabled }}"
- name: RAILS_FORCE_SSL
value: "{{ .Values.ingress.enabled }}"
# Action Mailer builds absolute URLs from this host, and it is the fallback the
# WebAuthn initializer uses when WEBAUTHN_RP_ID is unset.
- name: APP_DOMAIN
value: {{ $domain | quote }}
# Passkeys are bound to the relying party id; changing it later invalidates
# every registered credential (upstream docs/hosting/webauthn.md).
- name: WEBAUTHN_RP_ID
value: {{ $domain | quote }}
- name: WEBAUTHN_ALLOWED_ORIGINS
value: {{ printf "https://%s" $domain | quote }}
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
key: secret-key-base
- name: DB_HOST
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
key: host
- name: DB_PORT
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
key: port
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
key: database
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
key: password
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret
key: connection-url
{{- end -}}
{{/*
The additive env block of one container (README, guideline 10).
Input: dict "root" <root context> "env" <the container's env list>.
Entries are rendered through tpl, so a value may contain Helm templating, and
the value, secretKeyRef and configMapKeyRef forms are supported.
*/}}
{{- define "sure.env" -}}
{{- $root := .root -}}
{{- range $value := .env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "3" or "true" renders
# unquoted as a YAML number/bool, and the API server then rejects the whole
# manifest with "cannot unmarshal number into Go struct field EnvVar.value of
# type string".
value: {{ tpl $value.value $root | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end -}}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
spec:
itemPath: {{ required "A Secret-Reference for the database must be provided (secrets.dbSecretRef)" .Values.secrets.dbSecretRef }}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret
spec:
itemPath: {{ required "A Secret-Reference for Redis must be provided (secrets.redisSecretRef)" .Values.secrets.redisSecretRef }}
+28
View File
@@ -0,0 +1,28 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
http:
paths:
- backend:
service:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.storage.amount }}
{{- if .Values.storage.storageClass }}
storageClassName: {{ .Values.storage.storageClass }}
{{- end }}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
spec:
itemPath: {{ required "A Secret-Reference for the app secret must be provided (secrets.appSecretRef)" .Values.secrets.appSecretRef }}
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
spec:
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
ports:
- protocol: TCP
port: 80
targetPort: http
name: http
type: ClusterIP
+231
View File
@@ -0,0 +1,231 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
replicas: {{ include "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
annotations:
# The chart has no ConfigMap, so there is no checksum/config here. What
# these annotations cover is the SOURCE of the secrets, not their
# content: the Kubernetes Secrets are materialized by the 1Password
# operator, so Helm never sees what is in them. Repointing one of the
# secrets.*SecretRef values at a different item therefore changes the
# pod template and rolls the pod, instead of silently leaving the old
# credentials in the running container. A change of an item's CONTENT
# inside the vault is not covered - see the comment on secrets in
# values.yaml for why that gap cannot be closed here.
# The name differs from checksum/config on purpose - that one promises
# "the config changed", this one only "the config source changed".
checksum/secret-ref: {{ include (print $.Template.BasePath "/sure.secret.yaml") . | sha256sum }}
checksum/secret-ref-db: {{ include (print $.Template.BasePath "/sure-db.secret.yaml") . | sha256sum }}
checksum/secret-ref-redis: {{ include (print $.Template.BasePath "/sure-redis.secret.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "sure.podSecurityContext" .Values.sure.securityContext | nindent 8 }}
initContainers:
# Ruby refuses a world-writable directory that has no sticky bit as its
# temporary directory (ruby 3.4, tmpdir.rb: a candidate is skipped when
# "world_writable? && !sticky?"), and a Kubernetes emptyDir is created
# mode 0777 WITHOUT the sticky bit. With /tmp mounted as an emptyDir and
# the image's root filesystem read-only, Ruby then finds no candidate at
# all and both containers die during boot - measured in this chart's
# first install-test, which logged
#
# system temporary path is world-writable: /tmp
# /tmp is world-writable: /tmp
# . is not writable: /rails
# ArgumentError: could not find a temporary directory
#
# raised from MiniMagick, which Active Storage loads while Rails boots.
# Note where it does NOT come from: nothing in the app asked for a temp
# file yet.
#
# The fix is a directory that is writable without being world-writable.
# This init container creates two of them per app container inside the
# same emptyDirs (mode 0700, owned by the pod's uid): "ruby" for TMPDIR,
# and "home" for HOME - the image's /home/rails is read-only, and
# Bundler answers that by creating a throwaway home under TMPDIR on
# every single invocation. It runs as the same non-root uid as the app
# and needs no capability at all: the emptyDir root is 0777, so creating an
# entry in it is allowed, and chmod on a directory one has just created
# needs no privilege. That is deliberately weaker than the chown-as-root
# init container paperless-ngx needs.
#
# The image is the app's own, so there is no second image to pin, no
# Renovate block to maintain for it and nothing extra to pull.
- name: {{ .Release.Name }}-{{ .Chart.Name }}-init-tmp
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
# command, not args: the image entrypoint would start the application.
command:
- /bin/bash
- -ec
- |
for dir in /web-tmp/ruby /web-tmp/home /worker-tmp/ruby /worker-tmp/home; do
mkdir -p "$dir"
chmod 700 "$dir"
done
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
volumeMounts:
- name: web-tmp
mountPath: /web-tmp
- name: worker-tmp
mountPath: /worker-tmp
# Fixed, deliberately small: this container runs two mkdirs and two
# chmods. Not values-configurable, for the same reason as the init
# container in paperless-ngx - there is nothing here to tune.
resources:
requests:
cpu: 10m
memory: 32Mi
ephemeral-storage: 1Mi
limits:
cpu: 100m
memory: 64Mi
ephemeral-storage: 10Mi
containers:
# The web process. Its command is the image's default
# (CMD ["./bin/rails", "server"]), which is exactly what makes the
# entrypoint run "./bin/rails db:prepare" first - the migration only
# happens for that command, so it happens here and nowhere else.
- name: {{ .Release.Name }}-{{ .Chart.Name }}-web
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
env:
{{- include "sure.commonEnv" . | nindent 12 }}
{{- include "sure.env" (dict "root" $ "env" .Values.sure.web.env) | nindent 12 }}
ports:
- containerPort: 3000
protocol: TCP
name: http
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if" - a guard hides the erased case
# completely instead of rendering a visible null.
livenessProbe:
{{- include "sure.webLivenessProbe" .Values.sure.web.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "sure.webReadinessProbe" .Values.sure.web.readinessProbe | nindent 12 }}
startupProbe:
{{- include "sure.webStartupProbe" .Values.sure.web.startupProbe | nindent 12 }}
resources:
{{- include "sure.webResources" .Values.sure.web.resources | nindent 12 }}
securityContext:
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.web.securityContext "path" "sure.web.securityContext") | nindent 12 }}
volumeMounts:
# Active Storage's local disk service, i.e. every uploaded file and
# import, lives here (config/storage.yml: Rails.root.join("storage")).
- name: storage
mountPath: /rails/storage
# Writable paths, so readOnlyRootFilesystem: true can stay on.
# Each container gets its own emptyDirs: nothing has to be shared
# between the two processes, and a shared scratch directory would
# only create coupling nobody asked for.
#
# /tmp: Rack writes multipart uploads (CSV imports, attachments)
# here before Active Storage ever sees them. The app does not use
# this path directly, though - TMPDIR points at the private
# subdirectory the init container creates inside it, because Ruby
# rejects a world-writable emptyDir as a temporary directory.
- name: web-tmp
mountPath: /tmp
# /rails/tmp: Rails' own scratch directory - the Bootsnap cache,
# Puma's pidfile when PIDFILE is set, and the combined CA bundle the
# 00_ssl initializer writes when SSL_CA_FILE is given. Note the
# trade-off, read off the Dockerfile: the image ships a Bootsnap
# cache precompiled at build time under this path, and mounting an
# emptyDir over it hides that cache, so every pod start recompiles
# it. Boot time cost, not correctness; the alternative is a
# writable image layer, which the hardening standard rules out.
- name: web-rails-tmp
mountPath: /rails/tmp
# /rails/log: production logs go to STDOUT (config/environments/
# production.rb), so under normal operation nothing is written here
# at all. It stays writable because Rails' default logger path is
# log/<env>.log and any rake or console path that falls back to it
# would otherwise hit a read-only filesystem. Not measured.
- name: web-rails-log
mountPath: /rails/log
# The Sidekiq process. Same image, different command - and because that
# command is NOT "./bin/rails server", the image entrypoint skips
# db:prepare here, so the two containers never migrate against each
# other.
#
# args, NOT command, and that distinction is load-bearing: Kubernetes'
# "command" replaces the image's ENTRYPOINT, which here is
# /rails/bin/docker-entrypoint - the script that preloads jemalloc.
# Upstream counts jemalloc among the three things that make the
# documented memory figures hold, so replacing the entrypoint would
# silently give this container a different memory profile than the one
# its requests are sized for. "args" replaces only the CMD, which is
# what upstream's compose file does too.
- name: {{ .Release.Name }}-{{ .Chart.Name }}-worker
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
args:
- bundle
- exec
- sidekiq
env:
{{- include "sure.commonEnv" . | nindent 12 }}
{{- include "sure.env" (dict "root" $ "env" .Values.sure.worker.env) | nindent 12 }}
livenessProbe:
{{- include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "sure.workerReadinessProbe" .Values.sure.worker.readinessProbe | nindent 12 }}
startupProbe:
{{- include "sure.workerStartupProbe" .Values.sure.worker.startupProbe | nindent 12 }}
resources:
{{- include "sure.workerResources" .Values.sure.worker.resources | nindent 12 }}
securityContext:
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.worker.securityContext "path" "sure.worker.securityContext") | nindent 12 }}
volumeMounts:
# The worker reads and writes the same Active Storage files as the
# web process (imports are processed as background jobs), which is
# why upstream's compose file mounts one volume into both and why
# both containers live in one pod here: a single ReadWriteOnce PVC
# cannot be attached to two pods.
- name: storage
mountPath: /rails/storage
- name: worker-tmp
mountPath: /tmp
- name: worker-rails-tmp
mountPath: /rails/tmp
- name: worker-rails-log
mountPath: /rails/log
restartPolicy: Always
volumes:
- name: storage
persistentVolumeClaim:
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
- name: web-tmp
emptyDir: {}
- name: web-rails-tmp
emptyDir: {}
- name: web-rails-log
emptyDir: {}
- name: worker-tmp
emptyDir: {}
- name: worker-rails-tmp
emptyDir: {}
- name: worker-rails-log
emptyDir: {}
+234
View File
@@ -0,0 +1,234 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "sure values",
"description": "Strict values schema (issue #68): additionalProperties is 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. The objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotations) stay open, because the chart does not interpret their contents. The hardening, probe and resources blocks additionally accept null - the chart's null-safe helpers then fall back to ITS defaults (issue #99). The schema adds to required(), it does not replace it.",
"type": "object",
"additionalProperties": false,
"properties": {
"global": {
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"scaleDown": {
"type": "boolean"
},
"secrets": {
"type": "object",
"additionalProperties": false,
"properties": {
"appSecretRef": {
"description": "op:// item path of the app secret. Field: secret-key-base (SECRET_KEY_BASE).",
"type": ["string", "null"]
},
"dbSecretRef": {
"description": "op:// item path of the PostgreSQL credentials. Fields: host, port, database, username, password.",
"type": ["string", "null"]
},
"redisSecretRef": {
"description": "op:// item path of the Redis connection. Field: connection-url (REDIS_URL).",
"type": ["string", "null"]
}
}
},
"sure": {
"type": "object",
"additionalProperties": false,
"properties": {
"replicas": {
"description": "Replicas of the StatefulSet; both containers scale with it, because they share one ReadWriteOnce PVC.",
"type": "integer",
"minimum": 0
},
"securityContext": {
"description": "Pod-level hardening, shared by both containers. May be null / empty: the chart then falls back to ITS defaults (uid/gid 1000, matching the image's USER 1000:1000) instead of rendering an unhardened pod.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"pod": {
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
}
}
},
"web": {
"type": "object",
"additionalProperties": false,
"properties": {
"securityContext": {
"$ref": "#/definitions/containerSecurityContext"
},
"resources": {
"$ref": "#/definitions/resources"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"env": {
"$ref": "#/definitions/env"
}
}
},
"worker": {
"type": "object",
"additionalProperties": false,
"properties": {
"securityContext": {
"$ref": "#/definitions/containerSecurityContext"
},
"resources": {
"$ref": "#/definitions/resources"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"env": {
"$ref": "#/definitions/env"
}
}
}
}
},
"storage": {
"description": "The Active Storage PVC, mounted at /rails/storage in both containers. Deliberately NOT null-safe: an erased block yields an invalid PVC, which fails loudly.",
"type": "object",
"additionalProperties": false,
"properties": {
"amount": {
"type": ["string", "null"]
},
"storageClass": {
"type": ["string", "null"]
}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the Ingress template (issue #93) and decides whether the chart renders RAILS_ASSUME_SSL/RAILS_FORCE_SSL as true or false. It does NOT free ingress.domain: that value is also read for APP_DOMAIN, WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS and keeps its own required() there (guideline 12.1).",
"type": "boolean"
},
"clusterIssuer": {
"type": ["string", "null"]
},
"domain": {
"type": ["string", "null"]
},
"className": {
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
"type": "object"
}
}
}
},
"definitions": {
"resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"limitFactor": {
"type": "number",
"exclusiveMinimum": 0
},
"requests": {
"$ref": "#/definitions/resourceList"
},
"limits": {
"$ref": "#/definitions/resourceList"
}
}
},
"resourceList": {
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"cpu": {
"type": ["string", "number", "null"]
},
"memory": {
"type": ["string", "number", "null"]
},
"ephemeral-storage": {
"type": ["string", "number", "null"]
},
"ephemeralStorage": {
"type": ["string", "number", "null"]
}
}
},
"env": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
},
"valueFrom": {
"type": "object",
"additionalProperties": false,
"properties": {
"secretKeyRef": {
"$ref": "#/definitions/keyRef"
},
"configMapKeyRef": {
"$ref": "#/definitions/keyRef"
}
}
}
}
}
},
"keyRef": {
"type": "object",
"additionalProperties": false,
"required": ["name", "key"],
"properties": {
"name": {
"type": "string"
},
"key": {
"type": "string"
}
}
},
"containerSecurityContext": {
"description": "May be null / empty: the chart then falls back to its container hardening defaults (issue #99) instead of rendering an unhardened container. The pod-level context lives one level up, under sure.securityContext.pod, because it is shared by both containers.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"container": {
"description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
}
}
},
"probe": {
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The chart's defaults carry the probe's context - the path for the web container, the exec command for the worker - not just its timings (guideline 12.5).",
"type": ["object", "null"]
}
}
}
+312
View File
@@ -0,0 +1,312 @@
# Scale the whole chart down without uninstalling it: scaleDown: true renders
# every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and
# takes precedence over any per-workload replicas value. PVCs, Secrets and
# Services stay in place; scaleDown: false brings the workloads back up.
scaleDown: false
# 1Password item paths (op://<vault>/<item>). The 1Password operator turns each
# OnePasswordItem into a Kubernetes Secret whose keys are the FIELD NAMES of the
# item; the chart references those field names, so a renamed field is a pod in
# CreateContainerConfigError, not a render error. NOTES.txt lists every field.
#
# Helm never sees the CONTENT of these secrets, so no checksum/config-style
# annotation can cover them (README, guideline 9). The pod template does carry a
# checksum/secret-ref over the rendered OnePasswordItems, so REPOINTING one of
# the *SecretRef values at a different item restarts the pod; editing a value
# INSIDE the same item does not, and needs a manual
# kubectl rollout restart statefulset/<release>-sure-sfs
# The operator's operator.1password.io/auto-restart annotation is deliberately
# NOT set: it only ever restarts Deployments, so on this StatefulSet it would
# advertise a guarantee that does not exist (README, guideline 9).
secrets:
# SECRET_KEY_BASE (field: secret-key-base)
appSecretRef: null
# PostgreSQL connection (fields: host, port, database, username, password)
dbSecretRef: null
# Redis connection URL (field: connection-url)
redisSecretRef: null
sure:
# Replica count for the StatefulSet; 0 is allowed and scales it down. The
# chart-global scaleDown flag takes precedence.
#
# Both containers live in the SAME pod, so this value scales web and worker
# together. That is a consequence of the single ReadWriteOnce PVC they share
# at /rails/storage (Active Storage): splitting them into two workloads would
# need a ReadWriteMany volume. Above 1 the extra pods normally stay Pending on
# "Multi-Attach error for volume" - see NOTES.txt.
replicas: 1
# Pod-level hardening, shared by both containers and by the init container.
# The upstream image declares USER 1000:1000 (group and user "rails", created
# in the Dockerfile), so the chart needs no deviation from the repo hardening
# standard; fsGroup 1000 makes the storage PVC writable for that uid.
#
# readOnlyRootFilesystem: true holds, and the writable paths are emptyDirs:
# /rails/tmp, /rails/log and /tmp, one set per container. /tmp needs one extra
# step that is easy to mistake for decoration - a tiny init container creates
# two private subdirectories in it, and the containers run with TMPDIR and
# HOME pointing there. Ruby refuses a world-writable directory without a
# sticky bit as its temporary directory and an emptyDir is exactly that, so
# without this the app aborts during boot; HOME follows because the image's
# /home/rails is read-only and Bundler otherwise builds a throwaway home per
# invocation. The StatefulSet carries the measured evidence for both.
securityContext:
pod:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: Always
# RuntimeDefault applies the container runtime's default seccomp filter,
# which blocks the syscalls a normal workload never makes. It sits in the
# pod context on purpose: from here it covers every container in the pod
# - web and worker alike - including init containers, while a
# container-level seccompProfile would have to be repeated for each one
# (and silently missed on the next one added). A container that genuinely
# needs a different filter can still override it under
# <container>.securityContext.container, which wins over this value.
seccompProfile:
type: RuntimeDefault
# ---------------------------------------------------------------------------
# web - Puma, the container that also runs the database migrations
# ---------------------------------------------------------------------------
web:
securityContext:
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
resources:
# Limits are optional: memory and ephemeral-storage limits default to
# limitFactor x the request, an explicitly set limit always wins, and a
# CPU limit is only set when configured explicitly under "limits".
#
# The request follows upstream's own sizing note (docs/hosting/docker.md
# for 0.7.4): steady-state memory "around 352 MB", comfortable under a
# 512 MB limit, while "if you enable AI, run at least 1 GB". 512Mi request
# x limitFactor 3 = 1536Mi limit, which holds both the everyday figure and
# the AI recommendation without reserving the larger number on the node.
# Read from upstream's documentation, not measured here.
#
# ephemeral-storage is NOT the template default of 10Mi, and the reason is
# measured: with 10Mi (limit 30Mi) per container the kubelet evicted the
# pod within two minutes of every start - "Pod ephemeral local storage
# usage exceeds the total limit of containers 60Mi". Both emptyDirs and
# the container's stdout log count against that limit, and this image
# rebuilds its Bootsnap cache under /rails/tmp on every pod start because
# the emptyDir hides the one baked into the image. 256Mi request (768Mi
# limit) is headroom for that, not a measurement of it - the steady-state
# figure is still unknown.
limitFactor: 3
requests:
cpu: 0.1
memory: 512Mi
ephemeral-storage: 256Mi
# PROBES - what each one actually proves (README, guideline 12.3)
#
# /up is Rails' own health route (config/routes.rb: get "up" =>
# "rails/health#show"). READ FROM THE RAILS SOURCE, NOT MEASURED HERE: that
# controller renders 200 as soon as the app has booted without raising and
# touches NO database and NO Redis. It is a boot check, and the widespread
# belief that it tests the database is exactly what guideline 12.3 exists to
# catch - upstream's own Helm chart (charts/sure in the app repository) uses
# /up for readiness, which is the mistake this chart does not copy.
#
# Liveness therefore uses /up: a Postgres outage must not restart the pod.
livenessProbe:
httpGet:
path: /up
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness asks for "/" instead, because that path provably reaches the
# database - and it does so through curl rather than an httpGet probe, for
# a reason that was measured rather than guessed (see below).
#
# Why "/" reaches the database: every request runs ApplicationController's
# authenticate_user! before_action (app/controllers/concerns/
# authentication.rb); without a session cookie - which a probe never has -
# it falls into self_hosted_first_login?, i.e.
# "config.app_mode.self_hosted? && User.count.zero?". User.count is an SQL
# query. Measured in the install-test: against an empty database the app
# answered 302 to /registration/new, which is the branch that query decides,
# so the path demonstrably touches Postgres. With the database down the
# query raises and Rails answers 500.
#
# Why exec + curl and not httpGet: the kubelet's HTTP prober FOLLOWS
# redirects to the same host, and with RAILS_ASSUME_SSL=true (which the
# chart sets whenever the Ingress is on) Rails builds that redirect as
# https://<pod-ip>:3000/... - a scheme Puma does not speak. The install-test
# failed with "http: server gave HTTP response to HTTPS client" on a pod
# that was answering correctly. curl without -L asks once and reports the
# status code; the test accepts 2xx and 3xx exactly as the kubelet would,
# and a 500 from a missing database still fails. curl is part of the image
# (the Dockerfile installs it), so nothing extra is pulled in for this.
#
# One condition this depends on: SELF_HOSTED=true, which the chart always
# sets. In managed mode the same code short-circuits before User.count and
# "/" would redirect without touching Postgres at all.
readinessProbe:
exec:
command:
- /bin/bash
- -c
- "code=\"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/)\"; test \"$code\" -ge 200 -a \"$code\" -lt 400"
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 3
# The startup probe gates liveness and readiness, so neither needs an
# initialDelaySeconds. Its budget has to cover the first start, which is the
# expensive one: the image entrypoint (/rails/bin/docker-entrypoint) runs
# "./bin/rails db:prepare" whenever the command is "./bin/rails server", so
# the web container creates and migrates the schema before Puma binds the
# port. Budget 10s x 60 = 600s. Raise it before a large app-version jump;
# a restart in the middle of a migration is the failure mode it prevents.
startupProbe:
httpGet:
path: /up
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 60
# Extra environment variables, appended to the ones the chart always sets.
# Values are rendered through tpl; value, secretKeyRef and configMapKeyRef
# forms are supported.
#
# This is also where the AI features go - the chart models none of them as
# values on purpose. OPENAI_ACCESS_TOKEN (secretKeyRef form), OPENAI_MODEL,
# OPENAI_URI_BASE, LLM_CONTEXT_WINDOW, OPENAI_REQUEST_TIMEOUT,
# AI_RESPONSE_TIMEOUT, ASSISTANT_MAX_TOOL_CALL_ITERATIONS and the SMTP_*
# block are plain environment variables to this image. Set them on BOTH
# containers where a background job needs them as well (the assistant runs
# in the worker).
#
# Additive means additive: an entry repeating a name the chart already sets
# reaches the pod twice at install time (the later one, yours, wins) but is
# LOST on a later helm upgrade, because Kubernetes merges env by name and
# the pair collapses to the chart's own value - with a green upgrade either
# way (README, guideline 10).
env: []
# ---------------------------------------------------------------------------
# worker - Sidekiq: the same image, started with "bundle exec sidekiq" as
# its args (the image entrypoint stays in place; see the StatefulSet)
# ---------------------------------------------------------------------------
worker:
securityContext:
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
resources:
# Same sizing as the web container, for a documented reason: upstream
# notes that the sample/demo-data generator runs in the WORKER and is the
# one operation that deterministically exceeds 512 MB (observed flat at
# ~680 MB on a 2 GB container). The computed limit of 3 x 512Mi = 1536Mi
# covers that; the request stays at the everyday figure.
#
# ephemeral-storage: same 256Mi as the web container and for the same
# measured reason - see the comment there.
limitFactor: 3
requests:
cpu: 0.1
memory: 512Mi
ephemeral-storage: 256Mi
# PROBES - the worker speaks no HTTP at all, so all three are exec probes.
#
# The command asks Sidekiq's own monitor whether THIS process is in the
# process set: sidekiqmon prints one line per registered process, each
# starting with the identity "<hostname>:<pid>:<nonce>" (sidekiq 8.0.5,
# lib/sidekiq/component.rb), and in a pod the hostname is the pod name that
# bash exposes as $HOSTNAME. Sidekiq keeps that registration alive with a
# heartbeat whose Redis key expires after about 60s, so a process that hangs
# disappears from the set while its PID still exists - which is precisely
# what a process check like "pgrep sidekiq" could not tell you (and would
# not tell you anything at all: Sidekiq is PID 1 here, so if it dies the
# container dies with it and the check can never fail - a check a broken
# system passes, README).
#
# READ FROM THE SIDEKIQ 8.0.5 SOURCE, NOT MEASURED: sidekiqmon is a plain
# Ruby entry point that requires sidekiq/api only - it does NOT boot Rails -
# and it reads REDIS_URL from the environment, which the chart sets on this
# container. Cost per check is therefore one Ruby start, not an application
# boot; the periods below are chosen on the assumption that this takes a few
# seconds, and the PR install-test is the first place it actually runs.
#
# Liveness hangs on Redis, which guideline 12.3 allows only where no
# dependency-free path proves anything - that is the case here (see above) -
# and it is given a deliberately generous budget of 60s x 5 = 300s of
# missing heartbeat, so a short Redis blip does not restart the pod.
livenessProbe:
exec:
command:
- /bin/bash
- -c
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
periodSeconds: 60
timeoutSeconds: 15
failureThreshold: 5
# Readiness uses the same check with a tighter budget: a worker that is not
# registered in Redis is not doing any work, whatever its process state.
readinessProbe:
exec:
command:
- /bin/bash
- -c
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 3
# The startup probe gates the other two. Sidekiq boots the full Rails app
# before it registers, and on a first install it waits for the web container
# to finish db:prepare, so the budget matches the web container's: 20s x 30
# = 600s.
startupProbe:
exec:
command:
- /bin/bash
- -c
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
periodSeconds: 20
timeoutSeconds: 15
failureThreshold: 30
# Extra environment variables for the worker, appended to the ones the chart
# always sets. Same rules as sure.web.env above.
env: []
# The Active Storage volume, mounted at /rails/storage in BOTH containers.
# Deliberately not null-safe: an erased block yields an invalid PVC, which fails
# loudly (README, guideline 12).
storage:
amount: 10Gi
storageClass: null
ingress:
# Gates the whole Ingress template. Note that ingress.domain is read three
# more times outside it - APP_DOMAIN (the host Action Mailer builds link URLs
# from), WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS (the relying party a
# passkey is bound to) - so it keeps its own required() there and stays
# mandatory with the Ingress switched off; only clusterIssuer becomes
# unnecessary (README, guideline 12.1). Without that guard the app would come
# up with WEBAUTHN_RP_ID="" and register passkeys against the wrong relying
# party - rendered cleanly, pod green.
#
# ingress.enabled also decides how the app is told about TLS: enabled renders
# RAILS_ASSUME_SSL=true and RAILS_FORCE_SSL=true (the Ingress terminates TLS,
# so Rails must trust that and may set secure cookies and HSTS), disabled
# renders both as "false" so a plain-HTTP port-forward can log in at all.
enabled: true
clusterIssuer: null
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
annotations: {}
+2 -2
View File
@@ -5,5 +5,5 @@ icon: https://avatars.githubusercontent.com/u/136759148
type: application
version: 5.1.0+up6.0.0.beta12.1
appVersion: "6.0.0-beta12.1"
version: 5.1.3+up6.0.0.beta13.1
appVersion: "6.0.0-beta13.1"
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "teamspeak-server.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "teamspeak-server.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "teamspeak-server.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+8 -6
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "xxx.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "xxx.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,9 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies. This file is the reference
implementation.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14). This file is the reference implementation.
*/}}
{{- define "xxx.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
+2 -2
View File
@@ -4,5 +4,5 @@ description: A Helm chart to deploy urlaubsverwaltung on Kubernetes
icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png
type: application
version: 9.0.2+up6.12.0
appVersion: 6.12.0
version: 10.0.1+up6.13.1
appVersion: 6.13.1
+8 -5
View File
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
resources:
{{- include "urlaubsverwaltung.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. This file in template-chart is the reference implementation.
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. This file in template-chart is the reference
implementation.
*/}}
{{- define "urlaubsverwaltung.resources" -}}
{{- $resources := . | default dict -}}
@@ -168,8 +170,9 @@ Rules:
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "urlaubsverwaltung.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
@@ -3,4 +3,4 @@ kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
spec:
itemPath: {{ .Values.secrets.databaseSecretRef }}
itemPath: {{ required "A database Secret Reference must be provided (secrets.databaseSecretRef)" .Values.secrets.databaseSecretRef }}
@@ -3,4 +3,4 @@ kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-oidc-secret
spec:
itemPath: {{ .Values.secrets.oidcSecretRef }}
itemPath: {{ required "An OIDC Secret Reference must be provided (secrets.oidcSecretRef)" .Values.secrets.oidcSecretRef }}
+2
View File
@@ -17,9 +17,11 @@
"additionalProperties": false,
"properties": {
"databaseSecretRef": {
"description": "op:// item path carrying \"jdbc-url\", \"username\" and \"password\". Required - the chart deploys no database and all state lives in the external one. Enforced by required() in the template, not here: the null default keeps this type widened, and a schema \"required\" entry could never fire against a key that is present with value null.",
"type": ["string", "null"]
},
"oidcSecretRef": {
"description": "op:// item path carrying \"client-id\", \"client-secret\", \"issuer-name\", \"issuer-uri\", \"jwt-issuer-uri\" and \"redirect-uri\". Required - the chart offers no non-OIDC login and the app resolves the issuer at boot. Enforced by required() in the template, not here: the null default keeps this type widened, and a schema \"required\" entry could never fire against a key that is present with value null.",
"type": ["string", "null"]
}
}
+14
View File
@@ -5,7 +5,21 @@
scaleDown: false
secrets:
# REQUIRED - op:// item path carrying "jdbc-url", "username" and "password".
# This chart deploys NO database; all state lives in the external one, and
# three datasource environment variables read these keys unconditionally.
# Without the value the render aborts - required() in
# urlaubsverwaltung-db.secret.yaml is the whole enforcement, and
# values.schema.json cannot help here (README, guideline 12).
databaseSecretRef: null
# REQUIRED - op:// item path carrying "client-id", "client-secret",
# "issuer-name", "issuer-uri", "jwt-issuer-uri" and "redirect-uri". This chart
# offers no login path besides OIDC, and the identity provider is required at
# boot, not at login: the application resolves <issuer-uri>/.well-known/
# openid-configuration while building its security beans, before the HTTP port
# opens, and exits when it cannot. Without the value the render aborts -
# required() in urlaubsverwaltung-oidc.secret.yaml is the whole enforcement,
# and values.schema.json cannot help here (README, guideline 12).
oidcSecretRef: null
ingress:
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+33
View File
@@ -0,0 +1,33 @@
apiVersion: v2
name: valkey
description: A Helm chart to deploy a single Valkey instance (cache or queue backend) for one application on Kubernetes
icon: https://avatars.githubusercontent.com/u/164458127
type: application
# appVersion is the exact image tag, "9.1.2-alpine" - the version AND the base
# flavour, so the "+up" suffix carries both with the dash normalized to a dot.
#
# Why "-alpine" and not the fully numbered "-alpine3.24" (Gitea #46, third plan
# comment): neither tag is immutable. Upstream rebuilds the whole tag matrix
# periodically, and the build timestamps prove it - "9.1-alpine" and
# "9.0.6-alpine3.24" were pushed within minutes of each other on 2026-09-21
# although 9.0.6 is the older release line. Only a digest is immutable. So the
# choice was never "immutable vs. moving" but between two equally moving tags
# with different Renovate behaviour, and "-alpine" is the better one: docker
# versioning splits at the first dash, reads 9.1.2 as the version and "alpine"
# as the compatibility suffix, so the suffix stays constant forever and the
# update path can never freeze. With "-alpine3.24" it would: upstream builds
# exactly one Alpine base per Valkey release, so the next base arrives as
# "9.1.3-alpine3.25" - a candidate a suffix-bound rule never offers, and
# "no update" looks identical to a working rule.
#
# The price, named rather than discovered: a rebuild of 9.1.2-alpine changes the
# running image with no change in this repository and nothing reports it. The
# remedy if that ever matters is pinDigests for this one image - see the valkey
# block in renovate.json5.
version: 1.0.0+up9.1.2.alpine
appVersion: "9.1.2-alpine"
maintainers:
- name: jklein
+160
View File
@@ -0,0 +1,160 @@
{{- $replicas := include "valkey.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.valkey.replicas) -}}
{{- $limitBytes := include "valkey.memoryLimitBytes" .Values.valkey.resources -}}
{{- $maxmemory := include "valkey.maxmemoryBytes" (dict "resources" .Values.valkey.resources "maxmemory" .Values.valkey.maxmemory "maxmemoryFactor" .Values.valkey.maxmemoryFactor) -}}
Valkey {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
ACCESS — IN-CLUSTER ONLY
{{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }}.svc.cluster.local:6379
There is no Ingress and no value to add one: this is one application's cache
or queue backend, and its only authentication is the password. Applications
address it with a redis:// URL (Valkey speaks the same protocol, and every
client library still calls the scheme redis://):
redis://:<password>@{{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }}.svc.cluster.local:6379/0
The password belongs in the CONSUMING application's own vault item, where its
connection URL lives. Nothing here writes it there.
Check by hand from inside the namespace, with the password out of the pod's own
mounted file so it never reaches your shell history:
kubectl -n {{ .Release.Namespace }} exec {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0 -- /bin/sh /etc/valkey/ping.sh && echo OK
THE 1PASSWORD ITEM MUST CARRY ONE FIELD, AND ITS NAME IS PART OF THE CHART
secrets.authSecretRef points at
{{ .Values.secrets.authSecretRef }}
The 1Password operator materializes that item as the Kubernetes Secret
{{ .Release.Name }}-{{ .Chart.Name }}-secret, with one Secret key per field of the item. This chart
reads exactly one field:
auth-conf a COMPLETE Valkey configuration line, not the bare
password:
requirepass <the password>
A differently named field produces no such key, and the pod then stays in
ContainerCreating with "couldn't find key auth-conf" in its events.
Why a whole configuration line instead of the password: the file is mounted and
"include"d by the configuration, so the password never appears in the process
list, in an environment variable, or in anything Helm renders. The cost is that
a typo in that line would be a Valkey WITHOUT a password — running, healthy,
and reachable from the whole pod network, because the official image compiles
protected-mode off. An init container therefore checks the mounted file for a
"requirepass " line before the server starts and refuses to let the pod run
otherwise. If the pod is stuck in Init:Error or Init:CrashLoopBackOff, read it:
kubectl -n {{ .Release.Namespace }} logs {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0 -c {{ .Release.Name }}-{{ .Chart.Name }}-check-auth
It never prints the field's contents.
Watch the materialization with
kubectl -n {{ .Release.Namespace }} get onepassworditem {{ .Release.Name }}-{{ .Chart.Name }}-secret
kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
THIS RELEASE IS CONFIGURED AS A {{ if .Values.persistence.enabled }}{{ if eq .Values.valkey.maxmemoryPolicy "noeviction" }}QUEUE{{ else }}PERSISTENT CACHE{{ end }}{{ else }}{{ if eq .Values.valkey.maxmemoryPolicy "noeviction" }}VOLATILE STORE — PROBABLY BY MISTAKE{{ else }}CACHE{{ end }}{{ end }}
persistence.enabled: {{ .Values.persistence.enabled }}
valkey.maxmemoryPolicy: {{ .Values.valkey.maxmemoryPolicy }}
memory limit (computed): {{ include "valkey.humanBytes" $limitBytes }} ({{ $limitBytes }} bytes)
maxmemory: {{ include "valkey.humanBytes" $maxmemory }} ({{ $maxmemory }} bytes){{ if kindIs "invalid" .Values.valkey.maxmemory }}, derived as maxmemoryFactor {{ .Values.valkey.maxmemoryFactor | default 0.75 }} x the limit{{ else }}, set explicitly in values and checked against the limit{{ end }}
{{ if .Values.persistence.enabled }}
{{- if eq .Values.valkey.maxmemoryPolicy "noeviction" }}
The combination this chart is built for. When memory runs out Valkey REFUSES
WRITES instead of discarding keys, so a full queue is an error in the
application rather than jobs that quietly disappeared.
{{- else }}
Persistence with an evicting policy: the data survives a restart but not
memory pressure. Fine for a cache that is expensive to rebuild — wrong for a
Sidekiq or Celery queue, which would lose jobs while staying green.
{{- end }}
{{- else }}
{{- if eq .Values.valkey.maxmemoryPolicy "noeviction" }}
READ THIS: no persistence AND noeviction is almost never intended. The data is
gone on every pod restart, so nothing is being protected — but once memory is
full, writes start FAILING instead of cold keys being dropped. For a cache set
valkey.maxmemoryPolicy: allkeys-lru; for a queue set persistence.enabled: true.
{{- else }}
Cache shape: the dataset lives in the pod only. Both AOF and the RDB save
points are switched off, so a restart starts from empty and nothing is written
to ephemeral storage.
{{- end }}
{{- end }}
{{- if .Values.persistence.enabled }}
STORAGE
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, {{ .Values.persistence.size }}, {{ if .Values.persistence.storageClass }}storage class "{{ .Values.persistence.storageClass }}"{{ else }}on the cluster's default storage class
(persistence.storageClass is unset — the default class is not encrypted){{ end }}.
Persistence mode: {{ if .Values.persistence.appendonly }}append-only file (every write appended; a crash loses at most
the last fsync interval){{ else }}RDB snapshots only (a crash loses every write since the last
snapshot, silently — the instance comes back healthy, only shorter){{ end }}.
helm uninstall does NOT delete this PVC, which is deliberate — the data
survives a reinstall of the same release name. It also means a release removed
and recreated under a DIFFERENT name starts on an empty volume while the old
one keeps occupying space. On encrypted Longhorn volumes that space is never
returned to the pool.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}valkey.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers on
the address above and every application configured against it will fail its own
checks. Nothing else is removed: Service and Secret stay{{ if .Values.persistence.enabled }}, the PVC keeps
the data{{ end }}{{ if .Values.networkPolicy.enabled }}, and the NetworkPolicy stays in force{{ end }}.
{{ end }}
CHANGING THE PASSWORD IS NOT COVERED BY A CHECKSUM
The pod template carries checksum/config over the configuration ConfigMap and
checksum/secret-ref over the rendered OnePasswordItem. So a changed
maxmemory/policy rolls the pod, and so does repointing secrets.authSecretRef at
a DIFFERENT vault item. What cannot be covered is a new password inside the
same item: Helm never sees the content of a Secret the 1Password operator
fills in. After changing it there:
kubectl -n {{ .Release.Namespace }} rollout restart statefulset {{ .Release.Name }}-{{ .Chart.Name }}-sfs
(operator.1password.io/auto-restart is not set on purpose — the operator
restarts Deployments only, so on a StatefulSet it would be a silent no-op.)
Unverified, and named so rather than promised: because the probes read the
password out of the same mounted file, the kubelet's refresh of that file
(roughly a minute) should make the liveness probe fail against the still-old
server and restart the container by itself. Do the rollout restart instead of
waiting for it.
{{ if .Values.networkPolicy.enabled }}
NETWORK POLICY IS ACTIVE
Ingress to port 6379 is allowed from namespace {{ .Release.Namespace }}{{ if .Values.networkPolicy.allowedNamespaces }} and from
{{ join ", " .Values.networkPolicy.allowedNamespaces }}{{ end }}. Egress is unrestricted (this instance opens no
outbound connection of its own).
It is only a boundary where the CNI enforces NetworkPolicies. Where it does
not, the object is accepted and nothing is filtered — so verify it instead of
reading it off the manifest:
kubectl -n <some-other-namespace> run t --rm -it --image=docker.io/valkey/valkey:{{ .Chart.AppVersion }} \
-- valkey-cli -h {{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }} ping
That must hang or be refused. "NOAUTH Authentication required." means the
policy is not being enforced and the password is your only boundary.
{{- else }}
NO NETWORK POLICY
networkPolicy.enabled is false, so the password is the only thing between this
instance and every pod in the cluster. The official image compiles
protected-mode off, so there is no second line of defence behind it.
{{- end }}
WHAT A GREEN POD PROVES
All three probes run "valkey-cli -e ping" AUTHENTICATED with the password from
the mounted file and require the answer PONG. So Ready means: the server
accepted a connection, accepted that password, and answered.
Two things it does not mean. It does not say anything about the dataset — a
restarted instance whose AOF was lost answers PONG on an empty database. And
it does not say your application's keys are reachable: that depends on the
connection URL in the application's own vault item, which this chart neither
writes nor reads.
The probe asserts the answer (PONG), not just that the command returned, and
it passes "-e" so that valkey-cli reports an error reply through its exit code
at all — without it a bare "valkey-cli ping" exits 0 while printing
"NOAUTH Authentication required.", which would be a probe that cannot fail.
+434
View File
@@ -0,0 +1,434 @@
{{/*
Render the body of a container "resources:" block from a values dict of the form
resources:
limitFactor: 3 # optional, default 3
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
limits: { ... } # optional, every entry optional
Rules (variant B, see issue #19):
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
to the Kubernetes key "ephemeral-storage").
- an explicitly set limit always wins.
- a missing memory / ephemeral-storage limit is computed as
limitFactor x the corresponding request.
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
unlimited; CPU is compressible, so this cannot kill the workload).
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. template-chart holds the reference implementation, so a
fix belongs there as well and not here alone.
*/}}
{{- define "valkey.resources" -}}
{{- $resources := . | default dict -}}
{{- $factor := 3.0 -}}
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
{{- $factor = float64 $resources.limitFactor -}}
{{- end -}}
{{- $requests := dict -}}
{{- range $key, $value := ($resources.requests | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $requests (include "valkey.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "valkey.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "valkey.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
{{- end -}}
{{- end -}}
{{- $out := dict -}}
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Normalize a resource name from values to its Kubernetes key.
*/}}
{{- define "valkey.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer
results are printed without a decimal point.
*/}}
{{- define "valkey.multiplyQuantity" -}}
{{- $quantity := include "valkey.formatNumber" .quantity -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
{{- end -}}
{{- $product := mulf (float64 $number) .factor -}}
{{- if eq $product (floor $product) -}}
{{- printf "%d%s" (int64 $product) $suffix -}}
{{- else -}}
{{- printf "%s%s" (include "valkey.formatNumber" $product) $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render a number as a plain decimal string, never in exponential notation.
Why this exists (issue #157): 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 became "1.048576e+06" - not a valid Kubernetes quantity, and the
render aborted. Strings ("512Mi") are passed through untouched.
*/}}
{{- define "valkey.formatNumber" -}}
{{- if kindIs "float64" . -}}
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
{{- else -}}
{{- printf "%v" . -}}
{{- end -}}
{{- end -}}
{{/*
Render the replica count for a Deployment/StatefulSet.
Input: dict
"scaleDown" the chart-global .Values.scaleDown flag (may be nil)
"replicas" the workload's replicas value (may be nil)
Rules (issue #25):
- scaleDown: true takes precedence and always yields 0.
- otherwise an explicitly set replicas value is used as given, including an
explicit 0 — Helm's "default" would swallow 0, hence the explicit
kindIs "invalid" nil check.
- a missing replicas value defaults to 1.
*/}}
{{- define "valkey.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
1
{{- end -}}
{{- end -}}
{{/*
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
null / empty override falls back to the defaults instead of erasing them.
Input: dict "defaults" <dict> "given" <dict|nil> "path" <string, optional
values path used in error messages>. Output: YAML of the merged dict (consume
it with fromYaml).
Why this exists (issues #61 and #99): Helm merges values maps recursively, but
an override key written without children — e.g. a leftover
securityContext:
after deleting its last remaining sub-key — is YAML null. Where the chart
defines a default, Helm treats that null as a deletion of the default; the
template then renders "securityContext: null": no readOnlyRootFilesystem, no
dropped capabilities, no allowPrivilegeEscalation: false. The workload starts
and reports healthy while being unhardened, so nothing points at the mistake.
The same shape erases a probe (it disappears) or the resources block (no
requests, no computed limits). This helper defines the opposite semantics: an
empty block means "chart defaults apply".
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "valkey.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
{{- $given := .given -}}
{{- $path := .path | default "value" -}}
{{- if kindIs "invalid" $given -}}
{{- $given = dict -}}
{{- end -}}
{{- if not (kindIs "map" $given) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
{{- end -}}
{{- $out := deepCopy $defaults -}}
{{- range $key, $value := $given -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $default := index $defaults $key -}}
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
{{- $_ := set $out $key (fromYaml (include "valkey.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
{{- else if kindIs "map" $default -}}
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
{{- else -}}
{{- $_ := set $out $key $value -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Pick one sub-block out of a values block that may itself be null.
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
non-mapping block with a message that names the values path. Needed because
".Values.valkey.securityContext" can itself be erased, and indexing into nil
inside a template is not an error but silently yields nothing.
*/}}
{{- define "valkey.subBlock" -}}
{{- $block := .block -}}
{{- if kindIs "invalid" $block -}}
{{- $block = dict -}}
{{- end -}}
{{- if not (kindIs "map" $block) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
{{- end -}}
{{- toYaml (dict "value" (index $block .key)) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
The defaults below MUST stay in sync with the corresponding blocks in
values.yaml, which stays the documented, user-facing place for them; this copy
is only the fallback for the case where an override has erased the block. Every
reason behind a value is written down there.
Two rules these fallbacks exist to honour (README, "Chart style guidelines"):
- Reproduce THIS chart's defaults, not the repo standard. The uid here is 999
and the gid 1000, because that is the "valkey" user of the official image
(9.1/alpine/Dockerfile: "addgroup -S -g 1000 valkey; adduser -S -G valkey
-u 999 valkey"). Restoring the repo's usual 20000/1000 would leave the
process unable to write its own data directory, and fsGroup has to keep
matching runAsGroup or the PVC is not writable at all.
- Carry the probe's CONTEXT, not just its timings. All three probes here are
exec probes, so the command IS the probe - a fallback that lost it would
leave the workload with no working check at all.
*/}}
{{- define "valkey.podSecurityContext" -}}
{{- $given := (fromYaml (include "valkey.subBlock" (dict "block" . "key" "pod" "path" "valkey.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 999
"runAsGroup" 1000
"fsGroup" 1000
"fsGroupChangePolicy" "Always"
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" $given "path" "valkey.securityContext.pod") -}}
{{- end -}}
{{- define "valkey.containerSecurityContext" -}}
{{- $given := (fromYaml (include "valkey.subBlock" (dict "block" . "key" "container" "path" "valkey.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" $given "path" "valkey.securityContext.container") -}}
{{- end -}}
{{/*
The three probes. All of them run the same script from the chart's ConfigMap,
which authenticates with the password from the mounted auth file and asserts the
answer PONG - see templates/valkey.configmap.yaml for why "-e" and
VALKEYCLI_AUTH are what turn this from a formality into a check.
*/}}
{{- define "valkey.probeCommand" -}}
{{- list "/bin/sh" "/etc/valkey/ping.sh" | toJson -}}
{{- end -}}
{{- define "valkey.livenessProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "valkey.probeCommand" . | fromJsonArray))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.livenessProbe") -}}
{{- end -}}
{{- define "valkey.readinessProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "valkey.probeCommand" . | fromJsonArray))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.readinessProbe") -}}
{{- end -}}
{{- define "valkey.startupProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "valkey.probeCommand" . | fromJsonArray))
"periodSeconds" 5
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.startupProbe") -}}
{{- end -}}
{{/*
The merged resources block: the chart's defaults with the user's overrides on
top, null-safe. Everything that needs the effective numbers goes through this,
so the memory limit the configuration derives maxmemory from is the SAME number
the container is given - not a second calculation that can drift from it.
*/}}
{{- define "valkey.mergedResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.1 "memory" "256Mi" "ephemeral-storage" "64Mi") -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.resources") -}}
{{- end -}}
{{- define "valkey.effectiveResources" -}}
{{- include "valkey.resources" (fromYaml (include "valkey.mergedResources" .)) -}}
{{- end -}}
{{/*
Convert a Kubernetes memory quantity to a plain byte count.
Input: a quantity (string or number). Output: the byte count as an integer
string. Needed because Valkey's maxmemory is compared against, and derived
from, the container's memory limit - and "576Mi" cannot be compared with
"1Gi" as text.
The "m" (milli) suffix is rejected rather than converted: a milli-byte memory
quantity is legal Kubernetes and meaningless here, and silently rounding it to
zero would render "maxmemory 0", which in Valkey means UNLIMITED - the exact
opposite of what this chart computes a number for.
*/}}
{{- define "valkey.quantityBytes" -}}
{{- $quantity := include "valkey.formatNumber" . -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse memory quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- $factors := dict
"" 1.0
"k" 1000.0 "M" 1000000.0 "G" 1000000000.0 "T" 1000000000000.0
"Ki" 1024.0 "Mi" 1048576.0 "Gi" 1073741824.0 "Ti" 1099511627776.0 -}}
{{- if not (hasKey $factors $suffix) -}}
{{- fail (printf "unsupported memory quantity suffix %q in %q (use one of k, M, G, T, Ki, Mi, Gi, Ti, or plain bytes)" $suffix $quantity) -}}
{{- end -}}
{{- printf "%d" (int64 (mulf (float64 $number) (index $factors $suffix))) -}}
{{- end -}}
{{/*
The container's effective memory limit, as a byte count.
It is read back out of the RENDERED resources block on purpose: an explicitly
set limits.memory wins there, and a missing one is limitFactor x the request -
so this cannot disagree with what the container actually gets.
*/}}
{{- define "valkey.memoryLimitBytes" -}}
{{- $block := fromYaml (include "valkey.effectiveResources" .) -}}
{{- $limit := "" -}}
{{- if $block.limits -}}
{{- $limit = index $block.limits "memory" -}}
{{- end -}}
{{- if kindIs "invalid" $limit -}}
{{- fail "valkey: no memory limit could be determined. valkey.resources needs either requests.memory (the limit is then limitFactor x the request) or an explicit limits.memory - maxmemory is derived from it, and an unbounded instance would be OOM-killed instead of evicting." -}}
{{- end -}}
{{- include "valkey.quantityBytes" $limit -}}
{{- end -}}
{{/*
Valkey's maxmemory for this release, as a byte count.
Input: the .Values.valkey block.
- valkey.maxmemory set -> used as given, AFTER checking it against the
container's memory limit. A value above the limit ABORTS the render: it
would hand the decision "what happens when memory runs out" from the
eviction policy to the kubelet's OOM killer, and that arrives as a
restarted pod with no explanation. Not clamped, because a clamp would mean
the running instance quietly disagrees with the values file.
- otherwise -> maxmemoryFactor x the limit, floored. The
remaining fraction is not slack: client output buffers, the AOF buffer and
above all the copy-on-write pages of the fork() that snapshots or rewrites
the AOF live outside maxmemory.
*/}}
{{- define "valkey.maxmemoryBytes" -}}
{{- $limitBytes := int64 (include "valkey.memoryLimitBytes" .resources) -}}
{{- if and (not (kindIs "invalid" .maxmemory)) (ne (printf "%v" .maxmemory) "") -}}
{{- $wanted := int64 (include "valkey.quantityBytes" .maxmemory) -}}
{{- if le $wanted (int64 0) -}}
{{- fail (printf "valkey.maxmemory is %v, which converts to %d bytes. In Valkey, maxmemory 0 means UNLIMITED, so this would switch off the very bound the chart derives - leave valkey.maxmemory unset to use maxmemoryFactor x the memory limit instead." .maxmemory $wanted) -}}
{{- end -}}
{{- if gt $wanted $limitBytes -}}
{{- fail (printf "valkey.maxmemory is %v (%d bytes) and thus ABOVE the container's memory limit (%d bytes). Valkey would keep accepting data until the kubelet OOM-kills the pod, which is what the eviction policy exists to prevent. Lower valkey.maxmemory, or raise valkey.resources.requests.memory / limitFactor / limits.memory." .maxmemory $wanted $limitBytes) -}}
{{- end -}}
{{- printf "%d" $wanted -}}
{{- else -}}
{{- $factor := 0.75 -}}
{{- if not (kindIs "invalid" .maxmemoryFactor) -}}
{{- $factor = float64 .maxmemoryFactor -}}
{{- end -}}
{{- $derived := int64 (floor (mulf (float64 $limitBytes) $factor)) -}}
{{- if le $derived (int64 0) -}}
{{- fail (printf "valkey.maxmemoryFactor %v applied to the memory limit (%d bytes) yields %d bytes. Valkey reads maxmemory 0 as UNLIMITED, so this would silently remove the bound." .maxmemoryFactor $limitBytes $derived) -}}
{{- end -}}
{{- printf "%d" $derived -}}
{{- end -}}
{{- end -}}
{{/*
Render a byte count as a human-readable size, for NOTES.txt and comments only.
Never used where an exact value matters.
*/}}
{{- define "valkey.humanBytes" -}}
{{- $bytes := float64 . -}}
{{- if ge $bytes 1073741824.0 -}}
{{- printf "%.2fGi" (divf $bytes 1073741824.0) -}}
{{- else if ge $bytes 1048576.0 -}}
{{- printf "%.2fMi" (divf $bytes 1048576.0) -}}
{{- else if ge $bytes 1024.0 -}}
{{- printf "%.2fKi" (divf $bytes 1024.0) -}}
{{- else -}}
{{- printf "%d bytes" (int64 $bytes) -}}
{{- end -}}
{{- end -}}
{{/*
Additive environment entries for the Valkey container.
Input: dict "root" <root context> "env" <the env list>.
Entries are rendered through tpl, so a value may contain Helm templating, and
the value, secretKeyRef and configMapKeyRef forms are supported.
*/}}
{{- define "valkey.env" -}}
{{- $root := .root -}}
{{- range $value := .env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "6379" or "true" renders
# unquoted as a YAML number/bool, and the API server then rejects the whole
# manifest with "cannot unmarshal number into Go struct field EnvVar.value of
# type string".
value: {{ tpl $value.value $root | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end -}}
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more