Author SHA1 Message Date
jlab-renovate-bot 193475f093 Update dependency kubernetes/kubernetes to v1.37.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 / Build ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 11s
2026-10-02 22:11:33 +00:00
jlab-renovate-bot bf2e1009e4 Update docker.io/library/busybox Docker tag to v1.38.0
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 10s
SonarQube Analysis / SonarQube Analysis (push) Successful in 31s
2026-10-02 22:07:25 +00:00
jklein a9ca5302de feat(paperless-ngx): optional valkey subchart, password from the redis item
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 12s
Validate Helm Charts / Build paperless-ngx (pull_request) Successful in 6s
Validate Helm Charts / Trivy config paperless-ngx (pull_request) Successful in 22s
Install-test / Install-test paperless-ngx (pull_request) Successful in 3m31s
Validate Helm Charts / Install-test (pull_request) Successful in 3m31s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish Paperless-NGX Helm Chart / Publish Paperless-NGX Helm Chart (push) Successful in 10s
SonarQube Analysis / SonarQube Analysis (push) Successful in 33s
paperless-ngx cannot run without a Redis - PAPERLESS_REDIS is the Celery
broker - so every installation needed a second Helm release whose address
lived in a 1Password item and in neither chart. This adds our own published
valkey chart as an optional dependency, off by default and gated on
valkeySubchart.enabled (not valkey.enabled, which the subchart's strict
schema rejects and which Helm would only have complained about for whoever
first switched it on). Additive, default behaviour unchanged: minor,
12.1.3+up3.2.1 -> 12.2.0+up3.2.1.

Configured as a QUEUE (PVC + noeviction, the subchart's defaults), unlike
stalwart's cache. Read off the 3.2.1 source rather than assumed: one
PAPERLESS_REDIS url serves five consumers - the Celery broker, the result
backend (Redis in 3.x, the database before), the channels pub/sub layer and
two Django caches. allkeys-lru would not be a trade here but actively wrong,
because LRU evicts typeless across the whole keyspace and a backlog of
waiting consume tasks is exactly what it reads as cold; a dropped task is a
document silently never processed. The price, named in values.yaml rather
than smoothed over: two of the five consumers are caches, so a full instance
refuses writes - new tasks included - and the answer is memory sizing, not a
policy change. All three cache-like consumers are TTL-bounded, which is what
keeps that price small.

The password comes from the SAME item as the Redis connection, in a third
field auth-conf holding the complete line "requirepass <password>". It cannot
DEFAULT to secrets.redisSecretRef, and that is a property of Helm, not an
omission: a values.yaml is never templated and a parent cannot compute a
subchart's value. Measured - a template expression there lands verbatim in
the subchart's itemPath and fails as a YAML parse error naming neither the
value nor the cause.

Forgetting it is therefore caught in values.schema.json, by a conditional
rule rather than a fail() in a template. Also measured, and the reason:
Helm renders a subchart's templates BEFORE the parent's, so a guard in this
chart loses the race to the subchart's own required(), which reports
"secrets.authSecretRef" and cannot know the path belongs to the redis item.
A check that cannot fire is the mistake found while testing #77, so the
first draft of that guard was removed rather than shipped. The schema runs
before any rendering and names /valkey/secrets/authSecretRef.

One trap found while testing and closed here: "valkeySubchart: null" DELETES
the key, Helm then cannot resolve the condition path and treats the
dependency as ENABLED - so erasing the switch turned the subchart on with no
password set. Unlike every other block in this chart (issue #99), that one
is rejected by the schema instead of defaulting.

The install-test now runs with the subchart ON and the throwaway redis
Deployment removed: the url in the fixtures names ci-valkey-service, so the
bundled Valkey is the broker the release actually uses. #65 covered this
path by a render only - deliberately, since an unused Valkey would have made
that test look like it covered clustering. This is the first real
installation of the valkey-subchart path.

Documented, not glossed: the password now sits in that item twice and a
rotation has to touch both fields; with the subchart on the vault url is a
mandatory step, not a hint; and a OnePasswordItem materializes every field
of its item, so both Secrets carry all three keys - untidy, harmless, and
written down so nobody "tidies it up".

Fixes #78
2026-10-02 21:24:22 +00:00
jlab-renovate-bot a288ee231a Pin dependencies
SonarQube Analysis / SonarQube Analysis (push) Failing after 20s
2026-10-02 21:02:13 +00:00
jklein 07c75e80c1 feat(stalwart): take our own published valkey chart as the subchart
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 14s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 14s
SonarQube Analysis / SonarQube Analysis (push) Failing after 13s
Replaces the dependency on upstream's valkey chart with this repo's own,
resolved from the published index:

  version: "1.0.0+up9.1.2.alpine"
  repository: "https://git.jk-dev.de/api/packages/jlab-cluster/helm"

The condition is "valkeySubchart.enabled", NOT "valkey.enabled", and that is
forced rather than stylistic. Everything under "valkey:" is handed to the
subchart as its values and validated against ITS schema, and our valkey chart
ships additionalProperties: false with no "enabled" property - so
"valkey.enabled" is rejected by the subchart:

  valkey: - at '': additional properties 'enabled' not allowed

Measured. And because Helm skips a DISABLED subchart's schema validation, that
error would have surfaced only for whoever first set it to true: every render,
lint and install-test in CI would have stayed green. Adding "enabled" to
valkey/values.schema.json was rejected as the repair - that chart does not read
the key, and it would have needed its own PR and a publish first.

The host helper was also wrong for our chart and is fixed: our Service is
"<release>-valkey-service", not upstream's bare "<release>-valkey".

The values block is rewritten for our chart's schema, with the pair chosen
rather than inherited: CACHE (persistence off + allkeys-lru), because
everything Stalwart keeps in the in-memory store - rate limiters, auth tokens,
sessions, auto-ban entries, coordination pub/sub - is reconstructible, and the
node-id lease lives in the data store. The cost is named where it is set: with
allkeys-lru the rate limiters and the auto-ban FAIL OPEN under memory pressure,
so the answer is to size memory, not to switch to noeviction (which would make
writes error and take out authentication instead). secrets.authSecretRef wants
an item whose single field "auth-conf" holds the complete "requirepass <pw>"
line, not a bare password.

Chart.yaml's comments are corrected where they became false: nothing is
"consumed as it comes" any more and there is no shortfall of someone else's to
document. The Renovate reasoning stays and is now backed by measurement.

New trap, in Chart.yaml and in three places in the wiki: NEVER change valkey/
and a chart depending on it in the same PR. The dependency resolves from the
published index, so a combined PR lints, packages, renders AND install-tests
against the OLD subchart - green on an artifact that is not the one in the
diff. Two consecutive PRs.

Two things measured, not assumed, because either could have broken the update
path with no symptom but "no updates ever":
- helm dependency update AND build resolve the exact build-metadata version
  from the URL alone with an EMPTY HELM_REPOSITORY_CONFIG - a fresh runner's
  state - so the anonymous index needs no credential and
  ci/helm-dependencies.sh is untouched;
- Renovate 44.132.2 dry run with the dependency pinned low: manager helmv3
  matched the file, took registryUrls from Chart.yaml and offered
  newVersion "1.0.0+up9.1.2.alpine", versioning "helm". The self-hosted index
  and the "+up..." suffix are both handled.

README: the claim "no registry address appears in this repository" is narrowed
to what still holds - the publish target and its credentials come from
1Password and are not in the repo. The read path is now in Chart.yaml by
necessity, carries no credential, and the host was always in CLAUDE.md. Done
here rather than later so the paperless PR finds it already corrected.

Re-verified after the change: helm lint --strict (0 failed), helm package (the
subchart is embedded in the .tgz), helm template with the subchart OFF and ON
(ON was previously impossible), against CI test-values, the rendered
config.json, ci/run-quantity-render-test.sh (0 failed), NOTES.txt through the
scratch-ConfigMap trick, and the fail paths including the reworded abort. The
rendered subchart Service name matches what the helper emits, the cache pairing
really renders (maxmemory-policy allkeys-lru, appendonly no, save "", emptyDir)
and no PVC appears.

Part of #65
2026-10-02 20:59:17 +00:00
jklein 780cb8eafd feat(stalwart): new chart for the Stalwart mail server
Cluster-capable from the start, with roles rather than symmetry: one
StatefulSet per entry of the "nodes" map, the key passed in STALWART_ROLE.

Since v0.16 Stalwart keeps its whole configuration inside the data store and
manages it over JMAP, so the chart's scope is deliberately narrow: it renders
config.json (exactly the DataStore object), the workloads, the Services, the
NetworkPolicies, the Ingress and a cert-manager Certificate. Listeners, blob
store, in-memory store, TLS object and cluster roles are operator work in the
WebUI, which is why NOTES.txt carries an eight-step first install.

config.json stays secret-free: authSecret uses the EnvironmentVariable variant
(verified against the serde definition of PostgreSqlStore at v0.16.24), so the
password arrives through secretKeyRef and checksum/config stays meaningful.
The username cannot follow it - authUsername is a plain String with no secret
form - so it is a public value and says so.

Services are per PORT, not per group: a NodePort is cluster-unique, so "full"
and "mx" can only accept SMTP behind one Service, selected through a per-port
pod label. The http port gets a per-group ClusterIP Service instead, so the
Ingress reaches exactly ingress.nodeGroup.

Four traps, documented rather than explained away:

- The role lookup FAILS OPEN. A node whose ClusterRole is missing from the
  database enables every listener and every task while staying Running with a
  green probe, and the log line arrives after the listeners are bound. Hence
  mx at replicas 0, per-port Services, and a NetworkPolicy that is a ceiling
  independent of what was bound.
- NET_BIND_SERVICE is a documented deviation needed to EXEC, not to bind. The
  image sets setcap cap_net_bind_service=+ep, which makes the kernel fail
  execve when the bounding set is empty. The values comment says both why it
  is there and that it is never used, because the second half is what stops
  the next hardening pass from removing it.
- externalTrafficPolicy must be Cluster - no pod runs on a control-plane node
  while the HA proxy delivers to them, so Local would drop every packet - and
  the source address that costs is restored only by the HA proxy's PROXY v2
  header.
- /healthz is rate-limited to 100 requests/60s per source IP, which is a node
  IP for kubelet probes, so all pods on a node share one bucket. The probe
  intervals are calculated (6 req/min/pod, ~16 pods per node) and the sum is a
  comment in values.yaml.

A first install needs bootstrap.enabled: true: the setup wizard appears only
when config.json is ABSENT, and this chart always renders one, so recovery
mode is upstream's documented path for a templated config.json.

Second chart in the repo with a dependency - the Valkey project's own chart,
off by default behind condition:, because Stalwart's cluster coordinator rides
on the in-memory store connection. The render aborts when more than one pod
runs with no in-memory store address anywhere.

Also wired up: publish-stalwart.yml through the shared block in
workflow-util/pipelines, the change-app-version.yml choice list, a Renovate
customManagers block anchored on the v prefix (which excludes the moving
latest / v0.16 / -alpine tags), CI fixtures, and the README and CLAUDE.md
lines that claimed ytdlp-web-player was the only chart with a dependency.

Verified locally: ci/helm-dependencies.sh, helm lint --strict (0 failed),
helm package, helm template against chart defaults and against
ci/stalwart/test-values.yaml, the rendered config.json parsed as JSON with the
field types the serde definition requires, ci/run-quantity-render-test.sh
(0 failed, both nodes.*.resources paths exercised), NOTES.txt rendered through
a scratch ConfigMap, and the null-safety and fail paths (securityContext=null
keeps NET_BIND_SERVICE; a cluster without an in-memory store, an unknown port
name, a group without http and an unknown ingress.nodeGroup all abort with a
named message; the schema rejects timeout, a string timeoutMs and a list-shaped
nodes). NO runtime verification was possible - there is no k3d or Docker daemon
in this workspace.

Part of #65
2026-10-02 20:59:17 +00:00
jlab-renovate-bot ee43c33ef4 Update dependency kubernetes/kubernetes to v1.35.9
renovate/stability-days Updates have met minimum release age requirement
validate-charts.yml / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Blocked by required conditions
validate-charts.yml / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Blocked by required conditions
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 (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 10s
SonarQube Analysis / SonarQube Analysis (push) Failing after 24s
2026-10-02 16:13:30 +00:00
jlab-renovate-bot 9ae7eef99f Update lukevella/rallly Docker tag to v4.15.3
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 49s
Validate Helm Charts / Resource quantities (pull_request) Successful in 11s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 16s
Install-test / Install-test ${{ fromJSON(inputs.charts) }} (pull_request) Successful in 2m26s
Validate Helm Charts / Install-test (pull_request) Successful in 2m26s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 58s
Publish Rallly Helm Chart / Publish Rallly Helm Chart (push) Successful in 58s
SonarQube Analysis / SonarQube Analysis (push) Failing after 15s
2026-10-02 15:44:23 +00:00
jklein f284d209f7 sonar: correct the project key, and record what the first run measured
SonarQube Analysis / SonarQube Analysis (push) Successful in 42s
Validate Helm Charts / Resource quantities (pull_request) Successful in 9s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
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 (pull_request) Skipped
The project key was a guess. The project exists in SonarQube as
JLab-Helm-Charts; jlab-cluster_helm-charts was reasoned from the instance
serving two owners, and it produced the one error message that covers both a
non-existent project and a token without permission on it:

  ERROR You're not authorized to analyze this project or the project doesn't
        exist on SonarQube and you're not authorized to create it.

Because the server does not distinguish those two cases, the next occurrence of
that message now means the token lacks "Execute Analysis" - which is worth a
comment in the file rather than a second round of guessing.

sonar.projectName goes away entirely. Once the project exists on the server the
display name belongs to the server, and a name sent with every analysis would
overwrite a rename there on every run, silently. The reason is a comment in the
file so the line is not added back as an obvious omission.

The README analyser table was built from SonarQube's language list and promised
more than this instance delivers. Measured in run 375: two quality profiles
loaded, json and yaml; the IaC Kubernetes sensor (3.4 s) and the secrets
detection over all 391 indexed files did the work. Shell and GitHub Actions did
not run, and no configuration here can make them - analysers are server-side
plugins. The consequence is now stated: the five ci/ scripts and the thirty
workflow files, the files carrying the actual logic, are not analysed at all.
Whether the instance gains those plugins is a decision outside this repository.

Run 375 also answered the three questions this path carried, all positively:
node24 is in the runner image, the ~59 MiB scanner download and its signature
arrived in roughly three seconds (so the MTU failure that once killed a 17 MB
download does not repeat), and a repository variable does reach a workflow
called from another repository - which settles the identical question the
publish route carried for OP_CONNECT_HOST. The publish route's upload is still
unproven and stays listed as such.

Fixes #74
2026-10-02 00:15:06 +00:00
jklein 67d8c94fb3 ci: call the shared SonarQube block, and say what it does not do
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 (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 9s
SonarQube Analysis / SonarQube Analysis (push) Failing after 1m0s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Adds the caller side of the analysis: .github/workflows/sonar.yml as a hull of
the same kind as the publish ones, and sonar-project.properties as the
project-specific half. The workflow itself is the shared block in
workflow-util/pipelines, bound to @v4.

No paths filter, unlike the publish hulls: the analysis looks at the whole tree
- the five shell scripts under ci/, every workflow file, the YAML and Helm
templates, the values.schema.json files, and the secret detection over
everything. A filter would leave the previous result standing after a change it
did not cover while still presenting it as current.

workflow_dispatch is on the hull on purpose. Three things about this path are
unmeasured - node24 in the runner image, the ~59 MiB scanner download, and
whether a repository variable reaches a workflow called from another repository
- and any of them may need more than one attempt. Without a dispatch every
attempt would cost a dummy commit on main.

No exclusions, including the Helm templates, which are not valid YAML because
of {{ }}. What the Helm and YAML analysers make of them is only learnable by
letting them run over them once, and an exclusion written on a guess hides
whatever else matches it. If the first run produces noise worth suppressing,
the exclusion belongs in sonar-project.properties with the finding it answers
named next to it.

The project key carries the owner (jlab-cluster_helm-charts) because the
instance serves two, and a key cannot be changed afterwards without losing the
project's history.

The analysis is not a gate. The Community Build analyses the main branch only,
so the trigger is a push to main and a finding appears after the merge - at
which point there is nothing left to block. README and the CI-CD wiki page both
say so, with the two conditions under which a gate would become right, so that
its absence is not read as an oversight.
2026-10-01 23:49:48 +00:00
jklein 8878329f31 ci: publish charts through the shared block in workflow-util/pipelines
Validate Helm Charts / Detect changed charts (pull_request) Successful in 6s
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 / Resource quantities (pull_request) Successful in 13s
Validate Helm Charts / Install-test (pull_request) Skipped
The reusable publish workflow now lives in workflow-util/pipelines and is
called pinned to @v3. All 25 thin hulls switch over and our copy is deleted in
the same commit: a hull pointing at a deleted file, or an orphaned file nobody
calls, would both be worse than the state before.

Each hull gains one line, registry-owner: ${{ github.repository_owner }}. The
owner used to be derived inside the workflow (#63); as a required input without
a default it is evaluated here, in the calling repository, where the value is
measured. That is the house rule of pipelines - a shared block never guesses an
owner segment - and it keeps the block free of any context semantics. The
workflow_dispatch and push triggers of every hull are untouched; after the move
the dispatch is the only way to trigger a publish by hand.

The Connect host is no longer wired into the block. It reads the repository
variable OP_CONNECT_HOST, which the maintainer has set, and fails in its first
step naming the variable when it is empty.

Documentation follows the same split as the code. The README entry no longer
describes a workflow of this repository; it names where the block lives, what
each caller owes it, and points at the publish-helm-chart page of that
repository's wiki for the reasons behind the upload step, which are no longer
ours to keep current. Nothing of them was lost in the move - the upload step's
shell body went across unchanged apart from two error messages that name the
owner as an input.

Also corrected, because the move made them wrong rather than merely stale:

- the helm-dependencies.sh entry still counted the local publish workflow among
  the five places it runs; it is still five, one of them now the shared block,
  which is why that block names the script as a prerequisite of its callers;
- two comments in install-test.yml pointed at publish-helm-chart.yml as a
  sibling file, one of them for a trap it documented "at length" - that long
  form is now on the pipelines wiki, and the comments say so;
- the hull count read 24, and reusable workflows with secrets: inherit are now
  confirmed to work across repositories too;
- the chart-creation step in CLAUDE.md said the reusable workflow is ours.

install-test.yml stays here, with the reason written down (decision 4 on the
issue): it is reusable in form but welded to the ci/ fixtures, the exclusion
parser and this repository's chart matrix. A block only one repository can use
is not shared, only moved. The note is there so the question is not asked again.

Fixes #66
2026-10-01 23:01:47 +00:00
jlab-renovate-bot f3ad3a9844 Update urlaubsverwaltung/urlaubsverwaltung Docker tag to v6.14.0
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 8s
2026-09-30 18:57:54 +00:00
jklein b614b53602 ci: remove the temporary repository_owner probe, record the measurement
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 2s
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 (pull_request) Skipped
github.repository_owner is populated on this Gitea instance: measured as
"jlab-cluster" (12 characters) in run 282 of validate-charts.yml. The
value is now in the comment above the upload step, so the fact survives
without the step that produced it.
2026-09-30 18:51:55 +00:00
jklein a12983fbea ci(publish): compose the registry URL from a base and the repository owner
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 / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 15s
charts-url in op://GitHub-Actions/jlab-registry-access held the full
registry URL and publish-helm-chart.yml appended /api/charts to it. It
now holds only the base up to /api/packages, and the owner segment comes
from github.repository_owner, so the target is composed as
<base>/<owner>/helm/api/charts.

Nothing in this repository points at the registry any more, not even a
path segment, and the second owner on the same instance needs no second
setting. The 26 thin publish-<chart>.yml callers are unchanged.

Two guards keep the switch from the old shape loud rather than a 404
against a masked URL: charts-url is rejected unless it ends in
/api/packages, naming /helm, /api/charts or an included owner
explicitly; and the derived owner is rejected when empty or when it is
not a usable URL path segment. Everything the upload step already had
is untouched - the per-field :? guards, the whitespace check on the
credentials, the host masking, the built file name, --fail-with-body.

The probe step in validate-charts.yml is temporary and measures whether
this forge populates github.repository_owner at all; it is removed again
once the value has been read off the run.
2026-09-30 18:49:56 +00:00
74 changed files with 3890 additions and 1710 deletions

No files matched your search

+4 -4
View File
@@ -40,7 +40,7 @@ on:
- ytdlp-web-player
- sure
- valkey
- convertx
- stalwart
# 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
@@ -67,18 +67,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Configure OP-Connect"
uses: 1password/load-secrets-action/configure@v5
uses: 1password/load-secrets-action/configure@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5
with:
connect-host: https://op-connect.jk-dev.de
- name: "Load GitHub Credentials"
id: load-jk-cluster-bot-creds
uses: 1password/load-secrets-action@v5
uses: 1password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
JK_CLUSTER_BOT_TOKEN: op://GitHub-Actions/github-helm-charts-push-token/credential
JK_CLUSTER_USERNAME: op://GitHub-Actions/github-helm-charts-push-token/username
JK_CLUSTER_EMAIL: op://GitHub-Actions/github-helm-charts-push-token/email
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
token: ${{ env.JK_CLUSTER_BOT_TOKEN }}
- name: "Get Chart Version"
@@ -36,7 +36,7 @@ jobs:
count: ${{ steps.select.outputs.count }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Select the charts to test"
id: select
env:
+14 -11
View File
@@ -30,9 +30,10 @@ on:
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
# the shared publish block in workflow-util/pipelines 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"; the long form is on that repository's wiki). 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
@@ -119,7 +120,7 @@ jobs:
# 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
KUBECTL_VERSION: v1.37.1
# 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
@@ -131,9 +132,9 @@ jobs:
RUNNER_LABEL: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@v5
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Install kubectl"
run: |
# kubectl and the cluster are a pair: the client talks to the server
@@ -196,8 +197,8 @@ jobs:
# 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
# comes from 1Password Connect like the registry credentials in the shared
# publish block, 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
@@ -205,18 +206,20 @@ jobs:
# touches the VPN key.
- name: "Configure OP-Connect"
if: matrix.chart == 'freesailarr'
uses: 1password/load-secrets-action/configure@v5
uses: 1password/load-secrets-action/configure@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # 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
uses: 1password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # 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
# The same trap the shared publish block documents at length - since
# Gitea #66 that long form lives on the wiki of workflow-util/pipelines,
# page "publish-helm-chart", not in this repository: 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
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-apache-tika:
name: Publish Apache Tika Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'apache-tika'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-bentopdf:
name: Publish BentoPDF Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'bentopdf'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
@@ -9,7 +9,8 @@ on:
jobs:
publish-calibre-web-automated:
name: Publish Calibre Web Automated Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'calibre-web-automated'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
-15
View File
@@ -1,15 +0,0 @@
name: Publish ConvertX Helm Chart
on:
workflow_dispatch:
push:
branches:
- main
paths:
- convertx/**
jobs:
publish-convertx:
name: Publish ConvertX Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
with:
chart-name: 'convertx'
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-cyberchef:
name: Publish Cyberchef Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'cyberchef'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-freesailarr:
name: Publish Freesailarr Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'freesailarr'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-gotenberg:
name: Publish Gotenberg Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'gotenberg'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
-149
View File
@@ -1,149 +0,0 @@
name: Publish Helm Chart with patched Application Version
on:
workflow_dispatch:
inputs:
chart-location:
required: false
type: string
default: "."
description: "location of the helm chart directory"
chart-name:
required: true
type: string
description: "name of the helm chart"
workflow_call:
inputs:
chart-location:
required: false
type: string
default: "."
description: "location of the helm chart directory"
chart-name:
required: true
type: string
description: "name of the helm chart"
outputs:
chart-version:
description: "the version of the published helm chart"
value: ${{ jobs.publish-helm-chart.outputs.chart-version }}
secrets:
OP_CONNECT_TOKEN:
required: true
jobs:
publish-helm-chart:
outputs:
chart-version: ${{ steps.get-chart-version.outputs.CHART_VERSION }}
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
- name: "Install Helm"
uses: azure/setup-helm@v5
- name: "Configure OP-Connect"
uses: 1password/load-secrets-action/configure@v5
with:
connect-host: https://op-connect.jk-dev.de
# helm package exits 1 with "found in Chart.yaml, but missing in charts/
# directory" while a declared dependency has not been fetched, so this
# comes first. It is a no-op for every chart without a dependencies:
# block; ci/helm-dependencies.sh explains why it resolves from Chart.yaml
# instead of a committed Chart.lock. Note the consequence for the
# published artifact: the subchart .tgz is packaged INTO ours, so the
# version pinned in Chart.yaml is the version operators get, and nothing
# downstream ever sees the dependency again.
- name: "Fetch chart dependencies"
run: ${{ github.workspace }}/ci/helm-dependencies.sh "${{ inputs.chart-name }}"
working-directory: ${{ inputs.chart-location }}
- name: "Package Helm Chart"
run: helm package ${{ inputs.chart-name }}
working-directory: ${{ inputs.chart-location }}
- name: "Get Helm Chart Version"
id: get-chart-version
run: |-
CHART_VERSION=$(grep '^version:' ${{ inputs.chart-name}}/Chart.yaml | awk '{print $2}')
echo $CHART_VERSION
echo "CHART_VERSION=$CHART_VERSION" >> $GITHUB_OUTPUT
working-directory: ${{ inputs.chart-location }}
- name: "Load Chart Registry Credentials"
id: load-registry-creds
uses: 1password/load-secrets-action@v5
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
REGISTRY_PASSWORD: op://GitHub-Actions/jlab-registry-access/password
REGISTRY_USERNAME: op://GitHub-Actions/jlab-registry-access/username
REGISTRY_CHARTS_URL: op://GitHub-Actions/jlab-registry-access/charts-url
# The target is a classic, index-based Helm registry, not an OCI one, so
# there is no "helm registry login" session and no "helm push": the upload
# is a single HTTP POST of the packaged .tgz to <charts-url>/api/charts,
# authenticated per request. Three properties of this step are load-bearing
# and each of them was paid for once already.
#
# 1) The values are mapped into the step's own env block and read as quoted
# shell variables - never interpolated into the command line. load-secrets-
# action v5 (bumped in #140) no longer exports the loaded values as
# environment variables for later steps the way v4 did; only the step
# outputs carry them. Every "${{ env.* }}" reference therefore resolved to
# an empty string from that bump on, with nothing reporting an error - the
# loading step logs "Populating variable" and stays green either way. The
# ":?" guards fail loudly and name the op:// field that is missing, and
# quoting keeps an empty value from silently shifting arguments.
#
# 2) The credentials never reach the process list. "curl --user $U:$P"
# would put them in argv; "curl --config -" reads its options from stdin
# instead, so nothing sensitive appears in any argument. The price of that
# config file is its value syntax: an unquoted value ends at the first
# space (measured on curl 8.5.0 - "has space" arrived as "has"), so the
# step refuses whitespace in the credentials up front rather than letting
# a truncated password come back as a puzzling 401. A Gitea access token
# is 40 hex characters, so this only ever fires on a mis-entered item.
#
# 3) "--fail-with-body" is what makes a failed upload fail the job. Unlike
# "helm push", curl exits 0 on HTTP 401/403/409 unless asked not to - the
# publish would go green while nothing was uploaded. Measured against Gitea
# 1.24.7: a 401 without the flag is exit 0, with the flag exit 22, and the
# response body is still printed so the log names the cause.
#
# The file name is built from the chart name and the version read above
# instead of a "<chart>-*.tgz" glob: --upload-file takes exactly one file,
# and a glob matching two turns the second into curl's URL argument, while
# a glob matching none is passed through literally. Both fail obscurely.
# "helm package" keeps the "+" of a "1.4.0+up1.9.2" version verbatim in the
# file name, so "<name>-<version>.tgz" is an exact match (measured).
- name: "Upload Helm Chart to the Chart Registry"
env:
REGISTRY_PASSWORD: ${{ steps.load-registry-creds.outputs.REGISTRY_PASSWORD }}
REGISTRY_USERNAME: ${{ steps.load-registry-creds.outputs.REGISTRY_USERNAME }}
REGISTRY_CHARTS_URL: ${{ steps.load-registry-creds.outputs.REGISTRY_CHARTS_URL }}
CHART_NAME: ${{ inputs.chart-name }}
CHART_VERSION: ${{ steps.get-chart-version.outputs.CHART_VERSION }}
run: |-
: "${REGISTRY_CHARTS_URL:?empty - check op://GitHub-Actions/jlab-registry-access/charts-url}"
: "${REGISTRY_USERNAME:?empty - check op://GitHub-Actions/jlab-registry-access/username}"
: "${REGISTRY_PASSWORD:?empty - check op://GitHub-Actions/jlab-registry-access/password}"
: "${CHART_NAME:?empty - the chart-name input did not arrive}"
: "${CHART_VERSION:?empty - could not read version: from ${CHART_NAME}/Chart.yaml}"
# 1Password masks the full URL, but a curl connection error prints only
# "<host>:<port>", which that mask would not cover. Mask the host too.
CHARTS_HOST="${REGISTRY_CHARTS_URL#*://}"
echo "::add-mask::${CHARTS_HOST%%/*}"
case "${REGISTRY_USERNAME}${REGISTRY_PASSWORD}" in
*[[:space:]]*)
echo "::error::username/password in op://GitHub-Actions/jlab-registry-access must not contain whitespace - curl --config truncates an unquoted value at the first space"
exit 1
;;
esac
CHART_PACKAGE="${CHART_NAME}-${CHART_VERSION}.tgz"
test -f "$CHART_PACKAGE" || {
echo "::error::${CHART_PACKAGE} does not exist - helm package produced: $(ls -1 ./*.tgz 2>&1)"
exit 1
}
echo "uploading ${CHART_PACKAGE}"
printf 'user = %s:%s\nurl = %s\n' \
"$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" "${REGISTRY_CHARTS_URL%/}/api/charts" |
curl --config - \
--fail-with-body \
--silent --show-error \
--request POST \
--upload-file "$CHART_PACKAGE" \
--write-out '\nHTTP %{http_code}\n'
working-directory: ${{ inputs.chart-location }}
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-homeassistant:
name: Publish Homeassistant Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'homeassistant'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-matrix-synapse:
name: Publish Matrix Synapse Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'matrix-synapse'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
@@ -9,7 +9,8 @@ on:
jobs:
publish-minecraft-router:
name: Publish Minecraft-Router Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'minecraft-router'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
@@ -9,7 +9,8 @@ on:
jobs:
publish-minecraft-server:
name: Publish Minecraft-Server Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'minecraft-server'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-outline:
name: Publish Outline Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'outline'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-paperless-ngx:
name: Publish Paperless-NGX Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'paperless-ngx'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-patchmon:
name: Publish Patchmon Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'patchmon'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-rallly:
name: Publish Rallly Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'rallly'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-rss-bridge:
name: Publish RSS-Bridge Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'rss-bridge'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-samba:
name: Publish Samba Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'samba'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-satisfactory:
name: Publish Satisfactory Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'satisfactory'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-searxng:
name: Publish SearXNG Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'searxng'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
name: Publish Stalwart Helm Chart
on:
workflow_dispatch:
push:
branches:
- main
paths:
- stalwart/**
jobs:
publish-stalwart:
name: Publish Stalwart Helm Chart
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'stalwart'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-sure:
name: Publish Sure Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'sure'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
@@ -9,7 +9,8 @@ on:
jobs:
publish-teamspeak-server:
name: Publish TeamSpeak Server Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'teamspeak-server'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
@@ -9,7 +9,8 @@ on:
jobs:
publish-urlaubsverwaltung:
name: Publish Urlaubsverwaltung Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'urlaubsverwaltung'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-valkey:
name: Publish Valkey Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'valkey'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-voucher-vault:
name: Publish VoucherVault Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'voucher-vault'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
@@ -9,7 +9,8 @@ on:
jobs:
publish-ytdlp-web-player:
name: Publish YTDLP Web Player Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'ytdlp-web-player'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+2 -1
View File
@@ -9,7 +9,8 @@ on:
jobs:
publish-zipline:
name: Publish Zipline Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'zipline'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
+37
View File
@@ -0,0 +1,37 @@
name: SonarQube Analysis
# The analysis itself lives in workflow-util/pipelines, bound to a tag like
# every other call to that repository. This hull only decides WHEN it runs.
#
# No "paths" filter, unlike the publish-<chart>.yml hulls: the analysis looks at
# the whole tree - the five shell scripts under ci/, every workflow file, the
# YAML and Helm templates, the values.schema.json files, and the secret
# detection that covers everything. A filter would mean a change outside the
# filter leaves the last result standing while claiming to be current.
on:
# push on main, and only main, because the SonarQube Community Build analyses
# the main branch only - there is no pull-request analysis to hook into. The
# consequence belongs said out loud rather than discovered: a finding appears
# AFTER the merge. This is an observation of the state of main, not a gate,
# and the shared block deliberately carries no quality-gate step.
push:
branches:
- main
# workflow_dispatch, and it is not decoration. Three things about this path
# are unmeasured - whether node24 is in the runner image, whether the ~59 MiB
# scanner download gets through (a 17 MB one once did not, MTU 1500 against a
# pod network of 1450), and whether a repository variable reaches a workflow
# called from another repository. Any of them may need more than one attempt,
# and without a dispatch every attempt would cost a dummy commit on main.
# Only the maintainer can trigger it; the bot account cannot dispatch.
workflow_dispatch:
jobs:
sonar:
name: SonarQube Analysis
uses: workflow-util/pipelines/.gitea/workflows/sonar-analysis.yml@v4
# secrets: inherit passes SONAR_TOKEN through. The block declares it as
# required, and a missing required secret fails job CREATION on this
# instance, not the job - the run would stand there with no steps at all.
# SONAR_HOST_URL is a repository variable and needs nothing here.
secrets: inherit
+7 -7
View File
@@ -15,7 +15,7 @@ jobs:
any-install: ${{ steps.detect.outputs.any-install }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: "Detect changed charts"
@@ -116,11 +116,11 @@ jobs:
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }}
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: "Install Helm"
uses: azure/setup-helm@v5
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
# Both steps below refuse to run while a chart declares a dependency that
# is not in its charts/ directory - lint with "[WARNING] chart directory
# is missing these dependencies", package with "found in Chart.yaml, but
@@ -167,9 +167,9 @@ jobs:
# build-chart, so it adds no wall-clock time to the pipeline.
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@v5
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Render every chart with bare integer quantities"
run: ci/run-quantity-render-test.sh
@@ -197,9 +197,9 @@ jobs:
TRIVY_CONFIG_MODE: report
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@v5
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
# A plain release download rather than aquasecurity/setup-trivy, and the
# reason is measured on the Gitea probe PR of #225, not assumed. That
# action runs actions/checkout against aquasecurity/trivy on github.com
+2 -2
View File
@@ -85,7 +85,7 @@ helm template <chart-name> --set ingress.domain=example.com --set ingress.cluste
Copy `template-chart/` and replace every `xxx` placeholder — in file names (`templates/xxx.deployment.yaml` → `templates/<app>.deployment.yaml`), in `Chart.yaml`, in the `values.yaml` keys and their `.Values.xxx.*` references inside templates, and in `values.schema.json` (both the values paths and the `xxx.` helper prefixes). The schema and the null-safe fallback helpers come with the template but their content is chart-specific — adapt them along the six points of the README section "Values that must not lie", and validate the result against the chart's real bundle values, not only against its defaults. Then wire up CI:
1. Add `.github/workflows/publish-<chart-name>.yml` (copy an existing one, e.g. `publish-outline.yml`) — it triggers on pushes to `main` touching `<chart-name>/**` and calls the reusable `publish-helm-chart.yml`.
1. Add `.github/workflows/publish-<chart-name>.yml` (copy an existing one, e.g. `publish-outline.yml`) — it triggers on pushes to `main` touching `<chart-name>/**` and calls the reusable `publish-helm-chart.yml`, which lives in `workflow-util/pipelines` and is bound to a tag. Copying an existing hull gets the `uses:` path, `registry-owner: ${{ github.repository_owner }}` and `secrets: inherit` right; writing one from memory does not.
2. Add the chart name to the `chart-name` choice list in `.github/workflows/change-app-version.yml`.
3. Add install-test fixtures under `ci/<chart-name>/` (`test-values.yaml`, `fixtures.yaml`) or an entry with justification in `ci/excluded-charts.txt` — the PR install-test fails if both are missing.
4. Add a `customManagers` block for the chart's main image in `renovate.json5` (one per chart, since the image repository lives in the template and the version in `Chart.yaml`) — without it Renovate never updates that chart's app version. See README section "Dependency updates (Renovate)".
@@ -117,4 +117,4 @@ The binding style guide for all current and future charts lives in **README.md (
- Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly).
- Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them.
- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci/<chart>/` or a justified exclusion.
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` is the only chart using one). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
+63 -13
View File
@@ -17,7 +17,6 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
| `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 |
| `convertx` | ConvertX | StatefulSet + PVC at `/app/data` (SQLite, uploads and converted output in one volume), no external database. **The only chart that overrides the image's `command` to bypass its entrypoint**: the image carries no `USER` and its entrypoint exists solely to drop to uid 1000 with `gosu`, so the chart starts the app directly and lets `runAsUser`/`fsGroup` do that work instead — the hardening baseline stays intact, at the price that any logic upstream later moves into that entrypoint is skipped silently (re-read it at every app-version update; the reasoning sits at the `command:` line). Pins uid 1000 rather than the repo's 20000, because the image owns `/home/convertx` with it and the uid owns PV data. `readOnlyRootFilesystem: true` with two emptyDirs, `/tmp` and `/home/convertx` — the latter is `HOME`, which the chart also sets explicitly, because LibreOffice, Calibre and fontconfig build their profiles there on the first conversion. Probes use `/healthcheck` (static 200, unauthenticated); `/` is never a 200, it 302s to `/setup` or `/login`. `JWT_SECRET` is a required `OnePasswordItem` — unset, the app mints a fresh one per process and every restart logs everyone out. The ingress `proxy-body-size` annotation is the **only** upload ceiling, since the app enforces none. ~1.4 GiB image, hence a raised `INSTALL_TIMEOUT` |
| `cyberchef` | CyberChef | Stateless Deployment |
| `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) |
@@ -26,20 +25,21 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
| `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value |
| `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 |
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects). Configured as a **queue** (PVC + `noeviction`), unlike `stalwart`'s cache: `PAPERLESS_REDIS` is the Celery broker, and `allkeys-lru` evicts across the whole keyspace, where a backlog of waiting consume tasks is exactly what LRU reads as cold. The password comes from the **same 1Password item** as the Redis connection, in a third field `auth-conf` holding the complete line `requirepass <password>` — so the password sits in that item **twice** and a rotation has to touch both fields. Two more traps: with the subchart on, `redis-connection-url` in the vault **must** be repointed at `<release>-valkey-service:6379` or the bundled Valkey runs unused, and `valkeySubchart:` must not be erased — a null override deletes the key, Helm reads an unresolvable `condition:` as *enabled*, so erasing the switch turns the subchart **on** (rejected by `values.schema.json`). Never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
| `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 |
| `stalwart` | Stalwart mail server (SMTP, IMAP, POP3, JMAP, ManageSieve) | **The chart configures almost nothing, and that is the chart's defining property.** Since v0.16 Stalwart keeps its whole configuration — every listener, the blob store, the in-memory store, TLS objects, domains and the cluster roles — inside the data store and manages it over JMAP; the only file on disk is a `config.json` that names the data store and nothing else. So the chart renders that ConfigMap (secret-free: `authSecret` uses the `EnvironmentVariable` variant, so the password arrives by `secretKeyRef` and `checksum/config` stays meaningful), one StatefulSet per entry of the `nodes` **map** with `STALWART_ROLE` and `STALWART_HOSTNAME`, one NodePort Service **per port** (not per group — a NodePort is cluster-unique, so `full` and `mx` can only take SMTP behind the same Service, selected by a per-port pod label), a per-group ClusterIP `http` Service for the Ingress, per-group headless Services and NetworkPolicies, and a cert-manager `Certificate` for the mail ports. Everything else is operator work in the WebUI, which is why `NOTES.txt` carries an eight-step first install. Four traps worth knowing before reading the chart: **the role lookup fails open** — a node whose `ClusterRole` is missing from the database enables every listener *and* every task while staying Running with a green probe, so `mx` ships at `replicas: 0` and the NetworkPolicy is a ceiling independent of what was bound; **`NET_BIND_SERVICE` is a documented deviation needed to `exec`, not to bind** (the image sets `setcap cap_net_bind_service=+ep`, so `drop: [ALL]` alone makes the kernel fail `execve` — removing it as "unused" stops the chart starting); **`externalTrafficPolicy` must be `Cluster`** (no pod runs on a control-plane node while the HA proxy delivers to them, so `Local` would drop every packet) and the source address it costs is restored only by the HA proxy's PROXY v2 header; and **`/healthz` is rate-limited to 100 req/60s per source IP**, which is a node IP for kubelet probes, so the probe intervals are calculated rather than chosen. A first install needs `bootstrap.enabled: true` because the setup wizard appears only when `config.json` is *absent*. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects); never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
| `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 |
| `ytdlp-web-player` | ytdlp_web_player | Stateless Deployment, video cache and tool cache as emptyDirs (no PVC). **The first chart with a dependency** (`stalwart` and `paperless-ngx` followed): 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 |
## Versioning
@@ -52,23 +52,32 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
## CI/CD
- `publish-<chart>.yml` — one thin workflow per chart; triggers on pushes to `main` touching `<chart>/**` (or manually) and calls the reusable workflow below. **A push to a chart directory on `main` publishes that chart**, so the chart version must be bumped in the same change.
- `publish-helm-chart.yml` — reusable workflow: `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry. The target is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (`OP_CONNECT_TOKEN` secret, item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`); the workflow appends `/api/charts` to `charts-url`. No registry address ever appears in this repository. The loaded values are read via **`steps.<id>.outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error. Three properties of the upload step are load-bearing:
- **Credentials stay out of the process list.** `curl --user "$U:$P"` would put them in `argv`; the step uses `curl --config -` and pipes the options in on stdin instead.
- **A failed upload fails the job.** Unlike `helm push`, `curl` exits **0** on HTTP 401/403/409 — a publish would go green with nothing uploaded. `--fail-with-body` turns those into a non-zero exit and still prints the response body, so the log names the cause.
- **The file name is built, not globbed.** `<chart>-<version>.tgz` is assembled from the chart name and the version already read from `Chart.yaml`; `--upload-file` takes exactly one file, and a glob matching two silently turns the second into curl's URL argument. `helm package` keeps the `+` of a `1.4.0+up1.9.2` version verbatim in the file name, so the built name is an exact match.
- `publish-helm-chart.yml` — **not in this repository any more.** The reusable workflow lives in [`workflow-util/pipelines`](https://git.jk-dev.de/workflow-util/pipelines), the shared building-block repo, and the callers here bind it to a tag: `uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3` (Gitea #66). It still does the same thing — `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry, which is a **classic, index-based** Helm registry and not an OCI one, so `helm push` is not used, there is no `helm registry login` session, and every request carries its own Basic auth. What changed is who supplies what:
- **`registry-owner: ${{ github.repository_owner }}`** in each caller's `with:`. The owner used to be derived inside the workflow (#63); it is now a required input with no default, so the expression is evaluated **here**, in the calling repository, where the value is measured. That is `pipelines`' house rule — a shared block never guesses an owner segment — and it is why every caller carries that one extra line.
- **`secrets: inherit`**, unchanged, for `OP_CONNECT_TOKEN`.
- the repository **variable `OP_CONNECT_HOST`**, which replaced the Connect host that used to be hardcoded in our copy. A shared block must not carry one tenant's infrastructure address; the block's first step fails and names the variable when it is unset.
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.
The registry credentials and the target URL still come from 1Password Connect (item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`). `charts-url` holds only the **base up to `/api/packages`** and the block composes `<charts-url>/<owner>/helm/api/charts`, so **the publish target and its credentials appear nowhere in this repository.** `ci/helm-dependencies.sh` is still run before `helm package`, and it is still **our** script: the block's checkout clones the calling repository, so it runs from this tree.
**What this does *not* claim any more**: that no registry address is in the repo at all. That wording was true until a chart took one of our own published charts as a subchart — `stalwart`'s `Chart.yaml` names `https://git.jk-dev.de/api/packages/jlab-cluster/helm` as a dependency `repository:`, because a Helm dependency is resolved from a URL in the chart and there is nowhere else to put it. The narrower claim above is the one that matters and it is unchanged: that is the **read** path, the index is anonymously readable (measured), so it carries **no credential** and grants nothing a `git clone` of this repo does not already imply. The host itself has always been in `CLAUDE.md`; what is new is only the read-only path segment.
The reasons behind the upload step are **not repeated here**, because they are no longer ours to keep current: why both halves of the composed URL are guarded, why the credentials go in through `curl --config -` instead of `--user`, why `--fail-with-body` is what makes a failed upload fail the job, and why the file name is built rather than globbed, are all on the [`publish-helm-chart` wiki page](https://git.jk-dev.de/workflow-util/pipelines/wiki/publish-helm-chart) of that repository. Nothing was dropped in the move — the upload step's shell body went across unchanged except for two error messages that name the owner as an input.
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. That is also what makes a manual `workflow_dispatch` of any `publish-<chart>.yml` a harmless way to prove the shared block works.
- `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.
- `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 three: `ytdlp-web-player`, `stalwart`, `paperless-ngx`). 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 the shared publish block (which is why that block names this script as a prerequisite of its callers — see above), 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, 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.
**It stays in this repository, and that is a decision, not an oversight** (Gitea #66, decision 4). It is reusable in form but not in substance: it is welded to the `ci/` fixtures, to the `ci/excluded-charts.txt` parser and to this repository's chart matrix. A building block that only one repository can use is not shared, it is merely moved somewhere else — and moving it would buy nothing while putting a tag between us and a file we change often. `publish-helm-chart.yml` was the opposite case: it takes a chart name and a registry owner and knows nothing about this repository, which is exactly why it could go. This note exists so the question is not asked again in half a year.
- `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.
- `sonar.yml` — **SonarQube analysis**, a hull like the publish ones: it triggers on pushes to `main` (no `paths` filter — the analysis looks at the whole tree) or manually, and calls `workflow-util/pipelines/.gitea/workflows/sonar-analysis.yml@v4` with `secrets: inherit`. The project-specific half is `sonar-project.properties` in the repo root. **It is not a gate** and it does not block anything — see below.
### Where this repo lives, and what a Gitea runner does differently
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.
**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**, so the 25 thin `publish-<chart>.yml` hulls run unchanged — including now that the workflow they call lives in another repository (Gitea #66). 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.
**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`.
@@ -145,7 +154,7 @@ Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the ch
- `ci/<chart>/settings.env` — optional overrides: `INSTALL_TIMEOUT` for slow starters, and `REQUIRED_ENV="VAR_A VAR_B"` to declare the variables `fixtures.env.yaml` needs. **A missing or empty declared variable fails the install-test loudly, naming the variable — it never skips the chart.** Skipping would restore exactly the green-but-meaningless signal this mechanism exists to remove; the one legitimate case of absent secrets, a PR from a fork, belongs in an `if` on the workflow job rather than in the script
- `ci/excluded-charts.txt` — charts that cannot run in CI, one per line as `<chart> <justification>`. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `template-chart` (scaffold with placeholder image). The justification is mandatory: a line carrying only a chart name is deliberately **not** an exclusion, because a bare name would drop a chart out of CI with nothing to review. **An excluded chart gets no install-test job at all** — see below
`freesailarr` is the only chart using `fixtures.env.yaml` today: gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The install-test job loads the key from 1Password Connect for that chart only, using the same pattern as `publish-helm-chart.yml` — `steps.<id>.outputs.*` into the step's own `env:`, quoted, with `:?` guards that name the missing `op://` field before the cluster is even created. The key in that item is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make 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.
`freesailarr` is the only chart using `fixtures.env.yaml` today: gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The install-test job loads the key from 1Password Connect for that chart only, using the same pattern as the shared publish block — `steps.<id>.outputs.*` into the step's own `env:`, quoted, with `:?` guards that name the missing `op://` field before the cluster is even created. The key in that item is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make 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.
**An excluded chart is dropped from the matrix, not skipped inside its job (#231).** It used to get a job that ran the script, printed `SKIP` and exited 0 — and GitHub knows only the exit code, so the check list showed `Install-test satisfactory ✓` for a chart whose image was never pulled, indistinguishable from one that really came up in the cluster. `detect-changed-charts` now removes the excluded charts before the matrix is built, so no job exists for them and nothing claims they passed. Because an absence on its own says nothing, that job writes every excluded chart of the PR **with its documented justification** into the job summary — a `Skipped` would only say "not run", and why is the question a review actually asks. The list is parsed in exactly one place: `ci/run-install-test.sh --list-excluded` emits `<chart><TAB><reason>` and the workflow calls it instead of re-implementing the format in YAML, since a second parser would be a second chance to select different charts — silently, and in either direction. The script keeps its own `SKIP` branch for local runs. What cannot be done instead: deciding per chart in an `if` on the install-test job, to get a visible `Skipped` entry rather than an absent one. GitHub rejects it with `context "matrix" is not allowed here` — `jobs.<id>.if` is evaluated **before** the matrix is expanded, so the `matrix` context does not exist there, and the whole workflow ends in failure with zero jobs created (measured on the closed PR #233).
@@ -164,7 +173,32 @@ Why it exists at all: the answer arrived once by accident. Gitea PR #48 correcte
- **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.
- **A big image does not move that figure as much as it looks.** `convertx` was expected to, at ~1.4 GiB compressed — by a wide margin the largest image in the repo, and the reason its `settings.env` raises `INSTALL_TIMEOUT` to 900s. Measured on its first green run (Gitea PR #61) the whole leg took **2m35s**, cluster creation and rollout wait included, so it sits inside the existing envelope rather than setting a new one. Worth knowing before the next raised timeout is read as a prediction about the total: the legs run concurrently, so what the all-charts figure tracks is the **slowest** leg plus scheduling, not the sum of the pulls.
### SonarQube analysis (after the merge, and not a gate)
`sonar.yml` sends the whole tree through a SonarQube analysis. The workflow itself is the shared block [`sonar-analysis.yml`](https://git.jk-dev.de/workflow-util/pipelines/wiki/sonar-analysis) in `workflow-util/pipelines`, bound to `@v4`; the only thing on this side is `sonar-project.properties` in the repo root, holding the project key `JLab-Helm-Charts`. **That key is read off the existing project in SonarQube, not derived** — it is the one the maintainer set up, and a guessed key (`jlab-cluster_helm-charts`, reasoned from the instance serving two owners) is what made the first run fail. The file carries **no `sonar.projectName`** on purpose: once the project exists, the display name belongs to the server, and a name sent with every analysis would overwrite a rename there silently.
**What is analysed — measured, not taken from a language list.** The first run (run 375) loaded exactly **two quality profiles, `json` and `yaml`**; the sensors that did work were **IaC Kubernetes** (3.4 s) and the **secrets detection** (`TextAndSecretsSensor`) over all 391 indexed files.
| What is in this repository | Analyser | Runs? |
|---|---|---|
| 238 YAML files and 26 Helm template directories | IaC Kubernetes, YAML | yes |
| 26 `values.schema.json` | JSON | yes |
| the whole tree (391 files) | Secrets | yes |
| the 5 shell scripts under `ci/` — the whole CI logic | Shell | **no** |
| the 30 workflow files | GitHub Actions | **no** |
**The two missing rows are not a configuration mistake, and nothing in this repository can fix them: analysers come from the *server*, not from the scanner.** The language list the earlier version of this table was built from describes the newest SonarQube release; this instance does not have the Shell and GitHub Actions plugins. The consequence is worth stating plainly rather than leaving it to be rediscovered — **the files carrying the actual logic, the five `ci/` scripts and the thirty workflows, are not analysed at all.** What runs is IaC over the charts plus secret detection over everything. Whether the instance gets upgraded or gains those plugins is a decision for whoever runs it, not something this repository settles; `shellcheck` and review remain the only check on the scripts until then.
The secrets row is the valuable one that *does* run. **The premise of this repository is that no credential ever lands in it** — every secret is a 1Password `OnePasswordItem` reference — and until now nothing checked that premise. What it actually reports is still unknown: run 375 built the report (603 kB) but never uploaded it, because the upload is where the wrong project key failed.
**It is not a gate, and that is a decision.** The SonarQube Community Build analyses the **main branch only**; there is no pull-request analysis to hook into, so the trigger is a push to `main` and **a finding appears after the merge**. At that point there is nothing left to block, which is why the shared block carries no quality-gate step: a red gate behind the merge would only colour `main` red without having prevented anything, and a permanent red is a thing people learn to ignore — taking the credibility of `validate-charts` with it. A gate becomes the right thing under one of two conditions: pull-request analysis is available, or a deployment hangs off the workflow. Until then, read the findings in SonarQube; nothing in this repository turns red because of them.
**No exclusions, for the first round.** Including the Helm templates, which are not valid YAML because of `{{ }}`. What the Helm and YAML analysers make of them is only learnable by letting them run over them once, and excluding them up front would discard that answer along with the bulk of the repository. If the first run produces noise genuinely not worth reading, the exclusions go into `sonar-project.properties` with the finding they answer named next to them — not before.
**Prerequisites**, both on the instance and neither in a file here: the repository **variable `SONAR_HOST_URL`** (a variable and not a secret on purpose — the server address is not a credential, and as a variable it stays readable in the log, which is what makes a wrong one diagnosable) and the **secret `SONAR_TOKEN`**, which reaches the block through `secrets: inherit`. The block guards both and names the setting and its place when one is empty. A *missing* `SONAR_TOKEN` never gets that far: the block declares it `required`, and a missing required secret fails job **creation** on this instance, so the run would show no steps at all — the same quirk as everywhere else here.
**Three things about this path were unmeasured, and run 375 answered all three — positively.** `node24` **is** in the runner image, so the JavaScript scan action runs. The scanner download **gets through**: ~59 MiB of archive plus its signature in roughly three seconds, so the MTU failure that once killed a 17 MB download on this runner (see "What a GitHub runner had been providing quietly") does not repeat at five times the size. And a repository **variable does reach a workflow called from another repository** — the block's guard passed and the scanner started, which it could not have done without `vars.SONAR_HOST_URL` arriving. That last one also settles the identical question the publish route carried for `OP_CONNECT_HOST`: same forge version, same mechanism, a repository variable read inside a workflow called from another repository. What the publish route still has unproven is the upload itself, which has never run. `workflow_dispatch` stays on the hull for the retries: without it every attempt costs a dummy commit on `main`.
## Dependency updates (Renovate)
@@ -377,7 +411,9 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
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.
**4. A fallback reproduces *this chart's* defaults, not the repo standard.** Five charts deviate from the hardening baseline on purpose. A "cleaned up" standard default is not tidying, it is an outage: `calibre-web-automated` must keep `readOnlyRootFilesystem: false`, because in read-only mode the image disables its PUID/PGID remapping and the runtime uid silently moves from 1000 to 911 — **making existing volume data inaccessible**. The same applies to non-standard values that look like mistakes: `satisfactory` keeps `limitFactor: 2` (that is what holds the memory limit at the published 8Gi) and `fsGroupChangePolicy: OnRootMismatch` (which avoids a recursive chown of multi-GB game files on every start).
**4. A fallback reproduces *this chart's* defaults, not the repo standard.** Several charts deviate from the hardening baseline on purpose (no count here — the previous wording named one and went stale the next time a chart landed). A "cleaned up" standard default is not tidying, it is an outage: `calibre-web-automated` must keep `readOnlyRootFilesystem: false`, because in read-only mode the image disables its PUID/PGID remapping and the runtime uid silently moves from 1000 to 911 — **making existing volume data inaccessible**. The same applies to non-standard values that look like mistakes: `satisfactory` keeps `limitFactor: 2` (that is what holds the memory limit at the published 8Gi) and `fsGroupChangePolicy: OnRootMismatch` (which avoids a recursive chown of multi-GB game files on every start).
**The sharpest case is a capability that is granted and never used.** `stalwart`'s fallback has to carry `capabilities.add: [NET_BIND_SERVICE]`, because the image sets `setcap cap_net_bind_service=+ep` on its binary — which makes it a capability-dumb binary, and `capabilities(7)` then has the kernel fail the **`execve` itself** when the bounding set does not carry the capability: *"If the process did not obtain the full set of file permitted capabilities, then `execve(2)` fails with the error `EPERM`."* So `drop: [ALL]` with the standard empty `add` does not produce an unhardened container, it produces `exec /usr/local/bin/stalwart: operation not permitted` and a chart that cannot start. The capability is then spent entirely on the exec check and is never used to bind anything, which is exactly what makes it a magnet for a hardening pass looking for dead grants. This is why a deviation is documented with its **mechanism** and not only its name: "needed for binding" would have been wrong and would have invited the removal.
**5. A probe default carries the probe's context, not just its timings.** Two different symptoms, both from real charts: `teamspeak-server` addresses its TCP port by the name `file-transfer` because the voice service is UDP and cannot be probed — a default without that name leaves the workload **with no working probe at all**. `patchmon` sends a `Host` header because the server answers **403** to any request whose Host is neither loopback nor a CORS_ORIGIN host — a default without it means the pod never becomes Ready. Port names, `Host` headers and `exec` commands belong in the fallback; where the context is dynamic, pass the root context in and rebuild it (patchmon derives the header from `ingress.domain`).
@@ -404,6 +440,20 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
Until `ytdlp-web-player` (#214) this section read "charts have no dependencies (`charts/` directories are empty)". That rule is gone, replaced by a bar rather than a ban — a dependency is **admissible, not free**, and the reason it stopped being a ban is worth keeping: the chart needed an oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other.
Three charts carry one, and comparing them is the quickest way to see where the bar sits. `ytdlp-web-player` takes **upstream's** `oauth2-proxy` because the app has no authentication and the two are useless apart; there, point 2 above applies in full. `stalwart` (Gitea #65) takes **one of our own published charts**, `valkey`, because Stalwart's **cluster coordinator rides on the in-memory store connection**, so a multi-node install does not work without one. `paperless-ngx` (Gitea #78) takes the same one, because `PAPERLESS_REDIS` is the Celery broker and the application therefore cannot run without a Redis at all — before the subchart, every installation needed a second Helm release whose address lived in a 1Password item and in neither chart. All three are `condition:`-gated and off by default: the subchart is the path for an installation that has no Valkey yet, and one that already has points its connection value at that instead, so the dependency renders nothing.
**The same subchart, opposite configuration — and that comparison is the useful part.** `stalwart` runs it as a **cache** (`persistence.enabled: false` + `allkeys-lru`): its in-memory store holds rate-limiter counters, tokens and pub/sub, all reconstructible, and the cost it accepts is that the limiters fail open under memory pressure. `paperless-ngx` runs it as a **queue** (PVC + `noeviction`, the subchart's defaults): the broker's contents are work, and a dropped consume task is a document that is silently never processed. `allkeys-lru` would not be a trade there but actively wrong, because LRU evicts typeless across the whole keyspace and a backlog of waiting tasks is exactly what it reads as cold. The price `paperless-ngx` accepts instead: the same instance also serves the Django cache and the Celery result backend (both Redis in 3.x), so a full instance **refuses writes** — new tasks included — rather than dropping cold keys, and the answer is memory sizing, not a policy change. The valkey chart treats the two values as a **pair** and warns against mixing them; these two charts are the worked examples of picking either half deliberately.
**A dependency on one of our own charts is cheaper in one way and more dangerous in another.** Point 2 stops applying: there are no third-party defaults to leave unpatched, because the subchart follows these guidelines by being one of ours. In exchange it adds a hazard an external dependency does not have, and it is sharp enough to be a rule:
> **Never change a chart and a chart that depends on it in the same pull request.**
The dependency resolves from the **published index**, not from the working tree, and a chart is published only by a push to `main` that touches it. So a combined PR runs every `helm` command — lint, package, render **and the install-test** — against the **old** subchart, goes green on an artifact that is not the one in the diff, and leaves the subchart's own change exercised by nothing. Two consecutive PRs: change the subchart, merge, let it publish, then raise the `version:` in the dependent chart.
Two mechanical details of a self-referencing dependency, both measured for #65: the index is **anonymously readable**, so `ci/helm-dependencies.sh` needs no credential and resolves the exact build-metadata version (`1.0.0+up9.1.2.alpine`) from the URL alone with an empty `HELM_REPOSITORY_CONFIG` — the state a fresh runner is in. And Renovate's built-in `helmv3` manager does follow a self-hosted Gitea index: a dry run (Renovate 44.132.2) with the dependency pinned low reported `newVersion: "1.0.0+up9.1.2.alpine"`, `versioning: "helm"`, timestamp read from our index. Without that second result the published-version form would have had no update path at all, which is why it was measured rather than assumed.
One more trap, and it is the reason `stalwart` gates its subchart on `valkeySubchart.enabled` rather than the conventional `valkey.enabled`: **everything under the subchart's key is handed to the subchart as its values**, so a `condition:` key placed there is validated against *its* schema. Our charts ship `additionalProperties: false`, so `valkey.enabled` is rejected by the subchart — and because Helm skips a **disabled** subchart's schema validation, the error surfaces only for whoever first switches it on. Put the condition on a key of the parent's own.
**When a dependency earns its place.** All three, not two of three:
1. The dependent piece would otherwise be a second Helm release that only exists to serve this one, with the wiring between them living in neither chart.
-17
View File
@@ -1,17 +0,0 @@
# Fixtures for the convertx install-test: the Secret the 1Password operator
# would otherwise materialize from the OnePasswordItem (release name: ci).
#
# Without it the pod stays in CreateContainerConfigError - the StatefulSet
# reads JWT_SECRET from this Secret's "jwt-secret" key, and the stub
# OnePasswordItem CRD in ci/common/ has no controller behind it. The key name
# here is therefore load-bearing and has to match the chart's secretKeyRef.
#
# ConvertX needs no external database, cache or storage service: its SQLite
# file, uploads and output all live on the chart's own PVC, so this is the
# whole fixture set.
apiVersion: v1
kind: Secret
metadata:
name: ci-convertx-secret
stringData:
jwt-secret: convertx-ci-jwt-secret-0123456789abcdef0123456789abcdef
-23
View File
@@ -1,23 +0,0 @@
# The image is ~1.4 GiB compressed - by a wide margin the largest in this
# repo, because LibreOffice, Calibre, ImageMagick, ffmpeg, Inkscape and a TeX
# Live installation are all inside it. One layer alone is 1354 MiB.
#
# The rollout wait therefore has to cover the PULL, not just the start: the
# runner reference point is 96 MB in 17.5s, which puts this image in the
# region of five minutes of pulling before the container is even created. On
# top of that comes the startup probe budget (30 x 10s = 300s), which is
# generous because ConvertX shells out to ~20 converter binaries at import
# just to log their versions.
#
# MEASURED, and the estimate was wrong in the safe direction: the first green
# install-test of this chart (Gitea PR #61) took 2m35s for the WHOLE job -
# cluster creation, fixtures, pull, install and rollout wait together. The
# 96 MB/17.5s reference does not transfer to ghcr.io on this runner.
#
# 900s stays anyway, and deliberately. A rollout-wait timeout is a ceiling,
# not a cost: kubectl waits exactly as long as the rollout needs, so a
# generous number is free on a good day and only spends time on a chart that
# is already failing. What it buys is the bad day - a cold registry, or a node
# that has to extract the 1354 MiB layer onto slow disk. The default of 300s
# would have no headroom at all for either.
INSTALL_TIMEOUT=900
-15
View File
@@ -1,15 +0,0 @@
# The OnePasswordItem CR only needs a non-null item path; the actual
# Kubernetes Secret comes from ci/convertx/fixtures.yaml.
secrets:
jwtSecretRef: op://ci/convertx-jwt
ingress:
domain: convertx.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
# The chart's default is 10Gi; the install-test converts nothing, so 1Gi is
# enough and provisions faster on k3d's local-path class.
storage:
amount: 1Gi
+43 -42
View File
@@ -1,11 +1,19 @@
# Fixtures for the paperless-ngx install-test: throwaway postgres + redis,
# the secrets the 1Password operator would otherwise materialize (release
# name: ci), and storage plumbing. The chart hardcodes storageClassName
# Fixtures for the paperless-ngx install-test: a throwaway postgres, the
# secrets the 1Password operator would otherwise materialize (release name:
# ci), and storage plumbing. The chart hardcodes storageClassName
# "longhorn" (RWX for the consume PVC), which does not exist in k3d: a
# no-provisioner StorageClass named "longhorn" plus pre-bound hostPath PVs
# stand in for it. Paperless runs as uid 1000 with a read-only rootfs and
# hostPath volumes do not support fsGroup, so a node-setup Job pre-creates
# the exact subPath directories world-writable.
#
# THERE IS NO THROWAWAY REDIS HERE ANY MORE, and that is the change this file
# carries: the release brings its own Valkey through the subchart
# (ci/paperless-ngx/test-values.yaml says what that proves), so the broker under
# test is the chart's own object rather than a Deployment next to it. Leaving a
# redis Deployment in place would have been the worse of the two: the url would
# have had to name one of them, and whichever it named, the other would have sat
# there making the run look like it covered something it did not.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
@@ -76,12 +84,43 @@ spec:
path: /var/lib/ci-pv/paperless-ngx/consume
type: DirectoryOrCreate
---
# The two Secrets below stand in for ONE 1Password item. Both the chart's
# redis OnePasswordItem and the subchart's auth OnePasswordItem point at
# op://ci/paperless-redis, and a OnePasswordItem materializes EVERY field of the
# item it names - so in a real cluster both Secrets carry all three fields. They
# are written out that way here rather than trimmed to what each consumer reads,
# because the trimmed version would have been a fixture that is tidier than
# reality and would have hidden the only thing worth knowing: the extra keys are
# unused, not a leak and not a bug.
apiVersion: v1
kind: Secret
metadata:
name: ci-paperless-ngx-redis-secret
stringData:
redis-connection-url: redis://:cipass@redis:6379
# Names the SUBCHART's Service, which is the mandatory vault step of this
# configuration (NOTES.txt prints it). A wrong name here is the failure this
# install-test exists to catch: paperless would come up healthy and process
# nothing.
redis-connection-url: redis://:cipass@ci-valkey-service:6379
key-prefix: ci
# Same password as in the url above - that duplication is the chart's
# documented trap 1, and the fixture is the cheapest place to keep it honest:
# let the two drift and the run goes red on authentication.
auth-conf: requirepass cipass
---
# What the subchart's own OnePasswordItem (ci-valkey-secret) would become. The
# key the Valkey actually mounts is "auth-conf", and its value has to be a
# COMPLETE configuration line, because the Valkey configuration includes the
# file: give it anything that is not "requirepass <password>" and the init
# container refuses to start the pod, which is the failure that chart exists to
# make loud.
apiVersion: v1
kind: Secret
metadata:
name: ci-valkey-secret
stringData:
auth-conf: requirepass cipass
redis-connection-url: redis://:cipass@ci-valkey-service:6379
key-prefix: ci
---
apiVersion: v1
@@ -153,41 +192,3 @@ spec:
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
+69
View File
@@ -1,4 +1,36 @@
# CI values for the paperless-ngx install-test.
#
# THE SUBCHART IS ON, AND THAT IS THE POINT OF THIS FILE. Gitea #65 added the
# same valkey dependency to stalwart but covered it by a local RENDER only -
# deliberately, because Stalwart's in-memory store is an object in its database,
# so a Valkey in that namespace would have been reachable and unused and the
# test would have looked like it covered the clustering path while covering
# nothing of it. Here it is the opposite: paperless-ngx reads PAPERLESS_REDIS
# from the vault item, so pointing that url at the subchart's Service makes the
# bundled Valkey the broker this release actually uses. So this is the first
# install of the valkey-subchart path anywhere, and what it proves is:
#
# 1. The dependency resolves from the published index on a real runner with no
# credentials, and lint/package/render/install all survive it.
# 2. The subchart's objects are accepted inside a parent release: its
# StatefulSet becomes Ready, which means its init container found a
# "requirepass " line in the mounted auth file and its three exec probes
# authenticated and got PONG back.
# 3. The PVC path of the subchart works under a parent release name
# (ci-valkey-*), i.e. the names really are <release>-valkey-<kind>.
# 4. paperless-ngx talks to it: the url in the fixture Secret names
# ci-valkey-service, so a wrong Service name or a password mismatch between
# the two fields of the "item" shows up as a failing run instead of as a
# quietly idle Valkey.
#
# What it still CANNOT prove is that documents are processed - that needs a real
# consume run and is out of scope here, as before.
secrets:
# One item for both, which is the configuration the chart is designed around:
# redis-connection-url, key-prefix AND auth-conf live in the same 1Password
# item. In CI the operator does not exist, so ci/paperless-ngx/fixtures.yaml
# stands in for it - and it stands in TWICE, because a OnePasswordItem
# materializes every field of the item it names. See the fixture.
redisSecretRef: op://ci/paperless-redis
databaseSecretRef: op://ci/paperless-db
paperlessSecretRef: op://ci/paperless
@@ -16,5 +48,42 @@ paperless:
# neither erase them nor break rendering.
barcodes:
valkeySubchart:
enabled: true
valkey:
secrets:
# The SAME op:// path as secrets.redisSecretRef above, typed out again
# because Helm cannot default one value from another (Gitea #78). Typing it
# here is also what exercises the parent's guard in the "satisfied"
# direction; the failing direction is covered by a local render, since the
# harness installs one configuration per chart.
authSecretRef: op://ci/paperless-redis
persistence:
# QUEUE, the chart's and the subchart's default - and the risky half, which
# is why it is not switched off here: a provisioned PVC, the pod's fsGroup
# against a volume it did not create, and an append-only file written and
# replayed on it.
enabled: true
appendonly: true
# k3d's dynamic provisioner. The subchart's default, longhorn-encrypted-ns,
# does not exist on the runner, and the no-provisioner "longhorn" class the
# paperless fixtures define is pre-bound to hostPath PVs for paperless' own
# claims - a hostPath volume gets no fsGroup from the kubelet at all, so
# reusing it here would silently stop testing the ownership path.
storageClass: local-path
size: 128Mi
valkey:
resources:
# Small, to stay inside the runner's budget, while still exercising the
# subchart's derivation: 64Mi x limitFactor 3 = 192Mi limit, maxmemory =
# 0.75 x that = 144Mi.
requests:
cpu: 50m
memory: 64Mi
ephemeral-storage: 32Mi
# smbConnection and oidc stay disabled (defaults): the SMB CSI driver and a
# real OIDC login flow are out of scope for the CI install-test.
+104
View File
@@ -0,0 +1,104 @@
# Fixtures for the stalwart install-test: a throwaway Postgres, the one Secret
# the 1Password operator would otherwise materialize, and a stub CRD for
# cert-manager's Certificate (release name: ci; the OnePasswordItem stub CRD
# comes from ci/common/).
#
# NO VALKEY HERE, DELIBERATELY. The in-memory store is an object in Stalwart's
# DATABASE, not a chart value, so a Valkey in this namespace would be reachable
# and unused - it would make the test look like it covered the clustering path
# while covering nothing of it. The test runs a single node (nodes.full
# replicas 1, nodes.mx replicas 0), which is the configuration that needs no
# in-memory store at all. The subchart path is covered by a local render only,
# named as a known gap in the PR that introduced this chart.
#
# A stub CRD for Certificate rather than certificate.enabled: false, because
# switching the Certificate off would also drop the volume and volumeMount that
# carry it - and the mount is on the risky side of readOnlyRootFilesystem. With
# the stub the object applies, the Secret it names never appears, and the
# optional: true on the volume is exercised for real: the pods must start
# without it. That is the behaviour an operator meets on every first install,
# before cert-manager has answered the challenge.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: certificates.cert-manager.io
spec:
group: cert-manager.io
names:
kind: Certificate
listKind: CertificateList
plural: certificates
singular: certificate
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
x-kubernetes-preserve-unknown-fields: true
---
apiVersion: v1
kind: Secret
metadata:
name: ci-stalwart-datastore-secret
stringData:
# The ONLY field this chart reads from the data store item. config.json
# references it by variable name through
# "authSecret": {"@type":"EnvironmentVariable","variableName":"STALWART_DB_PASSWORD"}
# so this value has to match the throwaway Postgres below. A real password
# rather than an empty string on purpose: upstream's resolver treats an empty
# environment variable as "not found" and fails the store open, so an empty
# value here would fail the run for the right reason in the wrong place.
password: ci-stalwart-throwaway-password
---
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: stalwart
- name: POSTGRES_PASSWORD
value: ci-stalwart-throwaway-password
- name: POSTGRES_DB
value: stalwart
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["pg_isready", "-U", "stalwart"]
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
+123
View File
@@ -0,0 +1,123 @@
# CI values for the stalwart install-test.
#
# WHAT THIS TEST CAN AND CANNOT PROVE — read this before trusting a green run.
#
# It CANNOT prove a working mail server, and no install-test here could. Since
# v0.16 every listener, the blob store, the in-memory store, the TLS object and
# the cluster roles live in Stalwart's DATABASE and are created by a human in
# the WebUI. Against an empty database there is no SMTP service to test, so a
# green run says nothing about mail.
#
# What it DOES prove is the part that is the chart's own responsibility, and the
# first item is the reason this chart is tested rather than excluded:
#
# 1. THE CONTAINER EXECS AT ALL. The image carries
# "setcap cap_net_bind_service=+ep" on its binary, which makes the kernel
# fail execve itself when the capability is not in the bounding set. The
# first draft of this chart had capabilities.drop: [ALL] with nothing
# added and would have failed with "operation not permitted" in every
# install - caught by reading the Dockerfile, but exactly the class of
# failure an install-test exists to catch rather than reason about.
# 2. readOnlyRootFilesystem: true holds with the two image volumes plus /tmp
# as emptyDirs, and the init container really can write config.json into
# the /etc/stalwart emptyDir.
# 3. The rendered config.json parses AS STALWART READS IT, not just as JSON:
# the server opens the data store, which means "@type": "PostgreSql", the
# integer-millisecond timeout and the authSecret EnvironmentVariable
# indirection are all accepted. This is the one check that the password
# really arrives without ever being in the ConfigMap.
# 4. The Secret key names match what the templates reference, the ConfigMap
# mount works, the NodePorts can be allocated, the resource quantities are
# valid, the schema accepts a realistic (non-default) values file, and the
# NetworkPolicy objects are accepted without cutting off the probes.
#
# RECOVERY MODE IS ON, and that is not a shortcut - it is the only mode that can
# work here. The bootstrap wizard appears only when config.json is ABSENT, and
# this chart always renders one; upstream's documented path for a templated
# config.json is to start once with STALWART_RECOVERY_MODE=1. Recovery mode
# brings up the management HTTP endpoint and keeps the mail ports closed, which
# is precisely the reachable surface the probes use.
#
# THE ONE THING THIS FILE COULD NOT BE VERIFIED AGAINST: whether /healthz/live
# and /healthz/ready are served on the RECOVERY listener. The source shows them
# handled in the general HTTP request path with no auth and no network
# restriction, but recovery mode binds a single listener and skips most config
# parsing, and there is no cluster in the authoring environment to try it on.
# If the install-test goes red with the pods never becoming Ready, that is the
# reason, and the fix is either an exec probe or an entry in
# ci/excluded-charts.txt - see the PR for #65.
bootstrap:
enabled: true
logLevel: debug
dataStore:
postgres:
host: postgres
database: stalwart
username: stalwart
tls:
# OFF, against the chart's own default of true. The throwaway Postgres in
# fixtures.yaml serves no TLS, and the alternative - allowInvalidCerts -
# would be worse: it would pass whether or not TLS worked, so a chart
# change that broke the TLS path would still go green. A deviation that
# fails loudly beats one that hides.
enable: false
allowInvalidCerts: false
secrets:
# Materialized by ci/stalwart/fixtures.yaml as ci-stalwart-datastore-secret.
dataStoreSecretRef: op://ci/stalwart-datastore
# Both deliberately unset: neither is needed to bring a pod up, and leaving
# them out exercises the conditional env and OnePasswordItem blocks in the
# "absent" direction, which is the shape a minimal install really has.
recoveryAdminSecretRef: null
s3SecretRef: null
ingress:
# Left ON although no ingress controller answers in CI: the point is that the
# object is accepted and that the per-group http Service it names actually
# exists - a selector typo there is otherwise invisible until someone opens
# the URL.
enabled: true
domain: stalwart.ci.example.com
clusterIssuer: ci-selfsigned
nodeGroup: full
certificate:
# On, against a STUB CRD (fixtures.yaml). Nothing issues the Secret it names,
# which is the interesting case: the volume is mounted optional, so the pods
# must start without it. That is what every real first install looks like
# before cert-manager has answered the challenge.
enabled: true
clusterIssuer: ci-selfsigned
networkPolicy:
# Left ON. probeSources stays empty, so the http rule renders without a
# "from" selector - the configuration this chart ships. Whether the policy
# actually FILTERS is a property of the cluster's CNI and is not claimed by
# this test; what is claimed is that the objects are accepted and that they
# do not keep the kubelet's probes from working.
enabled: true
nodes:
full:
replicas: 1
exposedPorts: [smtp, submissions, imaps, http]
resources:
# Small, to stay inside the runner's budget, while still exercising the
# limit derivation: 256Mi x limitFactor 3 = 768Mi.
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 64Mi
mx:
# Stays at 0, which is also the chart default. Two reasons rather than one:
# a second node would need an in-memory store (the render aborts without
# one), and the "mx" ClusterRole does not exist in an empty database, so a
# running mx pod would fail-open into a second full node and the two would
# fight over the same NodePort-backed SMTP Service. The StatefulSet is
# still created and the harness still waits on it - a 0-replica rollout
# completes immediately, so this covers the explicit-zero path of the
# replicas helper.
replicas: 0
exposedPorts: [smtp, http]
-12
View File
@@ -1,12 +0,0 @@
apiVersion: v2
name: convertx
description: A Helm chart to deploy ConvertX on Kubernetes
icon: https://raw.githubusercontent.com/C4illin/ConvertX/main/public/android-chrome-512x512.png
type: application
version: 1.0.0+upv0.19.0
appVersion: "v0.19.0"
maintainers:
- name: jklein
-224
View File
@@ -1,224 +0,0 @@
{{- $replicas := include "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.replicas) -}}
{{- $startup := fromYaml (include "convertx.startupProbe" .Values.convertx.startupProbe) -}}
{{- $autoDelete := include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours -}}
{{- $registration := include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") -}}
{{- $unauthenticated := include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") -}}
{{- $httpAllowed := include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") -}}
ConvertX {{ .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
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate,
and neither ingress.clusterIssuer nor ingress.domain is needed. ConvertX has
no base-URL setting of its own — it builds no absolute links — so nothing
outside the Ingress reads the domain in this chart.
Reach the release inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
and open http://localhost:8080. Note that the session cookie is marked
"secure"{{ if eq $httpAllowed "true" }} — except that convertx.httpAllowed is true in this release, which
clears that flag, so logging in over plain http works{{ else }}, so logging in over plain http will NOT work: the browser
refuses to store the cookie. convertx.httpAllowed: true clears that flag{{ end }}.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}convertx.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so the endpoint
above does not answer until you set it back and upgrade again. PVC, Secret{{ if .Values.ingress.enabled }},
Service and Ingress stay in place, so neither the conversions nor the
certificate are touched.{{ else }} and
Service stay in place, so the conversions on the PVC are untouched.{{ end }}
{{ end }}
WHOEVER OPENS THE PAGE FIRST BECOMES THE ADMINISTRATOR
A fresh ConvertX has no users, and with no users it serves a SETUP FORM to
every visitor — no password, no invitation, nothing to bypass. The first
account created that way owns the instance, and convertx.accountRegistration
does not gate it: that value decides whether accounts TWO and onwards can be
created, not the first one.
So on a fresh install, register yourself before the DNS name is reachable by
anyone else. There is no second chance and no "reset admin" command; the
repair is deleting the users row from the SQLite database on the PVC.
{{- if eq $unauthenticated "true" }}
This release has convertx.allowUnauthenticated: true, so the point above is
moot in the worst way — see the warning further down.
{{- end }}
THE 1PASSWORD ITEM MUST CARRY THE FIELD THE CHART READS
secrets.jwtSecretRef points at
{{ .Values.secrets.jwtSecretRef }}
The 1Password operator materializes that item as the Kubernetes Secret
{{ .Release.Name }}-{{ .Chart.Name }}-secret, with one Secret key per FIELD of the item. So the field
name is not free-form: this chart reads exactly one field, and it must be
spelled
jwt-secret a long random string — openssl rand -hex 32
An item without it does not fail the install. The release goes through and
the pod then hangs in CreateContainerConfigError naming the key it could not
find. 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
WHY THIS VALUE IS REQUIRED RATHER THAN OPTIONAL: ConvertX signs its session
cookies with JWT_SECRET, and when the variable is unset it quietly generates
a random one PER PROCESS. Every restart — a node drain, an image update, an
OOM kill — would then invalidate every session, with no log line and no
error. Rotating the field has the same effect on purpose; after a rotation:
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
Editing the value INSIDE the item does not roll the pod by itself (Helm
never sees a secret's content). Pointing secrets.jwtSecretRef at a DIFFERENT
item does — the pod template carries a checksum over the rendered
OnePasswordItem.
TWO SWITCHES THAT OPEN THE INSTANCE UP
convertx.accountRegistration {{ $registration }}
convertx.allowUnauthenticated {{ $unauthenticated }}
Upstream warns explicitly against enabling either on an instance reachable
from the internet, and this chart puts a public DNS name in front by
default. accountRegistration lets anyone with the URL create an account;
allowUnauthenticated removes the login entirely, so anyone who reaches the
Service can upload files and read every conversion on the volume.
{{- if or (eq $registration "true") (eq $unauthenticated "true") }}
*** {{ if and (eq $registration "true") (eq $unauthenticated "true") }}BOTH OF THEM ARE ON{{ else }}ONE OF THEM IS ON{{ end }} IN THIS RELEASE. ***
{{- if eq $unauthenticated "true" }}
convertx.allowUnauthenticated: true — there is no login. Anyone who can
reach {{ if .Values.ingress.enabled }}https://{{ .Values.ingress.domain }}{{ else }}the Service{{ end }} can use it and read other people's files.
{{- end }}
{{- if eq $registration "true" }}
convertx.accountRegistration: true — anyone with the URL can sign up.
{{- end }}
If this instance is reachable from the internet, that is almost certainly
not what you wanted.
{{- end }}
RETENTION IS A VALUE, AND IT IS THE ONLY THING THAT EMPTIES THE VOLUME
{{- if eq $autoDelete "0" }}
convertx.autoDeleteEveryNHours is 0 — THE CLEANUP IS OFF. Uploads and
converted output accumulate on the PVC for as long as the release lives and
nothing reclaims them. Watch the volume, or set a non-zero number of hours.
{{- else }}
convertx.autoDeleteEveryNHours is {{ $autoDelete }} — ConvertX deletes the uploads and the
output of any job older than {{ $autoDelete }} hours. That is a hard delete, not an
archive: a conversion nobody downloaded in time is gone. Set it to 0 to
switch the cleanup off, and then size storage.amount for unbounded growth.
{{- end }}
STORAGE
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, {{ .Values.storage.amount }}, {{ if .Values.storage.storageClass }}storage class "{{ .Values.storage.storageClass }}"{{ else }}on the cluster's default storage class
(storage.storageClass is unset){{ end }}, mounted at /app/data.
Everything this release persists is on that one volume: the SQLite database
(users, jobs, file names), every uploaded file and every converted result.
There is no external database and no object storage, so one snapshot of the
PVC is a complete backup — and losing it loses the accounts along with the
files.
Because the PVC is rendered by the chart, helm uninstall deletes it and the
data with it, unless the storage class reclaim policy says otherwise.
{{- if gt (int $replicas) 1 }}
convertx.replicas is {{ $replicas }}, and that does not work. The PVC is ReadWriteOnce, so
the extra pods normally stay Pending on "Multi-Attach error for volume"; on
a genuinely RWX storage class they would start and then corrupt the SQLite
database two processes are now writing. Set it back to 1.
{{- end }}
{{- if .Values.ingress.enabled }}
THE INGRESS IS THE ONLY UPLOAD CEILING
ingress.maxUploadFileSize {{ .Values.ingress.maxUploadFileSize }}
ConvertX enforces no size limit of its own — none, at any setting — so this
annotation is the whole story. A file above it never reaches the
application: the user gets the ingress controller's 413 page and nothing
appears in the pod log, which makes it look like a broken upload button
rather than a limit.
Raising it has a second cost: the upload lands on the PVC above, so a
ceiling far beyond storage.amount just moves the failure from a 413 to a
full volume.
{{- else }}
UPLOAD CEILING — NONE FROM THIS CHART
ingress.enabled is false, so the chart sets no body-size limit anywhere, and
ConvertX enforces none of its own. Whatever proxy you put in front brings
its ceiling with it — ingress-nginx defaults to 1m, small enough to look
like a broken upload button.
{{- end }}
WHAT A GREEN POD PROVES
All three probes are HTTP GETs against /healthcheck, which ConvertX answers
with a static 200 and no authentication. It touches neither the database nor
the volume, so a green pod proves the process is serving — not that a
conversion works and not that the volume is writable.
It is deliberately NOT "/": with the defaults of this chart "/" answers 302,
to /setup while no account exists and to /login afterwards. A probe would
pass on that redirect, and pass on a different one from day two onwards.
The startup probe grants {{ $startup.failureThreshold }} × {{ $startup.periodSeconds }}s = {{ mul $startup.failureThreshold $startup.periodSeconds }}s. That budget is generous because
ConvertX shells out to about twenty converter binaries at startup just to
log their versions.
THE FIRST PULL IS SLOW, AND THAT IS NORMAL
The image is roughly 1.4 GiB compressed — LibreOffice, Calibre, ImageMagick,
ffmpeg, Inkscape and a TeX Live installation are all inside it. On a node
that has not seen it before, expect several minutes in ContainerCreating
before the startup probe budget above even begins. A pod that looks stuck
there is usually still pulling:
kubectl -n {{ .Release.Namespace }} describe pod {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0
THIS CHART BYPASSES THE IMAGE ENTRYPOINT
The image runs as root and drops privileges itself; this chart starts the
application directly (command: bun run dist/src/index.js) as uid 1000, with
fsGroup doing what the entrypoint's chown would have done. PUID, PGID and
UMASK therefore configure NOTHING here — setting them through convertx.env
has no effect at all, and the uid belongs in convertx.securityContext.pod.
The cost to know about: if upstream moves real work into that entrypoint,
this chart skips it silently. Re-read the upstream entrypoint.sh at every
app-version update.
{{- if .Values.convertx.env }}
EXTRA ENVIRONMENT VARIABLES ARE ACTIVE
convertx.env adds {{ len .Values.convertx.env }} variable(s), rendered after the ones the chart sets
itself. Two warnings that go with that block:
- WEBROOT moves every route of the application, /healthcheck included, so
it breaks all three probes while the app is perfectly healthy — and this
chart's Ingress still maps "/". Do not set it here.
- An entry repeating a name the chart already sets is additive, not a
replacement. At install yours wins; on an EXISTING release Kubernetes
merges env by name and the chart's own value survives instead, while helm
upgrade still reports success. Read it back off the StatefulSet.
{{- end }}
{{- if .Values.ingress.enabled }}
{{- with .Values.ingress.annotations }}
EXTRA INGRESS ANNOTATIONS ARE ACTIVE
{{- range $key, $value := . }}
{{ $key }}: {{ $value }}
{{- end }}
These are merged with the cert-manager and proxy-body-size annotations the
chart always sets. Annotations are interpreted by the ingress controller, so
a misspelled key is silently ignored — it does not fail the render.
{{- end }}
{{- end }}
-327
View File
@@ -1,327 +0,0 @@
{{/*
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. template-chart/templates/_helpers.tpl is the reference implementation.
*/}}
{{- define "convertx.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 "convertx.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "convertx.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "convertx.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 "convertx.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 "convertx.multiplyQuantity" -}}
{{- $quantity := include "convertx.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 "convertx.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 "convertx.formatNumber" -}}
{{- if kindIs "float64" . -}}
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
{{- else -}}
{{- printf "%v" . -}}
{{- end -}}
{{- end -}}
{{/*
Render the replica count for the StatefulSet.
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 "convertx.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.
This helper defines the opposite semantics: an empty block means "chart
defaults apply".
*/}}
{{- define "convertx.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 "convertx.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.convertx.securityContext" can itself be erased, and indexing into nil
inside a template is not an error but silently yields nothing.
*/}}
{{- define "convertx.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).
These defaults 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.
TWO OF THEM REPRODUCE A DELIBERATE DEVIATION AND MUST NOT BE "TIDIED":
- runAsUser / runAsGroup are 1000, not the repo's arbitrary 20000. The image
creates its convertx user at uid 1000 and owns /home/convertx with it, and
that uid owns the data on the PersistentVolume. Restoring the repo default
here would move the runtime uid off the volume's data.
- the probes point at /healthcheck, not at "/". "/" answers 302 in every
default configuration of this chart, and the redirect target changes once
the first account exists. The port name "http" is part of the context and
belongs here too: a default that loses it leaves the workload with a probe
that cannot succeed.
*/}}
{{- define "convertx.podSecurityContext" -}}
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "pod" "path" "convertx.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 1000
"runAsGroup" 1000
"fsGroup" 1000
"fsGroupChangePolicy" "Always"
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.pod") -}}
{{- end -}}
{{- define "convertx.containerSecurityContext" -}}
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "container" "path" "convertx.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.container") -}}
{{- end -}}
{{- define "convertx.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthcheck" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.livenessProbe") -}}
{{- end -}}
{{- define "convertx.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthcheck" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.readinessProbe") -}}
{{- end -}}
{{- define "convertx.startupProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthcheck" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.startupProbe") -}}
{{- end -}}
{{/*
The effective resources block, 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.
The numbers reproduce values.yaml, which carries the reasoning: they are sized
for converter subprocesses (LibreOffice, Calibre, ImageMagick, ffmpeg), not
for an idle web server, and the ephemeral-storage figure has to cover the two
emptyDirs this chart mounts.
*/}}
{{- define "convertx.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.25 "memory" "1Gi" "ephemeralStorage" "1Gi") -}}
{{- $merged := fromYaml (include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.resources")) -}}
{{- include "convertx.resources" $merged -}}
{{- end -}}
{{/*
Render a values boolean as the literal string the application accepts.
Input: dict "value" <bool|string|nil> "path" <values path, for the error>
"default" <bool, used when the value is nil>
Why this is not just "{{ .Values.x }}" (README, "Values that must not lie",
point 6): ConvertX parses each of these variables as
process.env.X?.toLowerCase() === "true"
so ANYTHING that is not the literal "true" means false - "yes", "1", "on",
"TRUE " with a trailing space. Two of the three variables this helper renders
open the instance up (ACCOUNT_REGISTRATION, ALLOW_UNAUTHENTICATED), and a
value written to switch one of them ON that silently lands on OFF is the kind
of failure nobody goes looking for, because the symptom is the safe state.
So: a bool renders as "true"/"false", the strings "true"/"false" render
normalized to lower case, nil takes the given default, and everything else
ABORTS THE RENDER naming the values path. Go's "not" is deliberately unused
here - it treats every non-empty string as true, so "not \"false\"" is false
and the string form would flip the wrong way.
*/}}
{{- define "convertx.boolString" -}}
{{- $v := .value -}}
{{- if kindIs "invalid" $v -}}
{{- $v = .default -}}
{{- end -}}
{{- if kindIs "bool" $v -}}
{{- ternary "true" "false" $v -}}
{{- else -}}
{{- $s := lower (printf "%v" $v) -}}
{{- if or (eq $s "true") (eq $s "false") -}}
{{- $s -}}
{{- else -}}
{{- fail (printf "%s must be true or false, got %q - ConvertX compares this variable against the literal string \"true\", so any other spelling would silently mean false" .path $v) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Render AUTO_DELETE_EVERY_N_HOURS.
An explicit 0 is the documented way to switch the cleanup off, so this cannot
go through Helm's "default": that swallows a 0 and would restore 24-hour
deletion on exactly the install that asked for none. Same nil-check shape as
the replicas helper, and for the same reason.
*/}}
{{- define "convertx.autoDeleteEveryNHours" -}}
{{- if kindIs "invalid" . -}}
24
{{- else -}}
{{- include "convertx.formatNumber" . -}}
{{- end -}}
{{- end -}}
-34
View File
@@ -1,34 +0,0 @@
{{- 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 }}
# ConvertX enforces no upload limit of its own (it starts its HTTP server
# with maxRequestBodySize: Number.MAX_SAFE_INTEGER), so these two
# annotations are the ONLY ceiling on an upload. Both spellings are set
# because the two nginx ingress controllers read different keys.
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.ingress.maxUploadFileSize }}
nginx.org/client-max-body-size: {{ .Values.ingress.maxUploadFileSize }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ required "A Domain/Host must be provided" .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" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
-13
View File
@@ -1,13 +0,0 @@
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
@@ -1,6 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
spec:
itemPath: {{ required "A Secret-Reference for the JWT signing key must be provided (secrets.jwtSecretRef)" .Values.secrets.jwtSecretRef }}
-13
View File
@@ -1,13 +0,0 @@
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
-201
View File
@@ -1,201 +0,0 @@
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 "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.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:
# Hash of the rendered OnePasswordItem, so that repointing
# secrets.jwtSecretRef at a DIFFERENT vault item really rolls the pod
# instead of leaving the old signing key in the running environment.
# A change of the item's CONTENT inside the vault is not covered -
# Helm never sees it; see the comment on secrets in values.yaml.
# The name differs from checksum/config on purpose: that one promises
# "the config changed", this one only "the config source changed".
#
# operator.1password.io/auto-restart is deliberately NOT set: the
# 1Password operator only restarts Deployments, so on a StatefulSet it
# is a silent no-op that advertises a guarantee it does not give.
checksum/secret-ref: {{ include (print $.Template.BasePath "/convertx.secret.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "convertx.podSecurityContext" .Values.convertx.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
image: "ghcr.io/c4illin/convertx:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
# THE IMAGE ENTRYPOINT IS BYPASSED ON PURPOSE. READ THIS BEFORE THE
# NEXT APP-VERSION BUMP - IT IS THE ONE BET THIS CHART MAKES.
#
# ghcr.io/c4illin/convertx declares
# ENTRYPOINT ["/entrypoint.sh"]
# CMD ["bun", "run", "dist/src/index.js"]
# and carries no USER, so the image runs as root by default and
# /entrypoint.sh is what drops it to uid 1000 with gosu. Setting
# "command" here replaces the ENTRYPOINT (and discards the CMD), so
# the application is started directly and Kubernetes does the
# privilege work instead: runAsUser/runAsGroup 1000 in the pod
# security context, and fsGroup 1000 in place of the entrypoint's
# recursive chown of /app/data. The hardening baseline of this repo
# stays intact - no root, no capabilities, read-only root fs.
#
# What the entrypoint of v0.19.0 actually does, read rather than
# assumed: nothing but uid/gid plumbing. No migration, no config
# generation, no secret generation. ConvertX creates and migrates
# its SQLite schema in-process at import time, so starting it
# directly is migration-safe. Its very first branch is
# if [ -z "$PUID" ] || [ "$PUID" -eq 0 ]; then ... exec "$@"
# i.e. with PUID unset it is already a pure pass-through - which is
# why bypassing it costs nothing TODAY and is exactly why the risk
# below is about tomorrow. The one line we do lose is
# "export HOME=/home/convertx", and this chart sets HOME itself
# below rather than relying on the runtime's passwd lookup.
#
# THE RISK: if upstream ever moves real work into the entrypoint - a
# data migration is the obvious candidate - this chart skips it
# silently. There is no error, no log line and no failed probe; the
# application simply starts against an unmigrated volume. So
# RE-READ entrypoint.sh AT EVERY APP-VERSION UPDATE, not only on
# major jumps, and if it has grown anything beyond uid/gid handling,
# stop and reconsider this override.
command:
- bun
- run
- dist/src/index.js
# Not decoration. The application resolves its data paths relative
# to the working directory - uploadsDir is "./data/uploads/" and
# outputDir is "./data/output/", hardcoded and configurable by no
# environment variable. The image sets WORKDIR /app, so this only
# restates it; it is here because a different working directory
# would put every upload somewhere other than the mounted PVC, and
# the application would report no error at all.
workingDir: /app
env:
# The entrypoint would have exported this; the image declares no
# ENV HOME, so without this line HOME comes from the container
# runtime's /etc/passwd lookup for uid 1000. That happens to
# resolve to /home/convertx in this image - and "happens to" is
# not a basis for the directory LibreOffice, Calibre, Inkscape and
# fontconfig write their profiles into on the first conversion.
# Set explicitly, and mounted writable below.
- name: HOME
value: "/home/convertx"
# The port the application binds. containerPort, the Service's
# targetPort and the three probes all name the same 3000; moving
# it means moving all four, which is why it is pinned here rather
# than exposed as a value.
- name: PORT
value: "3000"
# Signs the session cookies. Without it ConvertX falls back to
# randomUUID() per process, so every restart logs everyone out -
# see secrets.jwtSecretRef in values.yaml.
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
key: jwt-secret
# The three booleans go through convertx.boolString, which renders
# the literal "true"/"false" the application's strict comparison
# needs and aborts the render on anything else.
- name: ACCOUNT_REGISTRATION
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") }}"
- name: ALLOW_UNAUTHENTICATED
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") }}"
- name: HTTP_ALLOWED
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") }}"
- name: AUTO_DELETE_EVERY_N_HOURS
value: "{{ include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours }}"
{{- range $value := .Values.convertx.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "8080" or "true"
# renders unquoted as a YAML number/bool, and the API server then
# rejects the whole manifest.
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
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 "convertx.livenessProbe" .Values.convertx.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "convertx.readinessProbe" .Values.convertx.readinessProbe | nindent 12 }}
startupProbe:
{{- include "convertx.startupProbe" .Values.convertx.startupProbe | nindent 12 }}
resources:
{{- include "convertx.effectiveResources" .Values.convertx.resources | nindent 12 }}
securityContext:
{{- include "convertx.containerSecurityContext" .Values.convertx.securityContext | nindent 12 }}
volumeMounts:
# SQLite database, uploads and converted output - everything the
# application persists lives under this one path.
- mountPath: /app/data
name: data
# Two writable emptyDirs so the root filesystem can stay
# read-only. Neither is a precaution: ConvertX itself writes only
# under /app/data, but it runs its conversions as subprocesses
# (soffice, ebook-convert, magick, gs, xelatex, ffmpeg...) that
# inherit its environment, and those need scratch space.
#
# /home/convertx is HOME. LibreOffice creates its user profile
# there on first use - it is invoked without
# -env:UserInstallation, so there is no way to point it elsewhere
# - and Calibre, Inkscape, ImageMagick and fontconfig do the same
# with their own config and cache directories. The directory
# exists in the image (useradd -m) and is owned by 1000:1000, but
# it sits on the read-only root filesystem, so it has to be
# overmounted to be usable.
- mountPath: /home/convertx
name: home
# /tmp is the scratch directory of the toolchain: Ghostscript,
# TeX Live and Calibre all use it during a conversion.
#
# Both of these count against the container's ephemeral-storage
# limit - see the resources comment in values.yaml.
- mountPath: /tmp
name: tmp
restartPolicy: Always
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
- name: home
emptyDir: {}
- name: tmp
emptyDir: {}
-212
View File
@@ -1,212 +0,0 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "convertx 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) and the free-form Ingress annotation map - 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).",
"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": {
"description": "true renders the StatefulSet with replicas: 0 and overrides convertx.replicas. PVC, Secret, Service and Ingress stay in place.",
"type": "boolean"
},
"secrets": {
"type": "object",
"additionalProperties": false,
"properties": {
"jwtSecretRef": {
"description": "REQUIRED. op:// path of the 1Password item carrying the field 'jwt-secret', which becomes JWT_SECRET. Enforced by required() in the template rather than by a 'required' array here: the key ships as null, so JSON Schema's required - which tests presence, not null-ness - could never fire on it.",
"type": ["string", "null"]
}
}
},
"convertx": {
"type": "object",
"additionalProperties": false,
"properties": {
"replicas": {
"description": "0 is allowed and scales the workload down. Values above 1 are accepted by the schema but do not work: the SQLite database and the uploads share one ReadWriteOnce PVC.",
"type": "integer",
"minimum": 0
},
"accountRegistration": {
"description": "ACCOUNT_REGISTRATION - lets anyone with the URL create an account. Defaults to false; upstream warns against enabling it on an internet-facing instance. A string is accepted for the 'true'/'false' spellings only; anything else aborts the render with a message naming this path, because the application would silently read it as false.",
"type": ["boolean", "string", "null"]
},
"allowUnauthenticated": {
"description": "ALLOW_UNAUTHENTICATED - drops authentication entirely. Defaults to false; upstream describes this as local-only. Same accepted spellings as accountRegistration.",
"type": ["boolean", "string", "null"]
},
"httpAllowed": {
"description": "HTTP_ALLOWED - clears the 'secure' flag on the session cookies. Only needed when ConvertX is served without TLS in front; behind this chart's Ingress the browser speaks https, so the default false is correct. Same accepted spellings as accountRegistration.",
"type": ["boolean", "string", "null"]
},
"autoDeleteEveryNHours": {
"description": "AUTO_DELETE_EVERY_N_HOURS - how old a job may get before ConvertX deletes its uploads and output. 0 disables the cleanup and is honoured as such (the chart uses a nil check, not Helm's 'default', which would swallow the 0). This is the retention policy for the PVC.",
"type": ["integer", "number", "null"],
"minimum": 0
},
"securityContext": {
"$ref": "#/definitions/securityContext"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"env": {
"$ref": "#/definitions/env"
},
"resources": {
"$ref": "#/definitions/resources"
}
}
},
"storage": {
"description": "The data PVC: SQLite database, uploads and converted output. Not null-safe on purpose - an erased block yields a PVC without a size, which the API server rejects at apply time rather than the chart sizing the volume silently.",
"type": "object",
"additionalProperties": false,
"properties": {
"amount": {
"type": ["string", "null"]
},
"storageClass": {
"type": ["string", "null"]
}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template. false frees BOTH clusterIssuer and domain: nothing outside the Ingress reads the domain in this chart, because ConvertX has no base-URL or redirect-URI variable.",
"type": "boolean"
},
"maxUploadFileSize": {
"description": "Rendered into the ingress-nginx proxy-body-size annotation, so it only takes effect while the Ingress is enabled. It is the only upload ceiling that exists - ConvertX enforces none of its own.",
"type": ["string", "null"]
},
"clusterIssuer": {
"description": "REQUIRED while ingress.enabled is true (enforced by required() in the template).",
"type": ["string", "null"]
},
"domain": {
"description": "REQUIRED while ingress.enabled is true (enforced by required() in the template).",
"type": ["string", "null"]
},
"className": {
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager and proxy-body-size annotations the chart always sets. Free-form keys by nature.",
"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": {
"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"
}
}
},
"securityContext": {
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99) instead of rendering an unhardened workload. Those defaults pin runAsUser/runAsGroup 1000 - the uid the image creates and the uid that owns the data on the PersistentVolume - rather than the repo's arbitrary 20000.",
"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"]
},
"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 defaults target /healthcheck on the port named http - overriding the path to '/' would probe a 302 whose target changes once the first account exists.",
"type": ["object", "null"]
}
}
}
-307
View File
@@ -1,307 +0,0 @@
# 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. PVC, Secret, Service
# and Ingress stay in place; scaleDown: false brings the workload back up.
scaleDown: false
secrets:
# op:// item path for the JWT signing key. REQUIRED - the chart aborts the
# render without it, and that is deliberate rather than convenient.
#
# ConvertX signs its session cookies with JWT_SECRET and falls back to
# randomUUID() when the variable is unset (src/services/user.ts:
# "secret: process.env.JWT_SECRET ?? randomUUID()"). That fallback is
# per PROCESS, not per install: every restart - a node drain, an image
# update, an OOM kill - mints a fresh secret and silently invalidates every
# session. Nothing logs it; users simply find themselves logged out. A value
# that has to survive a restart is a secret, so it is one here.
#
# The item must carry this field, spelled exactly like this, because the
# 1Password operator copies an item's FIELD NAMES into the Secret's keys:
#
# jwt-secret a long random string (openssl rand -hex 32)
#
# A field spelled differently does not fail the install: the operator writes
# a Secret without that key and the pod stays in CreateContainerConfigError,
# which looks like a broken chart rather than a typo in a vault item.
jwtSecretRef: null
convertx:
# Replica count for this workload; 0 is allowed and scales it down.
# The chart-global scaleDown flag takes precedence.
#
# Raising this above 1 does not work and the chart does not stop you, so the
# reason is here: the SQLite database, the uploads and the converted output
# all live on the single ReadWriteOnce PVC below, which is a plain chart
# object rather than a volumeClaimTemplate. A second pod normally never
# schedules ("Multi-Attach error for volume"); on a genuinely RWX storage
# class it would schedule and then corrupt the WAL-mode SQLite file that two
# processes are now writing. On top of that each pod would sign cookies with
# its own JWT_SECRET only if the secret were unset - which is exactly why
# secrets.jwtSecretRef above is required.
replicas: 1
# --- Two switches that open the instance up. Both default to false. ---
#
# UPSTREAM WARNS EXPLICITLY AGAINST ENABLING EITHER ON AN INTERNET-FACING
# INSTANCE, and this chart puts an Ingress with a public DNS name in front
# by default, so that warning applies to the standard install here.
#
# Both are parsed strictly by the application: it compares the lowercased
# string against "true" and treats everything else as false. The chart
# therefore accepts only a boolean or the strings "true"/"false" (any case)
# and ABORTS THE RENDER on anything else - "yes" or "1" would otherwise be
# filed under "off" without a word, which is the wrong direction to fail in
# for a value whose whole job is to open the service up.
# Lets anyone with the URL create an account. Note what it does NOT gate:
# the FIRST account is always creatable, because a ConvertX with no users
# serves a setup form to whoever opens the page first. Keeping this false
# protects accounts two and onwards, not account one.
accountRegistration: false
# Drops authentication entirely - anyone reaching the Service can upload
# files and read every conversion. Upstream describes this as local-only.
allowUnauthenticated: false
# Clears the "secure" flag on the session cookies (the app computes
# "secure: !HTTP_ALLOWED"). Only needed when ConvertX is served over plain
# http, and that is NOT our case: the Ingress terminates TLS, so the browser
# speaks https and sets the cookie happily while only the in-cluster hop
# between ingress controller and pod is http - which the browser never sees.
# Turning this on behind our Ingress weakens the cookies for no gain.
# (Same shape as outline's forceHttps, and the same reason to leave it off.)
httpAllowed: false
# How often ConvertX deletes jobs - uploads and converted output - older
# than this many hours. 0 disables the cleanup entirely.
#
# This is the retention policy for the PVC below, which is why it is a value
# and not left at the upstream default. With cleanup disabled the volume
# grows with every conversion and nothing reclaims it; size storage.amount
# for that case, or accept that the pod eventually fills its volume.
#
# An explicit 0 is honoured (the chart uses a nil check, not Helm's
# "default", which would swallow the 0 and silently restore 24-hour
# deletion on a value set precisely to switch it off).
autoDeleteEveryNHours: 24
# DOCUMENTED DEVIATION FROM THE HARDENING BASELINE - runAsUser/runAsGroup.
#
# The deviation is about the NUMBER only: nothing in the baseline is
# relaxed. The repo default is an arbitrary uid (20000); this chart pins
# 1000, and the reason is the image, not a preference.
#
# ghcr.io/c4illin/convertx carries NO USER instruction - verified in the
# published v0.19.0 image config, where "User" is empty - so without a
# securityContext the pod would run as root. Its Dockerfile creates
# "convertx" with uid 1000, gid 1000 and home /home/convertx (useradd -u
# 1000 -g convertx -d /home/convertx -m), and the image's entrypoint exists
# only to drop to that user with gosu.
#
# 1000 rather than 20000 for two reasons that both have to hold:
# - /home/convertx belongs to 1000:1000 in the image, and HOME is where
# LibreOffice, Calibre and fontconfig put their profiles. An arbitrary
# uid gets a HOME of "/" from the runtime's passwd lookup instead.
# - the uid owns data on a PersistentVolume, so changing it later makes
# existing conversions inaccessible. This number is not free to tidy.
securityContext:
pod:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
# fsGroup is what replaces the entrypoint's recursive chown of
# /app/data: the kubelet applies the group to the mounted volume, so
# the container can write it as uid 1000 without any privileged step.
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
# 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 securityContext.container, which wins over this value.
seccompProfile:
type: RuntimeDefault
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
add: []
# ConvertX serves GET /healthcheck: a static 200 with {"status":"ok"},
# explicitly exempted from authentication (src/pages/healthcheck.tsx,
# "auth: false"). All three probes use it.
#
# Why NOT "/", the obvious default: with the chart defaults "/" is never a
# 200. It answers 302 to /setup while no user exists and 302 to /login
# afterwards, so a probe against it would pass on a redirect whose target
# changes the moment somebody registers - a green signal that means
# something different on day two than on day one. httpGet does count 3xx as
# success, which is what makes that the quiet kind of wrong.
#
# What the endpoint proves, and what it does not: the handler touches
# neither the database nor the volume, so it cannot report a broken PVC.
# It is still the right readiness target here, because the SQLite database
# is opened at module import - before app.listen() is reached - so a process
# that answers /healthcheck at all has already opened its database. There is
# no external dependency in this chart to hang readiness on; the "database"
# is a file on the pod's own volume.
#
# WEBROOT SHIFTS THIS PATH. The application mounts every route under the
# WEBROOT prefix, /healthcheck included. This chart does not offer WEBROOT
# as a value (see the env block below), and setting it through env without
# also moving these three paths leaves the pod probing a 404 forever.
livenessProbe:
httpGet:
path: /healthcheck
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthcheck
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness, so those need no
# initialDelaySeconds. Budget = periodSeconds * failureThreshold = 300s.
#
# Generous on purpose. At import time - under NODE_ENV=production, which
# the image sets - ConvertX shells out to roughly twenty converter binaries
# to log their versions (soffice, ebook-convert, xelatex, magick, ffmpeg,
# markitdown and the rest). Those are fire-and-forget and do not block
# app.listen(), but they all contend for the pod's CPU at exactly the moment
# the first probe arrives, and the first soffice/ebook-convert run also
# builds its profile under HOME. A tight budget turns a slow node into a
# crash loop.
startupProbe:
httpGet:
path: /healthcheck
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
# 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 where the upstream variables this chart does not expose as values
# belong - BRANDING, LANGUAGE, HIDE_HISTORY, MAX_CONVERT_PROCESS,
# UNAUTHENTICATED_USER_SHARING, TZ, FFMPEG_ARGS, FFMPEG_OUTPUT_ARGS. They
# change the UI or a converter's behaviour and nothing this chart renders,
# so they need no value of their own.
#
# THREE VARIABLES DO NOT BELONG HERE, and they fail in three different ways:
#
# WEBROOT moves EVERY route, /healthcheck included, so the three
# probes above start failing against a 404 while the app is
# perfectly healthy - and this chart's Ingress still maps "/".
# Serving under a sub-path needs the probe paths and the
# Ingress path changed together; the chart does not do that
# for you.
# PUID/PGID are read by the image entrypoint, which this chart bypasses
# /UMASK (see the command: override in the StatefulSet). They reach
# nothing and configure nothing here. Set the uid through
# securityContext.pod above, which is what actually applies.
# PORT the app would listen on it, but containerPort, the Service
# and the probes all still name 3000. Moving the port means
# moving all four, so the chart pins it instead.
#
# Additive means additive: an entry repeating a name the chart already sets
# does not replace it. At install the later entry - yours - wins; on an
# EXISTING release Kubernetes merges env by name, the pair collapses to the
# chart's own value, and helm upgrade still reports success. Read the value
# back off the StatefulSet rather than trusting a green upgrade.
env: []
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 numbers are sized for conversions, not for an idle web server, and
# each one has a reason:
#
# cpu 0.25 An idle Bun process uses almost nothing, but a
# conversion saturates whatever it can get and the startup
# fires ~20 version probes at once. Per repo convention no
# CPU LIMIT is computed, so this is the scheduling floor
# and the throttling share under contention, not a cap -
# a conversion may burst well past it on an idle node.
#
# memory 1Gi soffice --headless is a few hundred MB on its own,
# ebook-convert pulls in Qt WebEngine, and ImageMagick on
# a large raster can want more than either. 1Gi requested,
# 3Gi limit (limitFactor 3). Memory is NOT compressible:
# a conversion that crosses the limit is an OOM kill of
# the whole pod, so raise the request before feeding the
# instance large video or raw-image work.
#
# ephemeral- 1Gi, and this one is easy to get wrong: EMPTYDIR VOLUMES
# storage COUNT AGAINST IT. The chart mounts two (/tmp and
# /home/convertx) for the converter toolchain, and
# Ghostscript, TeX and Calibre all scratch to /tmp. The
# uploads and the converted output do NOT count - those
# live on the PVC - so the figure covers scratch space
# only. Crossing the 3Gi limit evicts the pod.
limitFactor: 3
requests:
cpu: 0.25
memory: 1Gi
ephemeralStorage: 1Gi
# The volume behind /app/data: the SQLite database, every uploaded file and
# every converted result. There is no second store and no object storage.
#
# Not null-safe on purpose, following the repo's documented exception: an
# erased block yields a PVC without a size, which the API server rejects at
# apply time. That is louder than a volume quietly sized by the chart.
storage:
amount: 10Gi
storageClass: null
ingress:
# Gates the whole Ingress template. Checked in both directions as the style
# guide requires: NOTHING outside the Ingress reads ingress.domain in this
# chart - ConvertX has no base-URL, public-URL or redirect-URI variable at
# all (verified against the complete env list in src/helpers/env.ts) - so
# switching the Ingress off frees BOTH domain and clusterIssuer here.
enabled: true
# THE ONLY UPLOAD CEILING THERE IS.
#
# ConvertX sets no size limit of its own: it starts Elysia with
# maxRequestBodySize: Number.MAX_SAFE_INTEGER, and v0.19.0 has no upload
# variable of any kind. So unlike outline - where an app limit and an
# ingress limit sat a factor of ten apart and the smaller one won
# confusingly - there is exactly one number here, and it is this one.
#
# Two consequences worth knowing before changing it:
# - A file above this limit never reaches ConvertX. The user gets the
# ingress controller's 413 page, not a message from the application,
# and nothing appears in the pod log.
# - With ingress.enabled: false there is NO ceiling from this chart at
# all, and whatever proxy you put in front brings its own - usually
# ingress-nginx's 1m default, which is small enough to look like a
# broken upload button.
#
# Defaulted generously because the point of the application is converting
# video and large documents. What it costs: the body transits the ingress
# controller and then lands on the PVC, so raising it means checking
# storage.amount as well.
maxUploadFileSize: 1024m
clusterIssuer: null
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer and proxy-body-size annotations the chart always sets.
annotations: {}
+79 -2
View File
@@ -5,9 +5,86 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s
type: application
version: 12.1.3+up3.2.1
version: 12.2.0+up3.2.1
appVersion: 3.2.1
maintainers:
- name: jklein
- name: jklein
# The third chart in this repo with a dependency (after ytdlp-web-player and
# stalwart). README, "Chart dependencies", holds the bar a dependency has to
# clear and the full price list; what is repeated here is only what is specific
# to THIS chart, because the generic half is already written down twice.
#
# WHY ONE HERE. paperless-ngx cannot run without a Redis: PAPERLESS_REDIS is
# the Celery broker, and without it the web UI comes up looking healthy while
# consumption, OCR, the scheduled tasks and the index updates never run. So
# every installation needs one, and before this block the only way to have one
# was a second Helm release whose address lived in a 1Password item and in
# neither chart.
#
# WHY IT IS OFF BY DEFAULT: an installation that already has a Redis (or wants
# a shared one, which is what secrets.redisSecretRef' key-prefix field exists
# for) points redis-connection-url at it, and this dependency renders nothing.
# The subchart is the path for an installation that has none.
#
# THE SUBCHART IS OUR OWN valkey CHART, resolved from the published index - not
# upstream's. So README guideline 14.2 ("we consume their chart as it comes")
# does not apply and there are no third-party defaults to leave unpatched: the
# subchart follows this repo's conventions because it IS this repo's chart.
# What does apply in full is the subchart being invisible to everything
# downstream that watches Helm release versions, and the PR-ordering hazard
# below.
dependencies:
- name: valkey
# KEEP THIS VERSION MOVING - it is the only path a Valkey update has into a
# cluster through this chart. The version declared here is baked into our
# packaged .tgz, so anything watching Helm release versions (Fleet
# included) sees only "paperless-ngx": a new valkey chart, a security fix
# included, arrives ONLY when this line is raised and a new paperless-ngx
# version is published. Nothing reports the gap.
#
# Renovate's BUILT-IN helmv3 manager reads this block and opens the PR, and
# ci/renovate-bump-chart.sh bumps this chart's patch version, which is what
# actually ships it. No customManagers entry exists or would help - that
# manager is for exactly this case, where repository and version sit
# together in one file. Measured for Gitea #65 against our self-hosted
# index (Renovate 44.132.2, dry run with the dependency pinned low:
# newVersion "1.0.0+up9.1.2.alpine", versioning "helm"), so the chain is
# proven for this registry and this build-metadata suffix and was not
# re-measured here.
#
# Independent of appVersion: raising it is a patch or minor bump of this
# chart and is never tied to the paperless-ngx image.
version: "1.0.0+up9.1.2.alpine"
# Our own published index, and the READ path only - anonymously readable
# (measured), so ci/helm-dependencies.sh needs no credential and was not
# touched. The publish target and its credentials still live in 1Password
# and appear nowhere in this repository; see README, "CI/CD".
repository: "https://git.jk-dev.de/api/packages/jlab-cluster/helm"
# Off by default, so a plain install renders none of its objects.
#
# NOT "valkey.enabled", AND THAT IS NOT A STYLE CHOICE. Everything under
# the "valkey:" key is handed to the subchart as ITS values and validated
# against ITS schema, and our valkey chart ships additionalProperties:
# false with no "enabled" property:
# Error: values don't meet the specifications of the schema(s) in the
# following chart(s): valkey: - at '': additional properties 'enabled'
# not allowed
# Worse, Helm skips a DISABLED subchart's schema validation, so with the
# default of false that error would stay invisible through lint, render and
# install-test and surface only for whoever first switched it on - the one
# case the value exists for. The condition is therefore a key of OUR OWN,
# outside the subchart's value subtree.
condition: valkeySubchart.enabled
#
# !!! NEVER CHANGE valkey/ AND THIS CHART IN THE SAME PULL REQUEST !!!
#
# The dependency resolves out of the PUBLISHED index, not out of the working
# tree, and valkey/ is published only by a push to main that touches it. A
# single PR carrying both therefore runs lint, package, render AND the
# install-test against the OLD valkey .tgz and goes green about an artifact
# that is not the one in the diff. Two consecutive pull requests: change
# valkey/, merge, let it publish, then raise the version here. README, "Chart
# dependencies", and the wiki's valkey page carry the long form.
+78 -4
View File
@@ -3,6 +3,9 @@
{{- $barcodes := fromYaml (include "paperless-ngx.barcodes" .Values.paperless.files.barcodes) -}}
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
{{- $scripts := .Values.paperless.scripts | default dict -}}
{{- $valkeySub := (.Values.valkeySubchart | default dict).enabled -}}
{{- $valkeyAuth := ((.Values.valkey | default dict).secrets | default dict).authSecretRef -}}
{{- $valkeySvc := printf "%s-valkey-service" .Release.Name -}}
{{- $pre := default (list) $scripts.preConsumptionScripts -}}
{{- $post := default (list) $scripts.postConsumptionScripts -}}
Paperless-ngx {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
@@ -70,6 +73,12 @@ WHAT THE 1PASSWORD ITEMS MUST CONTAIN
key-prefix PAPERLESS_REDIS_PREFIX — set this per release when
several releases share one Redis, or two instances
will consume each other's task queue
auth-conf the COMPLETE line "requirepass PASSWORD", not a
bare password. Read by the bundled Valkey only, so
it is {{ if $valkeySub }}REQUIRED for this release — see the Valkey
section below{{ else }}unused while valkeySubchart.enabled is
false, and the item needs it only once you switch
the bundled Valkey on{{ end }}.
secrets.paperlessSecretRef → {{ .Values.secrets.paperlessSecretRef }}
Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret, fields:
@@ -117,9 +126,9 @@ CHANGING A SECRET'S CONTENT RESTARTS NOTHING
fields points the release at whatever that new endpoint contains, empty or
not, and the next start migrates it.
POSTGRES AND REDIS ARE NOT PART OF THIS CHART
Both have to exist and be reachable before the first start. They fail in
opposite ways, and only one of them is loud:
POSTGRES IS NOT PART OF THIS CHART{{ if not $valkeySub }}, AND NEITHER IS REDIS{{ end }}
{{ if $valkeySub }}The database{{ else }}Both{{ end }} ha{{ if $valkeySub }}s{{ else }}ve{{ end }} to exist and be reachable before the first start.{{ if not $valkeySub }} They fail in
opposite ways, and only one of them is loud:{{ end }}
- Without the database paperless does not come up at all. All three probes
ask for "/" on port 8000, so you get no useful error: the pod never turns
@@ -129,7 +138,72 @@ POSTGRES AND REDIS ARE NOT PART OF THIS CHART
broker: consumption, OCR, the scheduled tasks and the index updates simply
never run, and documents dropped into the consume directory stay there.
A wrong redis-connection-url costs you the whole processing side and
nothing turns red.
nothing turns red.{{ if $valkeySub }} This release brings its own Valkey (below), so
the address is in your hands — which is exactly why the url is the one
thing to check first.{{ end }}
{{ if $valkeySub }}
THIS RELEASE BRINGS ITS OWN VALKEY — ONE MANDATORY STEP IS IN THE VAULT
valkeySubchart.enabled is true, so the release contains a Valkey of its own:
StatefulSet {{ .Release.Name }}-valkey-sfs, Service {{ $valkeySvc }}, its own
NetworkPolicy, and a PersistentVolumeClaim (persistence is on — this is a
queue, see below).
1. THE CONNECTION URL MUST POINT AT IT. This is a step, not a hint: the url
lives in the vault and the chart cannot write it. In the item
"{{ .Values.secrets.redisSecretRef }}", field redis-connection-url:
redis://:PASSWORD@{{ $valkeySvc }}:6379
Pointing elsewhere is not an error anywhere: paperless talks to that other
Redis, the Valkey next to it runs along unused, and nothing reports it.
2. THE PASSWORD IS IN THE ITEM TWICE — once inside redis-connection-url and
once in the "requirepass PASSWORD" line of auth-conf. Both are the same
password, and a rotation has to touch BOTH fields. Change only one and
paperless can no longer authenticate against its own Valkey. After any
such change, restart both workloads by hand (Helm cannot see inside a
1Password item, so nothing rolls automatically):
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-valkey-sfs
3. AUTH-CONF IS A CONFIGURATION LINE, NOT A PASSWORD. The Valkey mounts the
field as a file and includes it, so the password never reaches a process
list or an environment variable. An init container refuses to start the
pod unless a line beginning with "requirepass " is really there — without
that check a typo would leave a Valkey running with NO password and
looking perfectly healthy.
{{- if and $valkeyAuth (ne ($valkeyAuth | toString) ($.Values.secrets.redisSecretRef | toString)) }}
NOTE: valkey.secrets.authSecretRef points at a DIFFERENT item than
secrets.redisSecretRef:
valkey.secrets.authSecretRef → {{ $valkeyAuth }}
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
That is supported and may well be deliberate — the bundled Valkey is then fed
from its own item. It is only worth knowing that you now keep TWO items in
step: the password in auth-conf over there and the one inside
redis-connection-url here have to stay identical.
{{- end }}
BOTH ITEMS' FIELDS LAND IN BOTH SECRETS. A OnePasswordItem materializes every
field of the item it names, so with one shared item the Valkey's Secret also
carries redis-connection-url and key-prefix, and this chart's redis Secret
also carries auth-conf. Untidy and harmless — nothing mounts a field it does
not reference. It is mentioned so it does not get "cleaned up".
IT IS CONFIGURED AS A QUEUE, and that is a decision: persistence on, eviction
policy "noeviction". PAPERLESS_REDIS is the Celery broker, so its contents are
work that must not vanish — "allkeys-lru" would evict across the whole
keyspace and a backlog of waiting tasks is exactly what LRU reads as cold.
The price: the same instance also serves the Django cache, so when it fills,
writes are REFUSED rather than old keys dropped — new tasks included. The
answer is to give it more memory (valkey.valkey.resources.requests.memory),
never to switch the policy. values.yaml argues this in full.
SCALEDOWN DOES NOT REACH IT. scaleDown: true scales paperless to zero and
leaves the Valkey running, which is the right way round for a queue: the
backlog stays where it is.
{{- end }}
THE FIRST START MIGRATES, AND THE STARTUP PROBE IS THE CLOCK
Every start runs the database migrations before the server answers, and the
+6 -4
View File
@@ -20,10 +20,12 @@ Usage (the values path to the resources dict varies per chart):
{{- include "paperless-ngx.resources" .Values.<app>.resources | nindent 12 }}
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.
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 does take one - the optional valkey subchart - which changes
nothing here: a library chart for one helper would still be the wrong trade,
and the subchart carries its own copy of this helper anyway. This file in
template-chart is the reference implementation.
*/}}
{{- define "paperless-ngx.resources" -}}
{{- $resources := . | default dict -}}
@@ -1,3 +1,12 @@
{{- /*
With the valkey subchart on, THIS is the item that also carries the "auth-conf"
field the bundled Valkey reads (NOTES.txt prints all three fields). The rule
that valkey.secrets.authSecretRef must then be set is enforced in
values.schema.json and NOT by a fail() here - measured, see that file: Helm
validates the schema before it renders, and it renders a subchart's templates
before the parent's, so a check in this file could never beat the subchart's own
required() to it.
*/ -}}
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
+57
View File
@@ -4,6 +4,48 @@
"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 of BOTH workloads, and the barcodes block from issue #61, additionally accept null, because an erased block falls back to the chart defaults (issues #61 and #99).",
"type": "object",
"additionalProperties": false,
"required": ["valkeySubchart"],
"allOf": [
{
"title": "With the valkey subchart switched on, valkey.secrets.authSecretRef is required",
"description": "The one cross-key rule in this file, and it lives here rather than in a template on purpose: Helm validates the schema BEFORE it renders anything, and it renders a subchart's templates before the parent's. A fail() in this chart's templates therefore loses the race against the subchart's own required(), whose message names 'secrets.authSecretRef' and cannot know that the path belongs to the same 1Password item as secrets.redisSecretRef. Measured for Gitea #78 - a guard that cannot fire is the mistake found while testing #77. Set it to the SAME op:// path as secrets.redisSecretRef: the bundled Valkey reads its password from the field 'auth-conf' in that item, whose value must be the complete line 'requirepass <password>'. A different item is allowed; two items then have to be kept in step.",
"if": {
"required": ["valkeySubchart"],
"properties": {
"valkeySubchart": {
"type": "object",
"required": ["enabled"],
"properties": {
"enabled": {
"const": true
}
}
}
}
},
"then": {
"required": ["valkey"],
"properties": {
"valkey": {
"type": "object",
"required": ["secrets"],
"properties": {
"secrets": {
"type": "object",
"required": ["authSecretRef"],
"properties": {
"authSecretRef": {
"type": "string",
"minLength": 1
}
}
}
}
}
}
}
}
],
"properties": {
"global": {
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
@@ -261,6 +303,21 @@
}
}
}
},
"valkeySubchart": {
"description": "The dependency condition for the valkey subchart. A key of THIS chart, deliberately outside the 'valkey' subtree: everything under 'valkey:' is handed to the subchart as its values, and this repo's valkey chart has additionalProperties: false with no 'enabled' property, so 'valkey.enabled' is rejected by the subchart itself. Helm skips a disabled subchart's schema validation, so that error would only ever have surfaced for whoever first set it to true. NOT null-safe and 'enabled' is REQUIRED, which is the opposite of this chart's other blocks (issue #99) and is deliberate: Helm treats a condition whose path does not resolve as ENABLED, so an erased 'valkeySubchart:' would switch the subchart ON - measured for Gitea #78, where it surfaced as the subchart's own required() error. An erased block must not flip behaviour, least of all in that direction, so it is rejected here instead.",
"type": "object",
"additionalProperties": false,
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"valkey": {
"description": "Values passed through to the valkey subchart - this repo's OWN valkey chart, resolved from the published index. Open on purpose and at every level: the subchart owns this schema (it ships its own, and a stricter one than this), and a closed copy here would reject whatever that chart adds in its next release while duplicating a validation that already happens. This chart reads NONE of these keys; the dependency condition is valkeySubchart.enabled instead. Two shapes that are easy to get wrong: secrets.authSecretRef must be set BY HAND to the same op:// path as secrets.redisSecretRef (a values.yaml is never templated, so a parent cannot default it - Gitea #78), and the item's field 'auth-conf' holds the complete line 'requirepass <password>', not a bare password. values.yaml argues the queue-vs-cache choice and names the three operator traps.",
"type": "object"
}
},
"definitions": {
+173
View File
@@ -5,6 +5,10 @@
scaleDown: false
secrets:
# op:// path of the item holding the Redis/Valkey connection. Fields:
# "redis-connection-url" and "key-prefix" always, plus "auth-conf" when the
# bundled Valkey subchart is used - see the valkeySubchart block at the end
# of this file. NOTES.txt prints all three.
redisSecretRef: null
databaseSecretRef: null
paperlessSecretRef: null
@@ -176,3 +180,172 @@ oidc:
# Some providers need this since the allauth update in paperless-ngx 3.0
# (login fails at the callback with invalid_client otherwise).
tokenAuthMethod: null
# ---------------------------------------------------------------------------
# The bundled Valkey - this repo's own valkey chart as an optional subchart
# ---------------------------------------------------------------------------
# The switch for the dependency declared in Chart.yaml. Off by default: an
# installation that already has a Redis points redis-connection-url at it and
# this renders nothing.
#
# WHY THE KEY IS HERE AND NOT "valkey.enabled": everything under "valkey:" is
# handed to the subchart as its values and validated against ITS strict schema,
# which has no "enabled" property - so "valkey.enabled" is rejected BY THE
# SUBCHART. And because Helm skips a disabled subchart's schema validation, the
# error would only ever have appeared for whoever first switched it on.
# Chart.yaml carries the exact error text.
#
# DO NOT ERASE THIS BLOCK. Unlike every other block in this file (issue #99),
# "valkeySubchart: null" is not "the default applies" - a null override DELETES
# the key, the dependency's condition path then does not resolve, and Helm
# treats an unresolvable condition as ENABLED. So erasing the switch turns the
# subchart ON, with no password set and only the subchart's own error to show
# for it. Measured for Gitea #78; values.schema.json rejects it before anything
# renders. An empty block ("valkeySubchart: {}") is fine, because Helm merges
# that with the default below.
valkeySubchart:
enabled: false
# ---------------------------------------------------------------------------
# Values passed THROUGH to the valkey subchart
# ---------------------------------------------------------------------------
# Read as the SUBCHART's own values, so the key names are its schema, not this
# chart's. `valkey/values.yaml` in this repo is the reference. Nothing here has
# any effect while valkeySubchart.enabled is false.
#
# What still applies in full: the subchart is invisible to everything
# downstream that watches Helm release versions (Chart.yaml has the detail and
# the PR-ordering warning - never change valkey/ and this chart in the same
# PR), and this chart's scaleDown flag does NOT reach it. scaleDown: true
# scales paperless to zero and leaves the Valkey running; that is the right way
# round for a queue, because the backlog stays where it is.
valkey:
secrets:
# REQUIRED once the subchart is on, and it has to be set BY HAND to the
# SAME op:// path as secrets.redisSecretRef above - because the
# "requirepass" line lives in that same item (see the three traps below).
#
# IT CANNOT DEFAULT TO IT, and that is a property of Helm, not an omission:
# a values.yaml is never templated and a parent chart cannot compute a
# subchart's value - values are merged statically before any template runs.
# Writing "{{ .Values.secrets.redisSecretRef }}" here does not resolve, it
# lands verbatim in the subchart's itemPath and fails as a YAML parse error
# pointing at the subchart's template, which names neither this value nor
# the real cause. Measured; Gitea #78 has the output. So the path is typed
# twice, and forgetting it is caught by a conditional rule in
# values.schema.json - NOT by a fail() in a template, which cannot work
# here: Helm renders a subchart's templates BEFORE the parent's, so the
# subchart's own required() always wins that race and reports
# "secrets.authSecretRef" instead of this value's real path. The schema runs
# earlier than any rendering, which is why the rule lives there.
#
# MIND THE FIELD, it is not a password. The item must carry a field
# "auth-conf" whose value is the COMPLETE configuration line
# requirepass <password>
# The valkey chart mounts that field as a file and `include`s it, so the
# password never reaches the process list, an environment variable, or
# Helm's field of view. An init container refuses to start the pod unless
# the line is really there, because the alternative failure is an open
# server that looks perfectly healthy. A bare password does not work.
#
# OVERRIDING IT WITH A DIFFERENT ITEM IS ALLOWED and is not a mistake: it
# is the way to feed the bundled Valkey from its own item. The cost is that
# two items then have to be kept in step instead of one.
authSecretRef: null
# QUEUE, not cache - a decision, with the conflict it does not resolve.
#
# The valkey chart treats persistence and eviction as a PAIR and warns
# against mixing them: QUEUE is persistence.enabled: true + "noeviction"
# (its default), CACHE is persistence.enabled: false + "allkeys-lru".
# stalwart picked CACHE. paperless-ngx is the other case - which happens to
# be the subchart's own default, so the block below changes nothing. It is
# written out anyway, for two reasons: the subchart's persistence block is
# deliberately NOT null-safe (an erased one renders an invalid PVC), and the
# decision belongs where it is argued rather than one chart away. The
# eviction policy is NOT restated - valkey.maxmemoryPolicy stays at the
# subchart's "noeviction", which is the queue half of the pair. storageClass
# stays at the subchart default too (longhorn-encrypted-ns): queue payloads
# are the same documents that are in the database, so encrypted at rest.
#
# Read off the paperless-ngx 3.2.1 source (src/paperless/settings/__init__.py
# and settings/custom.py), not assumed: PAPERLESS_REDIS is ONE url, and
# parse_redis_url returns it twice for a TCP url, so one instance carries
# five consumers:
#
# 1. CELERY_BROKER_URL - the queue: consume, OCR, index
# updates, classifier training, the
# scheduled tasks
# 2. CELERY_RESULT_BACKEND - also Redis in 3.x (it was the
# database before), CELERY_RESULT_EXPIRES
# 3600
# 3. CHANNEL_LAYERS - channels_redis.pubsub, expiry 15s;
# pub/sub, not stored keys
# 4. CACHES["default"] - Django cache, recomputable
# 5. CACHES["read-cache"] - Cachalot DB read cache, TTL 3600,
# off unless
# PAPERLESS_DB_READ_CACHE_ENABLED
#
# WHY QUEUE: "allkeys-lru" would not be a trade here, it would be actively
# wrong. LRU evicts across the WHOLE keyspace with no regard for key type,
# and a backlog of waiting consume tasks is exactly what LRU reads as cold -
# written once, read later. So a cache configuration would preferentially
# throw away the work and keep the cache entries. A dropped consume or index
# task means a document that is silently never processed: the file stays in
# the consume directory although the directory watcher has already fired.
# And an evicted Celery result means a task that shows as "started" in the UI
# forever.
#
# THE CONFLICT THAT STAYS, stated rather than smoothed over: two of the five
# consumers are caches and they get the policy that does not suit them. When
# the instance fills, "noeviction" makes Valkey REFUSE WRITES - which hits
# cache writes and NEW TASKS alike, so paperless starts erroring instead of
# quietly degrading. That is the loud direction and therefore the right one,
# but it is a price and not a side effect, and the answer to it is to SIZE
# MEMORY for the working set, never to switch the policy. What makes the
# price small: all three cache-like consumers are TTL-bounded (15s, 3600s,
# 3600s), so they bound themselves and do not need eviction to stay in check,
# and the read cache is off by default.
persistence:
enabled: true
appendonly: true
# Sized for a task backlog and its results, not for documents - the
# documents are on paperless' own PVC. Grow it if a release regularly
# queues thousands of tasks (a PVC can grow, it can never shrink).
size: 1Gi
# networkPolicy and the Valkey resources are left at the subchart's defaults
# on purpose: its NetworkPolicy is on and allows the release's own namespace,
# which with a subchart is where paperless runs. allowedNamespaces only needs
# an entry when the two are in DIFFERENT namespaces, which a subchart rules
# out.
# ---------------------------------------------------------------------------
# THE THREE TRAPS OF THE BUNDLED VALKEY - all three are operator work
# ---------------------------------------------------------------------------
# 1. THE PASSWORD IS IN THE ITEM TWICE: inside redis-connection-url and again
# in the "requirepass" line of auth-conf. A rotation has to touch BOTH
# fields; touching one leaves a paperless that cannot authenticate against
# its own bundled Valkey. The alternative - assembling the url from parts -
# would break the secret contract of every running release (a major) and
# route the password through a template. Deliberately not chosen; the trap
# is named instead.
#
# 2. WITH THE SUBCHART ON, redis-connection-url MUST POINT AT THE SUBCHART:
# redis://:<password>@<release>-valkey-service:6379
# The name is determined and confirmed against the rendered output, but the
# url lives in the vault, so this is a MANDATORY STEP of the installation
# and not a hint. Left pointing at an external Redis, the bundled Valkey
# runs along unused and nothing says so. NOTES.txt prints the exact name for
# the release at hand.
#
# 3. A OnePasswordItem MATERIALIZES EVERY FIELD OF THE ITEM. With both CRs
# pointing at the same item, the Valkey Secret therefore also carries
# redis-connection-url and key-prefix, and this chart's redis Secret also
# carries auth-conf. Untidy, harmless, and written down here so nobody reads
# it as a leak or a bug and "tidies it up": the keys are unused, and no
# container mounts a field it does not reference.
#
# And one rule that is about this repository rather than about an installation:
# changes to valkey/ must not share a pull request with changes to this chart.
# Chart.yaml says why; README and the wiki's valkey page carry the long form.
+2 -2
View File
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi
type: application
version: 8.1.6+up4.15.2
appVersion: "4.15.2"
version: 8.1.7+up4.15.3
appVersion: "4.15.3"
maintainers:
- name: jklein
+30 -23
View File
@@ -334,29 +334,6 @@
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
},
{
description: [
"convertx: the registry carries versioned tags (v0.19.0 and its predecessors) next",
"to THREE moving ones - 'latest', 'main' and a 'pr-<n>' per open pull request.",
"v0.19.0 and 'latest' currently share the same digest (sha256:8590a21a…), which is",
"exactly the trap: an appVersion of 'latest' would look correct today and change the",
"running image on the next upstream push, without a commit here. 'main' and 'pr-<n>'",
"are worse still - they carry unreviewed code.",
"The anchored regex versioning below is what keeps all three out: it demands the v",
"prefix and three numeric parts, which none of them can satisfy. Precedent and the",
"full argument: the ytdlp-web-player block near the end of this file.",
],
customType: "regex",
managerFilePatterns: ["convertx/Chart.yaml"],
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
depNameTemplate: "ghcr.io/c4illin/convertx",
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
// The "v" stays outside every capture group: it is part of the tag and
// carries no ordering. A group is a claim about how the value is
// compared, and the anchors are what exclude latest/main/pr-<n>.
versioningTemplate: "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)$",
},
{
customType: "regex",
managerFilePatterns: ["cyberchef/Chart.yaml"],
@@ -508,6 +485,36 @@
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
},
{
description: [
"stalwart: the registry carries three kinds of tag - versioned ones (v0.16.24), the",
"same versions with an -alpine suffix (v0.16.24-alpine), and three MOVING names:",
"'latest', 'latest-alpine' and the truncated line tags 'v0.16' / 'v0.16-alpine'.",
"Measured on 2026-10-02: 'latest', 'v0.16' and 'v0.16.24' all resolve to the same",
"digest (sha256:ec011be2285...), pushed within seconds of each other - so 'latest'",
"and 'v0.16' pin nothing and would move the running image with no commit here.",
"",
"The anchored regex below is what keeps all five out, and it does so for two",
"independent reasons rather than one: the v prefix plus THREE numeric parts rejects",
"'latest' and 'latest-alpine' (no numbers) and 'v0.16' / 'v0.16-alpine' (two parts),",
"and the terminating anchor rejects the -alpine suffix. Choosing the glibc image over",
"the alpine one is a deployment decision and belongs in the chart, not in a",
"compatibility suffix Renovate would then have to maintain - contrast the valkey",
"block below, where '-alpine' IS the chosen flavour and docker versioning carries it.",
"",
"The 'v' stays outside every capture group, as in the ytdlp-web-player block: it is",
"part of the tag and carries no ordering, and a group is a claim about how the value",
"is compared. It is part of the tag, so it is part of appVersion (+upv0.16.24) -",
"precedent: calibre-web-automated, matrix-synapse, ytdlp-web-player.",
],
customType: "regex",
managerFilePatterns: ["stalwart/Chart.yaml"],
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
depNameTemplate: "docker.io/stalwartlabs/stalwart",
datasourceTemplate: "docker",
depTypeTemplate: "app-version",
versioningTemplate: "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)$",
},
{
description: [
"sure: the registry carries four kinds of tag side by side - releases (0.7.4), the",
+50
View File
@@ -0,0 +1,50 @@
# Project-specific half of the SonarQube analysis. The workflow is the shared
# block in workflow-util/pipelines and is identical for every repository that
# uses it; everything that is about THIS repository lives here.
#
# The key is the one the project already has on the server, given by the
# maintainer. It is not derivable and must not be guessed: the project is
# created in SonarQube first, and the key is then read off it.
#
# The first attempt here was a guess, "jlab-cluster_helm-charts", reasoned from
# the instance serving two owners. It failed with
#
# ERROR You're not authorized to analyze this project or the project doesn't
# exist on SonarQube and you're not authorized to create it.
#
# which is the message for BOTH a wrong key and a token without "Execute
# Analysis" on the project - the server does not distinguish them, so a wrong
# key here looks exactly like a permission problem over there. Once the key is
# known to be right, that message means the token.
sonar.projectKey=JLab-Helm-Charts
# NO sonar.projectName, deliberately.
#
# The project exists on the server, so the display name belongs to the server:
# whoever renames it there expects the rename to hold. A name sent with the
# analysis would overwrite it on every single run, silently, and the person who
# renamed it would only see it snap back. A value that rewrites someone else's
# setting without saying so is not a value this repository carries. If the name
# is wrong, it gets fixed in SonarQube, not here.
# The whole tree, which is also the scanner's default - stated rather than
# implied, because the absence of exclusions below is a decision and an empty
# file would not show that.
sonar.sources=.
# NO EXCLUSIONS, deliberately, for the first round.
#
# The interesting case is the Helm templates: because of {{ }} they are not
# valid YAML, and what the Helm and YAML analysers do with them is something we
# can only find out by letting them run over them once. Excluding them up front
# would throw away the only chance to learn it, and it would exclude the 26
# chart template directories that are the bulk of this repository.
#
# The same goes for the secret detection, which is the most valuable analyser
# here: the premise of this repository is that no credential ever lands in it -
# every secret is a 1Password OnePasswordItem reference - and until now nothing
# checked that premise.
#
# If the first run produces noise that is genuinely not worth reading, the
# exclusions belong here, with the finding they answer named in a comment. Not
# before: an exclusion written on a guess hides whatever else matched it.
+129
View File
@@ -0,0 +1,129 @@
apiVersion: v2
name: stalwart
description: A Helm chart to deploy a cluster-capable Stalwart mail server (SMTP, IMAP, JMAP, ManageSieve) on Kubernetes
icon: https://avatars.githubusercontent.com/u/76623657
type: application
# appVersion is the exact image tag, and the "v" is part of it - so it is part
# of appVersion and of the "+up" suffix. Precedent: calibre-web-automated
# (+upv4.0.6), matrix-synapse (+upv1.159.0), ytdlp-web-player (+upv1.2.0).
#
# The glibc image, not "-alpine". Both flavours are published for every release
# from the same source and carry the same uid/gid 2000 and the same two volumes;
# the choice is a deployment decision, and keeping it out of the tag is what
# lets renovate.json5 anchor the version with a plain three-part regex instead
# of maintaining a compatibility suffix. The moving tags "latest", "v0.16" and
# their -alpine twins are excluded by that same anchor: measured on 2026-10-02
# all three of latest / v0.16 / v0.16.24 resolved to one digest pushed seconds
# apart, so either floating name would change the running image with no commit
# in this repository and nothing to report it.
version: 1.0.1+upv0.16.24
appVersion: "v0.16.24"
maintainers:
- name: jklein
# The second chart in this repo with a dependency (after ytdlp-web-player).
# README, "Chart dependencies", holds the bar it has to clear and the price;
# both are restated here because the price is paid by whoever reads this file
# next, not by whoever added the line.
#
# WHY ONE HERE. Stalwart's in-memory store is not a cache that can be dropped:
# from v0.16 it also carries the cluster coordinator ("the Default variant
# reuses the Redis connection already configured on the in-memory store"), so a
# multi-node install does not work without it. Redis/Valkey is admissible as
# the in-memory store ONLY - never as the data or blob store. Offering it inside
# the chart means the wiring (host, port, egress rule) is in one place instead
# of split across two releases that have to agree.
#
# WHY IT IS OFF BY DEFAULT: an installation that already runs a Valkey points
# externalServices.valkey.host at it and this dependency renders nothing. The
# subchart is the path for an installation that has none.
#
# THE SUBCHART IS OUR OWN valkey CHART, resolved from the published index -
# NOT upstream's. So guideline 14.2 ("we consume their chart as it comes") does
# not apply here and there is nothing of someone else's to leave unpatched: the
# subchart follows this repo's conventions because it IS this repo's chart. What
# does apply is everything about the subchart being invisible downstream, plus
# one hazard that a self-referencing dependency adds and an external one does
# not - see the PR-ordering warning below.
dependencies:
- name: valkey
# KEEP THIS VERSION MOVING - it is the only path a Valkey update has.
#
# A subchart is invisible to everything downstream that watches Helm release
# versions: what this file declares is baked into our packaged .tgz, and
# Fleet only ever sees the version of "stalwart". So a new valkey chart - a
# security fix included - reaches a cluster ONLY when this line is raised
# and a new stalwart version is published. Nothing reports the gap.
#
# Renovate's BUILT-IN helmv3 manager reads this block and opens the PR (no
# customManagers entry exists or would help - repository and version sit
# together in one file, which is the case that manager is for), and
# ci/renovate-bump-chart.sh bumps this chart's patch version, which is what
# actually ships it. That chain is the whole safety net; switched off, this
# line needs a human on a calendar instead.
#
# MEASURED, not assumed, because the registry is self-hosted and the version
# carries build metadata - two things that could each have broken the chain
# silently. Renovate 44.132.2, dry run on 2026-10-02 with this dependency
# pinned at a lower version: manager helmv3 matched the file, took
# registryUrls from the line below, and offered
# newVersion: "1.0.0+up9.1.2.alpine", versioning: "helm"
# with the release timestamp read from our index. So both the self-hosted
# index and the "+up..." suffix are handled.
#
# Independent of appVersion: raising it is a patch or minor bump of this
# chart and is never tied to the Stalwart image.
version: "1.0.0+up9.1.2.alpine"
# Our own published index, and the READ path only. It is anonymously
# readable - measured 2026-10-02: GET .../index.yaml returns 200 without
# credentials - so ci/helm-dependencies.sh needs no secret and was not
# touched. Verified with an empty HELM_REPOSITORY_CONFIG, i.e. the state a
# fresh runner is in: helm reports "Getting updates for unmanaged Helm
# repositories" and resolves the exact build-metadata version from the URL
# alone, with no "helm repo add" anywhere. Both "helm dependency update" and
# "helm dependency build" were tried.
#
# The PUBLISH target and its credentials still live in 1Password and appear
# nowhere in this repository - see README, "CI/CD".
repository: "https://git.jk-dev.de/api/packages/jlab-cluster/helm"
# Off by default, so a plain install renders none of its objects.
#
# NOT "valkey.enabled", AND THAT IS NOT A STYLE CHOICE. Everything under the
# "valkey:" key is passed to the subchart as its values, and our valkey
# chart ships a values.schema.json with additionalProperties: false and no
# "enabled" property - so "valkey.enabled" is rejected by the SUBCHART:
# Error: values don't meet the specifications of the schema(s) in the
# following chart(s): valkey: - at '': additional properties 'enabled'
# not allowed
# Measured. Worse, Helm skips a disabled subchart's schema validation, so
# the error appears only for whoever first sets it to true - the default
# path renders clean.
#
# The condition is therefore a key of OUR OWN, outside the subchart's value
# subtree, which is also the honest shape: "enabled" is not a valkey value.
# It only ever looked like one because upstream's schema was permissive.
# The alternative - adding "enabled" to valkey/values.schema.json - would
# have to be its own PR and be published first (see the warning below), for
# a key that chart does not read.
condition: valkeySubchart.enabled
#
# !!! NEVER CHANGE valkey/ AND THIS CHART IN THE SAME PULL REQUEST !!!
#
# The hazard a self-referencing dependency adds. This dependency resolves out of
# the PUBLISHED index, not out of the working tree, and valkey/ is only
# published by a push to main that touches it. So in one PR carrying both:
#
# - every helm command here - lint, package, render, AND the install-test -
# pulls the OLD valkey .tgz, because the new one does not exist yet;
# - the PR therefore goes green against a version of the subchart that is not
# the one in the diff, and the diff to valkey/ is reviewed while nothing
# actually exercised it;
# - after the merge both charts publish, and the first install of the new
# stalwart is the first time the new valkey is used anywhere.
#
# Two consecutive pull requests instead: change valkey/, merge, let it publish,
# then raise the version here. The ordering is not a nicety - the green check on
# the combined PR is a statement about the wrong artifact.
+292
View File
@@ -0,0 +1,292 @@
{{- $release := include "stalwart.fullname" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- $ingressGroup := "" -}}
{{- if .Values.ingress.enabled }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
Since v0.16 Stalwart keeps its whole configuration inside the data store and
manages it over JMAP. The only file on disk is config.json, which names the
data store and nothing else. So this release has given you the workloads, the
Services, the NetworkPolicies, the Ingress and a certificate — but no
domains, no listeners, no blob store, no in-memory store and no cluster
roles. Those are the steps below, and without them this is not yet a mail
server.
NODE GROUPS IN THIS RELEASE
{{- range $group, $spec := $nodes }}
{{- $replicas := include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas) }}
{{ printf "%-10s" $group }} replicas {{ $replicas }}, reachable on: {{ $spec.exposedPorts | join ", " }}
{{- end }}
{{- if .Values.scaleDown }}
scaleDown: true — every group is at replicas 0 and nothing is running. The
Services, Ingress, Secrets and the Certificate stay in place, so setting it
back to false brings the release up without re-issuing the certificate.
{{- end }}
{{- if .Values.bootstrap.enabled }}
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
RECOVERY MODE IS ON — THIS RELEASE SERVES NO MAIL
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
bootstrap.enabled is true, so every pod runs with STALWART_RECOVERY_MODE=1:
the management HTTP endpoint on {{ (index $ports "http").containerPort }} is the ONLY thing listening and
every mail port is closed. The Services and the Ingress look exactly as
healthy as they do in normal operation, which is why this banner exists.
This is correct for a first install (step 1 below). When the database is
provisioned, set bootstrap.enabled: false and upgrade again.
{{- end }}
THE 1PASSWORD ITEMS MUST CARRY THE FIELDS THIS CHART READS
One Secret key is created per FIELD of the item, and these templates
reference the keys by name — an item missing a field leaves the container in
CreateContainerConfigError. An EMPTY field fails the same way as a missing
one: Stalwart's secret resolver treats an empty environment variable as "not
found".
{{- if .Values.secrets.dataStoreSecretRef }}
secrets.dataStoreSecretRef → {{ .Values.secrets.dataStoreSecretRef }}
field: password (the data store password)
The USERNAME is not in this item and cannot be: authUsername is a plain
String on the DataStore object with no secret form. It is
dataStore.postgres.username = "{{ .Values.dataStore.postgres.username }}" and is rendered into the
ConfigMap.
{{- else }}
secrets.dataStoreSecretRef is NOT SET, so no data store password reaches the
pods and config.json references an environment variable that will not exist.
Stalwart fails to open the store with
Environment variable '{{ include "stalwart.env.dbPassword" . }}' not found
Set it unless the database genuinely takes no password.
{{- end }}
{{- if .Values.secrets.recoveryAdminSecretRef }}
secrets.recoveryAdminSecretRef → {{ .Values.secrets.recoveryAdminSecretRef }}
field: recovery-admin (the COMPLETE value "admin:<password>")
One field, not two. The upstream parser splits on the first ":" and drops
the entry when either half is empty, so a half-filled item would silently
mean "no recovery admin" instead of failing — and this credential logs in
regardless of directory state.
REMOVE IT WHEN YOU ARE DONE. It is a back door. Once a permanent
administrator exists in the database, set secrets.recoveryAdminSecretRef
to null and upgrade; the variable then disappears from the pods.
{{- else }}
secrets.recoveryAdminSecretRef is NOT SET. That is the right end state once a
permanent administrator exists — but during a first install or a recovery
start it means Stalwart generates a temporary administrator password and
prints it to STDOUT, i.e. into the pod log, freshly on every restart and
visible to anyone who can read logs. Catch it with
kubectl -n {{ .Release.Namespace }} logs sts/{{ $release }}-{{ $ingressGroup | default "<group>" }}-sfs | grep -A3 bootstrap
{{- end }}
{{- if .Values.secrets.s3SecretRef }}
secrets.s3SecretRef → {{ .Values.secrets.s3SecretRef }}
fields: access-key, secret-key
Exposed to the containers as {{ include "stalwart.env.s3AccessKey" . }} and
{{ include "stalwart.env.s3SecretKey" . }}. The chart does NOT configure the blob
store; point the BlobStore object in the WebUI at these names:
accessKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3AccessKey" . }}"}
secretKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3SecretKey" . }}"}
{{- end }}
CHANGING WHAT IS INSIDE AN ITEM RESTARTS NOTHING. Helm never sees a secret's
content, so there is nothing to hash. Repointing one of the values above at a
different vault item DOES roll the pods (checksum/secret-ref covers the
op:// path). After rotating a password inside an item:
{{- range $group, $spec := $nodes }}
kubectl -n {{ $.Release.Namespace }} rollout restart statefulset/{{ $release }}-{{ $group }}-sfs
{{- end }}
FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
1. bootstrap.enabled: true, nodes.mx.replicas: 0.
The setup wizard everyone reaches for appears only when config.json is
ABSENT, and this chart always renders one — so the wizard never runs here.
Recovery mode is the documented path for a templated config.json: it
opens the management endpoint and keeps the mail ports shut. With a second
group running, the wizard would be unusable anyway: each request would
land on a random pod.
2. Open the management UI and create the permanent administrator.
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $release }}-{{ $ingressGroup | default "<group>" }}-http-service {{ (index $ports "http").containerPort }}:{{ (index $ports "http").containerPort }}
then http://localhost:{{ (index $ports "http").containerPort }}/admin
3. In the WebUI, create: the blob store (S3), the in-memory store
({{ if $valkeyHost }}{{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }}{{ else }}no address configured — see externalServices.valkey{{ end }}),
the coordinator (Default — it reuses the in-memory store connection), the
search store (Default — it uses the data store, so no extra service is
needed), the TLS object pointing at the certificate from step 5, the
NetworkListener objects, and one ClusterRole per node group below.
THE LISTENER PORTS MUST MATCH THIS RELEASE EXACTLY. The chart cannot
create listener objects, so these numbers are a second copy of a decision
made in the WebUI, and a mismatch is silent — the Service points at a port
nothing listens on, every pod stays Ready, nothing reports it:
{{- range $portName, $port := $ports }}
{{ printf "%-12s" $portName }} bind {{ printf "%-5s" (printf "%v" $port.containerPort) }}{{ if $port.public }} (published on node port {{ $port.nodePort }}){{ else }} (not published){{ end }}
{{- end }}
AND ONE ClusterRole PER GROUP, NAMED EXACTLY:
{{- range $group, $spec := $nodes }}
{{ $group }}
{{- end }}
A node whose role name is not in the database does not fail. It becomes a
FULL node — every listener and every task — while staying Running with a
green readiness probe. The only trace is one log line,
'Cluster role "<name>" not found in registry', and it is printed AFTER the
listeners are already bound.
4. Configure HTTP forwarding before the auto-ban notices the Ingress.
Stalwart sees the ingress controller's pod IP for every WebUI and JMAP
request, not the client's. Sooner or later the auto-ban bans that one
address and the WebUI and JMAP go dark for everyone at once. The PROXY
protocol is not the fix for HTTP: enable "forwarded" handling with
X-Forwarded-For evaluation, and discard forwarding headers the client
brought itself. The outage arrives weeks later and looks like a server
fault, which is why this is a step and not a tip.
5. Point the TLS object at the mounted certificate.
{{- if .Values.certificate.enabled }}
certificate: {{ .Values.certificate.mountPath }}/tls.crt
privateKey: {{ .Values.certificate.mountPath }}/tls.key
cert-manager writes these into tls-{{ $release }}-mail. Watch it with
kubectl -n {{ .Release.Namespace }} get certificate {{ $release }}-mail-certificate
Until it is issued the mail ports serve a self-signed certificate: the
volume is mounted optional so that a pending challenge does not hold every
pod in ContainerCreating.
{{- else }}
certificate.enabled is false, so this chart issues no mail certificate.
The Ingress certificate does NOT cover the mail ports — the HA proxy
terminates no TLS, so Stalwart has to present one itself for STARTTLS on
25 and implicit TLS on 465/993. Provide it another way or clients will see
a name mismatch.
{{- end }}
6. Set send-proxy-v2 on the HA proxy. ALL OR NOTHING WITH STEP 3.
externalTrafficPolicy is {{ .Values.service.externalTrafficPolicy }}, which it has to be — no Stalwart
pod runs on a control-plane node, and the HA proxy delivers to the
control-plane nodes, so Local would drop every packet. The cost is that
kube-proxy masquerades the source address, and on a mail server a lost
source address switches off SPF, takes DMARC with it, makes every
blocklist report "clean", collapses per-IP rate limits onto one address,
removes greylisting's key, and makes the auto-ban lock out every sender at
once. The Received: chain becomes worthless — the one item there that
destroys evidence retroactively rather than just failing.
The PROXY v2 header restores it, for port 25 as well, because the HA proxy
opens its own connection to the node. But a trusted network makes the
header MANDATORY: without it a connection from there hangs until timeout,
and with it from an untrusted network Stalwart reads "PROXY TCP4 ..." as
an SMTP command. Test on a spare port with its own frontend, and move 25
only once a Received: line shows the real sender address.
7. Restart the pods — "a newly added listener binds only on the next start" —
and set bootstrap.enabled: false in the same round.
8. Raise nodes.mx.replicas only after the "mx" ClusterRole exists (step 3),
and publish DNS (below).
DNS A MAIL SERVER CANNOT RUN WITHOUT
None of this is rendered by the chart; all of it is required.
MX {{ .Values.ingress.domain | default "<your mail domain>" }}. → this deployment's public name
SPF TXT "v=spf1 ..." naming the HA proxy's address as a sender
DKIM TXT at <selector>._domainkey — the key is generated IN Stalwart
(Domains → DKIM), so this record is published after step 3
DMARC TXT at _dmarc — start at p=none with rua= and tighten only once
the reports are clean
REVERSE DNS PTR for the HA PROXY's public address, matching its forward
name. Not the node's and not the pod's: the HA proxy is what
other mail servers see connecting. Many receivers reject mail
outright when it is missing, and nothing in Kubernetes can set
it — it belongs to whoever owns the IP range.
WHAT A GREEN POD PROVES HERE, AND WHAT IT DOES NOT
Liveness asks /healthz/live, readiness and startup ask /healthz/ready.
Read off the source at v0.16.24: /healthz/ready returns 200 as soon as a data
store HANDLE exists — it does not query the database. So Ready does not mean
the database is reachable, and it does not catch a pod that fell back to an
ephemeral in-memory store because config.json was missing. A green pod here
means the process is answering HTTP.
The probe intervals are calculated, not chosen: Stalwart rate-limits
anonymous /healthz requests to 100 per 60s PER SOURCE IP, probes arrive from
the node IP, and so every pod on a node shares one bucket. The defaults cost
6 requests/min/pod and leave room for about 16 pods per node. Tightening them
produces 429s, and a 429 on liveness is a restart loop.
{{- if .Values.ingress.enabled }}
WEB UI AND JMAP
https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}"
Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE. Upstream moves
administration to https://<hostname>/admin once setup is complete, so this
Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the
database (step 3). Between finishing setup and creating that listener the
Ingress resolves to a port that answers nothing.
{{- else }}
NO INGRESS
ingress.enabled is false, so there is no Ingress and no WebUI certificate.
ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its
dnsNames, and without it the certificate would carry no name at all.
{{- end }}
{{- if .Values.valkeySubchart.enabled }}
THE VALKEY SUBCHART IS ENABLED
Reachable at {{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }} — this repo's own valkey chart,
pulled from the published index. Enter that address as the in-memory store in
step 3; the coordinator's Default variant reuses the same connection.
ITS 1PASSWORD ITEM IS A SEPARATE ONE, AND ITS FIELD IS NOT A PASSWORD:
valkey.secrets.authSecretRef points at
{{ .Values.valkey.secrets.authSecretRef | default "<NOT SET - the subchart's own required() will abort the render>" }}
and that item carries ONE field, "auth-conf", whose value is the complete
configuration line
requirepass <password>
not a bare password. The valkey chart mounts the file and includes it, and
an init container refuses to start the pod unless the line is really there —
because the alternative failure is an open server that looks healthy.
IT IS CONFIGURED AS A CACHE, NOT A QUEUE
persistence.enabled false + maxmemoryPolicy allkeys-lru. Everything Stalwart
keeps here — rate-limiter counters, auth tokens, sessions, auto-ban entries,
the coordination pub/sub — is reconstructible after a restart; nothing in it
is the system of record.
The cost of allkeys-lru, so it is not a surprise: under memory pressure keys
are discarded, so the rate limiters and the auto-ban FAIL OPEN. Size memory
for the working set rather than switching to noeviction — with noeviction
Stalwart's writes to the store start erroring, which takes out
authentication instead of just the limits.
scaleDown and checksum/config stop at the subchart boundary, so scaling this
release to zero does NOT stop the Valkey, and a new valkey chart release
reaches you only when stalwart itself is republished.
{{- end }}
{{- if not .Values.networkPolicy.enabled }}
NETWORKPOLICY IS OFF
networkPolicy.enabled is false, so nothing limits reachability except the
Services. That removes the second line of defence against the fail-open in
step 3: a group that accidentally became a full node is then reachable on
every port it bound.
{{- else if not .Values.networkPolicy.probeSources }}
NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
networkPolicy.probeSources is empty, so the rule for port {{ (index $ports "http").containerPort }} renders with
no "from" selector. Deliberate: the kubelet probes from the worker node's IP,
which this chart cannot know, and a wrong guess blocks every probe — a pod
that never becomes Ready reads as a broken application rather than as a
policy. The port carries no nodePort, so this is open inside the cluster, not
from outside. Narrow it with networkPolicy.probeSources, and then add the
ingress controller's namespace to networkPolicy.allowedNamespaces or the
Ingress goes dark.
{{- end }}
+433
View File
@@ -0,0 +1,433 @@
{{/*
Resource / replica / null-safety helpers.
These are copied from template-chart with "stalwart." as the define prefix,
as in every other chart here: sharing them would mean a library chart, and a
dependency is admissible but expensive (README, guideline 14) - this chart
already carries one and does not need a second. template-chart holds the
reference implementation; the resource-quantity render test in CI exists
because a copy can carry an old bug into a diff nobody reads.
*/}}
{{- define "stalwart.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 "stalwart.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "stalwart.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "stalwart.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 "stalwart.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>.
*/}}
{{- define "stalwart.multiplyQuantity" -}}
{{- $quantity := include "stalwart.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 "stalwart.formatNumber" $product) $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render a number as a plain decimal string, never in exponential notation
(issue #157): the null-safe helpers pass values through fromYaml, which parses
every number as float64, and Go's "%v" switches to exponential notation from
about 1e6 upwards - not a valid Kubernetes quantity.
*/}}
{{- define "stalwart.formatNumber" -}}
{{- if kindIs "float64" . -}}
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
{{- else -}}
{{- printf "%v" . -}}
{{- end -}}
{{- end -}}
{{/*
The replica count of one node group.
Input: dict "scaleDown" <bool|nil> "replicas" <int|nil>.
The explicit kindIs "invalid" check is what keeps an explicit 0 - which this
chart relies on, since the "mx" group ships with replicas: 0.
*/}}
{{- define "stalwart.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
(issues #61 and #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
<string>. Output: YAML, consume it with fromYaml.
*/}}
{{- define "stalwart.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 "stalwart.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>.
*/}}
{{- define "stalwart.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 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.
Two rules these fallbacks exist to honour (README, guideline 12):
- reproduce THIS chart's defaults, not the repo standard. uid/gid 2000 is the
image's own user, and there is deliberately NO fsGroup here because this
chart mounts no PVC.
- carry the probe's CONTEXT, not just its timings. The paths and the port
name are part of these defaults; without them the probes would hit "/" on
a port that may not exist, and liveness would stop being independent of
the data store.
*/}}
{{- define "stalwart.podSecurityContext" -}}
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "pod" "path" "securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 2000
"runAsGroup" 2000
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.pod") -}}
{{- end -}}
{{- define "stalwart.containerSecurityContext" -}}
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "container" "path" "securityContext"))).value -}}
{{/*
NET_BIND_SERVICE is part of the FALLBACK, not only of values.yaml, and that is
the whole point of rule "reproduce THIS chart's defaults, not the repo
standard". The image carries setcap cap_net_bind_service=+ep on its binary, so
with an empty bounding set the kernel fails the execve itself - an erased
securityContext block that fell back to the repo-standard "add: []" would stop
the chart from starting at all, with "operation not permitted" and nothing
pointing at this file.
*/}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list "NET_BIND_SERVICE")) -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.container") -}}
{{- end -}}
{{/*
The probe fallbacks carry the calculated intervals, not just the paths. The
arithmetic is in values.yaml: Stalwart rate-limits anonymous /healthz requests
to 100 per 60s PER SOURCE IP, probes come from the node IP, so every pod on a
node shares one bucket. 20/20/10 costs 6 requests/min/pod in both phases
(the startup probe gates the other two, so they never run together) and leaves
room for ~16 pods per node. The obvious 10/10/5 would halve that to ~8.
Tightening these here without redoing that sum produces 429s, and a 429 on
liveness is a restart loop.
*/}}
{{- define "stalwart.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz/live" "port" "http")
"periodSeconds" 20
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "livenessProbe") -}}
{{- end -}}
{{- define "stalwart.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz/ready" "port" "http")
"periodSeconds" 20
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "readinessProbe") -}}
{{- end -}}
{{- define "stalwart.startupProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz/ready" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "startupProbe") -}}
{{- end -}}
{{/*
=============================================================================
Chart-specific helpers
=============================================================================
*/}}
{{/*
The common name prefix. One place, because it appears in every object name and
in the Service selectors.
*/}}
{{- define "stalwart.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name -}}
{{- end -}}
{{/*
The validated "ports" map. Rejects a non-mapping and an empty map, and checks
every entry for the one field the chart cannot do without.
Why the number is checked here rather than where it is used: an erased
ports.smtp.number used to render "port:" and "targetPort:" empty into the
Service and an empty "containerPort" into the StatefulSet - rejected by nothing
until the API server saw it (the satisfactory lesson, Gitea #16).
*/}}
{{- define "stalwart.ports" -}}
{{- $ports := .Values.ports -}}
{{- if kindIs "invalid" $ports -}}
{{- fail "ports must be a mapping of port name to { containerPort, nodePort, protocol, public }, and it cannot be erased: every Service, containerPort and NetworkPolicy rule in this chart is rendered from it" -}}
{{- end -}}
{{- if not (kindIs "map" $ports) -}}
{{- fail (printf "ports must be a mapping, got %s" (kindOf $ports)) -}}
{{- end -}}
{{- if eq (len $ports) 0 -}}
{{- fail "ports is empty - the chart would render workloads with no containerPort and no Service at all" -}}
{{- end -}}
{{- range $name, $spec := $ports -}}
{{- if not (kindIs "map" $spec) -}}
{{- fail (printf "ports.%s must be a mapping, got %s (%v)" $name (kindOf $spec) $spec) -}}
{{- end -}}
{{- if kindIs "invalid" $spec.containerPort -}}
{{- fail (printf "ports.%s.containerPort is required - a port without one renders an empty containerPort and an empty Service targetPort. Note containerPort and nodePort are two different numbers here and are not interchangeable: containerPort is what the application binds (and what the NetworkListener object in the database must say), nodePort is what the cluster publishes" $name) -}}
{{- end -}}
{{- if and $spec.public (kindIs "invalid" $spec.nodePort) -}}
{{- fail (printf "ports.%s is marked public but has no nodePort - a NodePort Service without a nodePort would be assigned a random one, which the HA proxy in front of the cluster does not know about" $name) -}}
{{- end -}}
{{- end -}}
{{- toYaml $ports -}}
{{- end -}}
{{/*
The validated "nodes" map, with each group's exposedPorts checked against the
ports map. Input: the root context.
The exposedPorts check is the one that earns its keep: a typo there would
silently drop a port from a group's containerPorts and from its NetworkPolicy,
leaving a Service selecting pods that never bound the port.
*/}}
{{- define "stalwart.nodes" -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $nodes := .Values.nodes -}}
{{- if kindIs "invalid" $nodes -}}
{{- fail "nodes must be a mapping of cluster-role name to node group, and it cannot be erased: it is what every StatefulSet in this chart is rendered from" -}}
{{- end -}}
{{- if not (kindIs "map" $nodes) -}}
{{- fail (printf "nodes must be a mapping of cluster-role name to node group, got %s - note it is a MAP and not a list, so that an override never depends on a position" (kindOf $nodes)) -}}
{{- end -}}
{{- if eq (len $nodes) 0 -}}
{{- fail "nodes is empty - the release would contain Services and an Ingress but no workload" -}}
{{- end -}}
{{- range $group, $spec := $nodes -}}
{{- if not (kindIs "map" $spec) -}}
{{- fail (printf "nodes.%s must be a mapping, got %s (%v)" $group (kindOf $spec) $spec) -}}
{{- end -}}
{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" $group) -}}
{{- fail (printf "nodes.%s is not usable as a Kubernetes name segment: a group key becomes part of every object name and of a label value, so it must be lowercase alphanumeric with dashes. It is ALSO the cluster-role name passed in STALWART_ROLE, so renaming it here without renaming the ClusterRole object in the database turns the group into a full node" $group) -}}
{{- end -}}
{{- $exposed := $spec.exposedPorts -}}
{{- if kindIs "invalid" $exposed -}}
{{- fail (printf "nodes.%s.exposedPorts is required - a group with no ports is unreachable, and because this list is also the NetworkPolicy's ingress rule, an erased one is indistinguishable from a deliberate lockout" $group) -}}
{{- end -}}
{{- if not (kindIs "slice" $exposed) -}}
{{- fail (printf "nodes.%s.exposedPorts must be a list of port names, got %s (%v)" $group (kindOf $exposed) $exposed) -}}
{{- end -}}
{{- range $exposed -}}
{{- if not (hasKey $ports .) -}}
{{- fail (printf "nodes.%s.exposedPorts names %q, which is not a key of ports: - known ports are %s. Left unchecked this would drop the port from the group's containerPorts and from its NetworkPolicy while the Service still selected its pods" $group . (keys $ports | sortAlpha | join ", ")) -}}
{{- end -}}
{{- end -}}
{{- if not (has "http" $exposed) -}}
{{- fail (printf "nodes.%s.exposedPorts does not include \"http\", but all three probes target that port - the group would never become Ready. Every group needs it, including an SMTP-only one; it is simply not published there" $group) -}}
{{- end -}}
{{- end -}}
{{- toYaml $nodes -}}
{{- end -}}
{{/*
The effective resources of one node group.
Input: dict "group" <name> "given" <the group's resources block|nil>
"limitFactor" <the chart-global .Values.limitFactor|nil>.
The per-group defaults are reproduced here, not a single generic set: "mx" is
deliberately lighter than "full", and a fallback that handed an erased
mx.resources the heavier figures would quietly change what the chart had been
rendering. A group the chart does not ship gets the "full" figures, which is
the safe direction - too much accounting rather than too little.
*/}}
{{- define "stalwart.effectiveResources" -}}
{{- $factor := 3 -}}
{{- if not (kindIs "invalid" .limitFactor) -}}
{{- $factor = .limitFactor -}}
{{- end -}}
{{- $requests := dict "cpu" "200m" "memory" "512Mi" "ephemeral-storage" "64Mi" -}}
{{- if eq .group "mx" -}}
{{- $requests = dict "cpu" "100m" "memory" "256Mi" "ephemeral-storage" "64Mi" -}}
{{- end -}}
{{- $defaults := dict "limitFactor" $factor "requests" $requests -}}
{{- $merged := fromYaml (include "stalwart.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "nodes.%s.resources" .group))) -}}
{{- include "stalwart.resources" $merged -}}
{{- end -}}
{{/*
The label a pod carries for one port it is reachable on. The per-port Services
select on these, which is what lets one NodePort Service serve several node
groups - NodePorts are cluster-globally unique, so "full" and "mx" can only
accept SMTP behind the SAME Service object.
*/}}
{{- define "stalwart.portLabel" -}}
{{- printf "stalwart.jk-dev.de/port-%s" . -}}
{{- end -}}
{{/*
The address of the in-memory store, and the render abort that goes with it.
Input: the root context. Returns the host, or "" when none is needed.
The abort is the point. From v0.16 the cluster coordinator's Default variant
"reuses the Redis connection already configured on the in-memory store", so
more than one node without an in-memory store is a cluster with no
coordination path - and the symptom is not a failed install but a cluster that
comes up and disagrees with itself. Note what this value does NOT do: it does
not configure Stalwart. The in-memory store is an object in the database; this
is the address to type into the WebUI, and the port for the egress rule.
*/}}
{{- define "stalwart.valkeyHost" -}}
{{- $given := "" -}}
{{- if not (kindIs "invalid" .Values.externalServices) -}}
{{- $valkey := .Values.externalServices.valkey -}}
{{- if and (not (kindIs "invalid" $valkey)) (not (kindIs "invalid" $valkey.host)) -}}
{{- $given = $valkey.host -}}
{{- end -}}
{{- end -}}
{{- $subchartOn := false -}}
{{- if and (not (kindIs "invalid" .Values.valkeySubchart)) .Values.valkeySubchart.enabled -}}
{{- $subchartOn = true -}}
{{- end -}}
{{- if $given -}}
{{- $given -}}
{{- else if $subchartOn -}}
{{/* The Service our own valkey chart renders:
{{ .Release.Name }}-{{ .Chart.Name }}-service
and inside a subchart .Chart.Name is the SUBCHART's name, so this is
"<release>-valkey-service" with no special case. Reproduced rather than
included, because a parent cannot call a subchart's define with the
subchart's context.
Note the "-service" suffix: this repo's naming scheme ends every Service
that way, which is NOT what upstream's valkey chart does (it would be a
bare "<release>-valkey"). An earlier draft of this chart depended on
upstream's and carried their name; getting this wrong points the operator
at an address that does not resolve, and nothing in the chart would say
so. */}}
{{- printf "%s-valkey-service" .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $total := 0 -}}
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) -}}
{{- $total = add $total (int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas))) -}}
{{- end -}}
{{- if gt $total 1 -}}
{{- fail (printf "this release runs %d Stalwart pods, which is a cluster, but no in-memory store has an address: externalServices.valkey.host is empty and valkeySubchart.enabled is false. Stalwart's cluster coordinator rides on the in-memory store connection, so a multi-node install has no coordination path at all - and that failure does not show up as a failed install, it shows up as nodes that do not agree with each other. Set externalServices.valkey.host to an existing instance, or set valkeySubchart.enabled: true to deploy the subchart" $total) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
The environment-variable names that config.json references and the StatefulSet
sets. They are helpers rather than literals in two files because a mismatch
between the two is not a render error: the server starts, resolves authSecret,
finds no such variable, and fails to open the data store with
"Environment variable '<name>' not found". One definition, used by both.
Note the upstream resolver treats an EMPTY value as not found, so a 1Password
item whose field exists but is blank fails exactly like a missing field.
*/}}
{{- define "stalwart.env.dbPassword" -}}STALWART_DB_PASSWORD{{- end -}}
{{- define "stalwart.env.s3AccessKey" -}}STALWART_S3_ACCESS_KEY{{- end -}}
{{- define "stalwart.env.s3SecretKey" -}}STALWART_S3_SECRET_KEY{{- end -}}
{{/*
The node group the Ingress routes to, validated. Input: the root context.
*/}}
{{- define "stalwart.ingressNodeGroup" -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $group := required "ingress.nodeGroup must name the node group the Ingress routes to" .Values.ingress.nodeGroup -}}
{{- if not (hasKey $nodes $group) -}}
{{- fail (printf "ingress.nodeGroup is %q, which is not a key of nodes: - known groups are %s" $group (keys $nodes | sortAlpha | join ", ")) -}}
{{- end -}}
{{- $group -}}
{{- end -}}
@@ -0,0 +1,56 @@
{{- /*
The certificate for the MAIL ports, issued by cert-manager and mounted into
every node group read-only.
WHY THE INGRESS CERTIFICATE IS NOT ENOUGH: the Ingress covers the WebUI and
JMAP only. The HA proxy in front of the cluster terminates no TLS, so Stalwart
itself has to present a certificate for STARTTLS on 25 and implicit TLS on 465
and 993. Those connections never pass through the ingress controller.
WHY NOT STALWART'S OWN ACME: the HA proxy forwards 25, 465 and 993 and not 80,
so an HTTP-01 challenge answered by Stalwart is unreachable from outside.
DNS-01 inside Stalwart would work and would duplicate what cert-manager
already does here.
THIS OBJECT ONLY OBTAINS THE FILES. Pointing Stalwart at them is a separate,
manual step: the TLS object in the DATABASE has to name the two paths below.
Until it does, the mail ports serve whatever default certificate Stalwart
generates for itself, and clients see a name mismatch while every Kubernetes
object here is green.
*/ -}}
{{- if .Values.certificate.enabled }}
{{- $domain := required "A Domain/Host must be provided (ingress.domain) - it is read here for the mail certificate as well as by the Ingress, so it stays required even with ingress.enabled false" .Values.ingress.domain -}}
{{- /* An explicit if, NOT
.Values.certificate.clusterIssuer | default (required "..." .Values.ingress.clusterIssuer)
which is the obvious one-liner and is wrong: Go evaluates a pipeline's
arguments eagerly, so required() fires whenever ingress.clusterIssuer is
null even though certificate.clusterIssuer is set and the fallback is
never needed. Measured: ingress.enabled=false plus
certificate.clusterIssuer=<issuer> aborted the render. */ -}}
{{- $issuer := .Values.certificate.clusterIssuer -}}
{{- if not $issuer -}}
{{- $issuer = required "A Cluster-Issuer must be provided: set certificate.clusterIssuer for the mail certificate (usually a DNS-01 issuer), or ingress.clusterIssuer, which it falls back to" .Values.ingress.clusterIssuer -}}
{{- end -}}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "stalwart.fullname" . }}-mail-certificate
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Deliberately NOT the Ingress TLS secret name (tls-<release>-stalwart-ingress).
# Two separate certificates on purpose: this one usually comes from a
# different issuer (DNS-01, see below) and covers different names, and
# sharing one secret would make the ingress controller and the mail
# listeners fight over which names the certificate has to carry.
secretName: tls-{{ include "stalwart.fullname" . }}-mail
issuerRef:
name: {{ $issuer }}
kind: ClusterIssuer
group: cert-manager.io
dnsNames:
- {{ $domain | quote }}
{{- range .Values.certificate.extraDnsNames }}
- {{ . | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,52 @@
{{- /*
config.json - the ONLY file Stalwart reads from disk, and the only part of its
configuration this chart owns.
It is exactly the DataStore object, with no wrapper: upstream's declarative
deployment page states "It only needs to contain the DataStore object", and the
reader confirms it - crates/store/src/registry/local.rs deserializes the whole
file straight into the DataStore enum, which is internally tagged on "@type".
Everything else (listeners, blob store, in-memory store, TLS objects, domains,
cluster roles) is a JMAP object in the database and is created in the WebUI.
WHY A CONFIGMAP AND NOT A SECRET: there is no credential in here. authSecret
is a tagged sub-object with an EnvironmentVariable variant, so the password is
referenced by NAME and arrives through the env (see the StatefulSet). That is
what keeps checksum/config meaningful - Helm can hash this file because Helm
can see all of it.
The username is NOT a secret and cannot be made one: authUsername is a plain
String on the object with no secret form, unlike authSecret. It is public here
by necessity, not by oversight.
*/ -}}
{{- $pg := .Values.dataStore.postgres -}}
{{- $store := dict
"@type" "PostgreSql"
"host" (required "dataStore.postgres.host is required - without it Stalwart has no database, and a config.json that cannot be parsed or reached is the one case that does abort the start loudly" $pg.host)
"port" (int $pg.port)
"database" $pg.database
"authUsername" $pg.username
"authSecret" (dict "@type" "EnvironmentVariable" "variableName" (include "stalwart.env.dbPassword" .))
"useTls" $pg.tls.enable
"allowInvalidCerts" $pg.tls.allowInvalidCerts
"poolMaxConnections" (int $pg.poolMaxConnections)
"timeout" (int $pg.timeoutMs) -}}
{{- if not (kindIs "invalid" $pg.options) -}}
{{- $_ := set $store "options" $pg.options -}}
{{- end -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "stalwart.fullname" . }}-configmap
labels:
app: {{ include "stalwart.fullname" . }}
data:
# Rendered through toPrettyJson from a dict rather than written as a JSON
# literal: "timeout" is a Duration, which upstream's object encoding defines
# as an INTEGER COUNT OF MILLISECONDS and which explicitly "does not accept a
# string" - the serde impl deserializes it through u64::deserialize into
# Duration::from_millis. A hand-written literal is where a "15s" creeps in,
# and the server then refuses to parse the file at startup. Going through the
# dict also means "port" and "poolMaxConnections" cannot come out quoted.
config.json: |
{{- toPrettyJson $store | nindent 4 }}
+65
View File
@@ -0,0 +1,65 @@
{{- /*
The Ingress for the WebUI and JMAP only. SMTP, IMAP and ManageSieve are not
HTTP and cannot pass through it - those reach the pods through the per-port
NodePort Services.
It targets exactly ONE node group (ingress.nodeGroup) through that group's own
http Service, never a port-label selector: the management surface is not safe
to spread across a group that may not run the management tasks.
TWO TRAPS THAT BELONG TO THIS OBJECT AND ARE NOT SOLVED BY IT:
1. The auto-ban sees the INGRESS CONTROLLER's pod IP for every JMAP and WebUI
request, not the client's. Sooner or later it bans that address and the
WebUI and JMAP go dark for everyone at once. The PROXY protocol is not the
remedy for HTTP: the remedy is Stalwart's "forwarded" handling with
X-Forwarded-For evaluation, plus discarding whatever forwarding headers the
client brought along. That is a configuration step in the database, and it
is mandatory rather than optional - the failure arrives weeks later and
looks like a server problem.
2. AFTER BOOTSTRAP, PORT 8080 IS NOT THE ADMIN PORT ANY MORE. Upstream:
"After the restart, the WebUI is reached at https://<hostname>/admin on the
configured server hostname, and HTTP access on port 8080 is no longer
available for administration." So this Ingress only works once an HTTP
NetworkListener on 8080 exists in the database. Between finishing the setup
and creating that listener, the Ingress resolves to a port that answers
nothing.
*/ -}}
{{- if .Values.ingress.enabled }}
{{- $group := include "stalwart.ingressNodeGroup" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $spec := index $nodes $group -}}
{{- if not (has "http" $spec.exposedPorts) -}}
{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the Ingress to point at" $group $group) -}}
{{- end -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "stalwart.fullname" . }}-ingress
labels:
app: {{ include "stalwart.fullname" . }}
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .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:
# The http Service of the "{{ $group }}" group only.
name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ include "stalwart.fullname" . }}-ingress
{{- end }}
@@ -0,0 +1,136 @@
{{- /*
One NetworkPolicy per node group.
This object is the CEILING ON REACHABILITY, and that is its real job here: it
holds no matter which ports the process actually bound. A node whose
ClusterRole is missing from the database fail-opens into a full node and binds
every listener - including IMAP and the admin interface on a group meant to
take SMTP only - while staying Running with a green probe. The per-port
Services publish only what was asked for, and this policy is the second,
independent limit. See values.yaml for the source-level detail.
!!! THE PORT NUMBERS HERE ARE POD PORTS (25/465/993/8080), NOT NODEPORTS !!!
This is the easiest mistake to make in this chart, so it is spelled out. A
NetworkPolicy is evaluated at the POD's network interface, after kube-proxy has
already translated the NodePort to the targetPort. Writing 30025 here would
match nothing: the packet arrives at the pod on 25. The policy would then look
strict, be accepted by the API server, and silently block every inbound
connection - or, if it is the only rule, block the lot.
Egress is restricted BY PORT and not by destination, because a NetworkPolicy
matches CIDRs and selectors and never hostnames - so dataStore.postgres.host
and externalServices.*.host cannot appear in it. What it still buys: a
compromised pod cannot open arbitrary outbound ports.
And a NetworkPolicy is only a boundary where the cluster's CNI enforces one. An
unenforced policy is accepted and filters nothing.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $root := . -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $egress := .Values.networkPolicy.egress -}}
{{- $probeSources := .Values.networkPolicy.probeSources | default list -}}
{{- $allowedNs := .Values.networkPolicy.allowedNamespaces | default list -}}
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ $name }}-networkpolicy
labels:
app: {{ $name }}
spec:
podSelector:
matchLabels:
app: {{ $name }}-sfs
policyTypes:
- Ingress
- Egress
ingress:
{{- /* The public mail ports: open to any source. They have to be - the
senders are the whole internet, and after NodePort masquerading the
source address is a control-plane node anyway. */}}
{{- $publicPorts := list }}
{{- range $portName := $spec.exposedPorts }}
{{- $port := index $ports $portName }}
{{- if $port.public }}{{- $publicPorts = append $publicPorts $portName }}{{- end }}
{{- end }}
{{- if $publicPorts }}
- ports:
{{- range $portName := $publicPorts }}
{{- $port := index $ports $portName }}
# {{ $portName }}: published on nodePort {{ int $port.nodePort }}, reaching the pod on {{ int $port.containerPort }}
- protocol: {{ $port.protocol | default "TCP" }}
port: {{ int $port.containerPort }}
{{- end }}
{{- end }}
{{- /* The http port: probes plus the ingress controller. */}}
{{- $http := index $ports "http" }}
- ports:
- protocol: {{ $http.protocol | default "TCP" }}
port: {{ int $http.containerPort }}
{{- if or $probeSources $allowedNs }}
# Restricted because networkPolicy.probeSources or .allowedNamespaces is
# set. MIND THE KUBELET: probes come from the IP of the worker node
# running the pod, so that address has to be in probeSources or the pod
# never becomes Ready - and a pod that is never Ready reads as a broken
# application rather than as a policy.
from:
- namespaceSelector:
matchLabels:
# Set by Kubernetes itself on every namespace (>= 1.21), so this
# needs no labelling work.
kubernetes.io/metadata.name: {{ $root.Release.Namespace }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
# NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside
# the cluster: http carries no nodePort, so nothing outside reaches it
# through this chart. The alternative was to guess the worker node CIDRs,
# and a wrong guess blocks every kubelet probe. Narrow it by setting
# networkPolicy.probeSources (and then .allowedNamespaces for the
# ingress controller, or the Ingress goes dark).
{{- end }}
egress:
{{- if $egress.dns }}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- end }}
{{- if $egress.smtp }}
# Outbound delivery: port 25 on any host on the internet. Uniform for every
# group on purpose - the chart does not know which groups run the
# outboundMta task, the value would drift away from the database, and the
# failure mode of getting it wrong is mail silently not leaving.
- ports:
- protocol: TCP
port: 25
{{- end }}
{{- $outPorts := list (int $root.Values.dataStore.postgres.port) }}
{{- with $root.Values.externalServices }}
{{- if .s3 }}{{- $outPorts = append $outPorts (int .s3.port) }}{{- end }}
{{- if .valkey }}{{- $outPorts = append $outPorts (int .valkey.port) }}{{- end }}
{{- end }}
{{- range $root.Values.networkPolicy.egress.extraPorts }}
{{- $outPorts = append $outPorts (int .) }}
{{- end }}
# The data store, the blob store, the in-memory store and anything added
# through networkPolicy.egress.extraPorts. Ports only - see the header.
- ports:
{{- range $outPorts | uniq }}
- protocol: TCP
port: {{ . }}
{{- end }}
{{- end }}
{{- end }}
+75
View File
@@ -0,0 +1,75 @@
{{- /*
The 1Password items this chart materializes. Each is rendered only when its
op:// path is set, because two of the three are genuinely optional.
WHAT THE FIELD NAMES ARE AND WHY THEY ARE NOT FREE-FORM: the operator
materializes one Secret key per FIELD of the item, and these templates mount or
reference those keys by name. An item without the expected field leaves the
container in CreateContainerConfigError. NOTES.txt lists them for the installer.
AND THE LIMIT THAT CANNOT BE CLOSED: a change to the CONTENT of any of these
items does not restart anything. Helm never sees a secret's value - the
operator fills the Secret in-cluster after rendering - so there is nothing to
hash. What this chart does cover is the op:// PATH, through checksum/secret-ref
on the pod template (see the StatefulSet): repointing one of the values below
at a different vault item does roll the pods. For a content change, and after
rotating a password inside the item, the manual step is
kubectl -n <ns> rollout restart statefulset/<release>-stalwart-<group>-sfs
for every group. The 1Password operator's auto-restart annotation is NOT set
here and must not be: verified against operator 1.12.0 it enumerates
Deployments only, so on a StatefulSet it is a silent no-op that advertises a
guarantee it does not provide (README, guideline 9).
*/ -}}
{{- if .Values.secrets.dataStoreSecretRef }}
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ include "stalwart.fullname" . }}-datastore-secret
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Must carry the field: password
#
# Only the password. The data store's username is dataStore.postgres.username
# and is rendered into the ConfigMap, because authUsername is a plain String
# on the DataStore object while authSecret is a SecretKeyOptional - upstream
# offers a secret form for one and not the other.
itemPath: {{ .Values.secrets.dataStoreSecretRef }}
{{- end }}
{{- if .Values.secrets.recoveryAdminSecretRef }}
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ include "stalwart.fullname" . }}-recovery-admin-secret
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Must carry the field: recovery-admin
#
# Holding the COMPLETE value of STALWART_RECOVERY_ADMIN as one string,
# "admin:<password>". The chart deliberately does not assemble it from a
# username and a password field: the upstream parser splits on the first ":"
# and drops the entry when either half is empty, so a half-filled item would
# silently mean "no recovery admin" rather than failing - and this credential
# is a back door that logs in regardless of directory state, which is not
# something to be uncertain about in either direction.
itemPath: {{ .Values.secrets.recoveryAdminSecretRef }}
{{- end }}
{{- if .Values.secrets.s3SecretRef }}
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ include "stalwart.fullname" . }}-s3-secret
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Must carry the fields: access-key, secret-key
#
# This chart does not configure the blob store - that object lives in the
# database. It exposes both fields to the container as {{ include "stalwart.env.s3AccessKey" . }}
# and {{ include "stalwart.env.s3SecretKey" . }}, so the BlobStore object created in the WebUI can
# reference them by name instead of storing the credentials in the database.
itemPath: {{ .Values.secrets.s3SecretRef }}
{{- end }}
+123
View File
@@ -0,0 +1,123 @@
{{- /*
Three kinds of Service, and the split is forced rather than stylistic.
1. ONE NodePort Service PER PUBLIC PORT, not per node group. A NodePort is
unique across the whole cluster, so "full" and "mx" can only accept SMTP
behind the SAME Service object - two Services asking for 30025 is a
conflict. Hence the selector matches a per-port LABEL on the pods instead
of a node group, which lets one Service cover every group that lists the
port and still exclude the ones that do not.
Putting all the ports into one Service does not work either: a Service has
exactly one selector, so 30993 would then be routed to "mx" pods as well,
where nothing is listening.
2. ONE ClusterIP http Service PER NODE GROUP. The Ingress must reach exactly
one group (ingress.nodeGroup): the management surface is not safe to spread
across a group that may not run the management tasks, and a port-label
selector would send each request to a random group. So http gets a
per-group Service and the Ingress names one of them.
3. ONE headless Service PER NODE GROUP, for the StatefulSet's stable pod DNS.
*/ -}}
{{- $root := . -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $release := include "stalwart.fullname" . -}}
{{- /* 1. Per-port NodePort Services. */ -}}
{{- range $portName, $port := $ports }}
{{- if $port.public }}
{{- $groups := list }}
{{- range $group, $spec := $nodes }}
{{- if has $portName $spec.exposedPorts }}{{- $groups = append $groups $group }}{{- end }}
{{- end }}
{{- if $groups }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $release }}-{{ $portName }}-service
labels:
app: {{ $release }}
{{- with $root.Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: NodePort
# Cluster, never Local. No Stalwart pod is ever scheduled on a control-plane
# node, and the HA proxy delivers to the control-plane nodes - under Local
# every packet would be dropped, not some. The cost is that kube-proxy
# masquerades the source address; the PROXY v2 header the HA proxy prepends
# is what makes that harmless. values.yaml has the full reasoning and the
# list of what a lost source address breaks on a mail server.
externalTrafficPolicy: {{ $root.Values.service.externalTrafficPolicy }}
selector:
# Scoped to this release, then to the port. Node groups serving this port:
# {{ $groups | sortAlpha | join ", " }}
stalwart.jk-dev.de/release: {{ $release }}
{{ include "stalwart.portLabel" $portName }}: "true"
ports:
- name: {{ $portName }}
protocol: {{ $port.protocol | default "TCP" }}
# port and nodePort are the published number; targetPort is the number
# the application binds inside the pod. They differ here on purpose.
port: {{ int $port.nodePort }}
nodePort: {{ int $port.nodePort }}
targetPort: {{ $portName }}
{{- end }}
{{- end }}
{{- end }}
{{- /* 2. + 3. Per-group http ClusterIP and headless Services. */ -}}
{{- range $group, $spec := $nodes }}
{{- $name := printf "%s-%s" $release $group }}
{{- if has "http" $spec.exposedPorts }}
{{- $http := index $ports "http" }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}-http-service
labels:
app: {{ $name }}
{{- with $root.Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: ClusterIP
selector:
app: {{ $name }}-sfs
ports:
- name: http
protocol: {{ $http.protocol | default "TCP" }}
port: {{ int $http.containerPort }}
targetPort: http
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}-headless
labels:
app: {{ $name }}
spec:
# Headless: this exists for the StatefulSet's stable per-pod DNS names
# (<pod>.<release>-stalwart-<group>-headless), not for load balancing.
clusterIP: None
# So the DNS names resolve before a pod is Ready - a cluster member has to be
# findable by its peers while it is still starting up.
publishNotReadyAddresses: true
selector:
app: {{ $name }}-sfs
ports:
{{- range $portName := $spec.exposedPorts }}
{{- $port := index $ports $portName }}
- name: {{ $portName }}
protocol: {{ $port.protocol | default "TCP" }}
port: {{ int $port.containerPort }}
targetPort: {{ $portName }}
{{- end }}
{{- end }}
+266
View File
@@ -0,0 +1,266 @@
{{- /*
One StatefulSet per node group. The group key is the cluster-role name and is
passed in STALWART_ROLE.
A StatefulSet and not a Deployment, although this chart mounts no PVC: the
node-id lease Stalwart acquires for clustering is keyed on the hostname, and
only a StatefulSet gives a pod a stable one. STALWART_HOSTNAME is fed from the
pod name for exactly that reason.
NO volumeClaimTemplates. All state is external - messages and metadata in
Postgres, blobs in S3, the in-memory store in Valkey - so both image volumes
are emptyDirs. That is also why there is no fsGroup in the pod context.
*/ -}}
{{- $root := . -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- /* Evaluated here, in a template that is ALWAYS rendered, because this is
where the "cluster without an in-memory store" render abort lives. A
guard that only ran from NOTES.txt would never fire under helm template
or helm lint - NOTES.txt is not rendered by either - and would therefore
pass every check in CI while being broken (guideline 12.2). The value is
emitted as a comment below, which is also the address an operator has to
enter in the WebUI. */ -}}
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
---
{{ if $valkeyHost -}}
# In-memory store this release expects: {{ $valkeyHost }}:{{ $root.Values.externalServices.valkey.port }}
# NOT configured by this chart - it is an InMemoryStore object in the database,
# and this is the address to enter in the WebUI. The cluster coordinator's
# Default variant reuses the same connection.
{{ end -}}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ $name }}-sfs
labels:
app: {{ $name }}-sfs
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" $root }}
stalwart.jk-dev.de/node-group: {{ $group }}
spec:
replicas: {{ include "stalwart.replicas" (dict "scaleDown" $root.Values.scaleDown "replicas" $spec.replicas) }}
serviceName: {{ $name }}-headless
selector:
matchLabels:
app: {{ $name }}-sfs
template:
metadata:
name: {{ $name }}-sfs
labels:
app: {{ $name }}-sfs
# Scopes every per-port Service to THIS release, so a second stalwart
# release in the same namespace does not get its pods selected.
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" $root }}
stalwart.jk-dev.de/node-group: {{ $group }}
{{- /* One label per port this group may be reached on. The per-port
Services select on these: NodePorts are cluster-globally
unique, so "full" and "mx" can only accept SMTP behind the SAME
Service object, and a label is what lets one selector cover
both groups while still excluding a group that does not list
the port. */}}
{{- range $spec.exposedPorts }}
{{ include "stalwart.portLabel" . }}: "true"
{{- end }}
annotations:
# Hash of the rendered ConfigMap: a changed config.json changes the pod
# template and therefore restarts the pods. Without it the change would
# be invisible to the running container - the file is mounted by
# subPath, which the kubelet never refreshes, and Stalwart reads it only
# at start.
checksum/config: {{ include (print $root.Template.BasePath "/stalwart.configmap.yaml") $root | sha256sum }}
# Hash of the rendered OnePasswordItems - the op:// PATHS, not their
# contents. Repointing secrets.* at a different vault item therefore
# takes effect instead of leaving the old credentials in the running
# pod. Named separately from checksum/config on purpose: that one
# promises "the config changed", this one only "the config SOURCE
# changed". A change inside an item is not covered and cannot be -
# Helm never sees a secret's content.
checksum/secret-ref: {{ include (print $root.Template.BasePath "/stalwart.secret.yaml") $root | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "stalwart.podSecurityContext" $root.Values.securityContext | nindent 8 }}
initContainers:
# config.json is copied out of the ConfigMap into the emptyDir rather
# than mounted from it, because STALWART WRITES THIS FILE ITSELF: on
# completing its setup wizard it "writes config.json ... and restarts
# into normal operation", and a ConfigMap mount is read-only. Copying
# keeps /etc/stalwart writable AND keeps checksum/config honest, which
# a bare emptyDir would not.
#
# The copy direction matters: the chart's version wins on every pod
# start. If an operator has let the server rewrite the file, that
# rewrite is discarded on the next restart - which is the intended
# behaviour here (the chart owns the data store setting) but is worth
# knowing before editing it through the WebUI.
- name: copy-config
image: "{{ $root.Values.initImage.repository }}:{{ $root.Values.initImage.tag }}"
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- cp /chart-config/config.json /etc/stalwart/config.json
volumeMounts:
- name: config
mountPath: /chart-config
readOnly: true
- name: etc-stalwart
mountPath: /etc/stalwart
resources:
{{- include "stalwart.resources" (dict "limitFactor" 3 "requests" (dict "cpu" "10m" "memory" "16Mi" "ephemeral-storage" "8Mi")) | nindent 12 }}
securityContext:
{{- include "stalwart.containerSecurityContext" $root.Values.securityContext | nindent 12 }}
containers:
- name: {{ $name }}-sfs
image: "docker.io/stalwartlabs/stalwart:{{ $root.Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
env:
# The cluster role. A ClusterRole object of exactly this name has
# to exist in the database - if it does not, the node silently
# becomes a FULL node (every listener AND every task) while staying
# Running with a green readiness probe. See values.yaml.
- name: STALWART_ROLE
value: {{ $group | quote }}
# The node identity for the cluster node-id lease. The pod name is
# stable because this is a StatefulSet; the OS hostname inside the
# pod would be too, but reading it from the API is what makes the
# value survive a hostname the image cannot change.
- name: STALWART_HOSTNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
{{- if $root.Values.bootstrap.enabled }}
# RECOVERY MODE IS ON: management HTTP on 8080 only, every mail
# port closed. Required for a first install because this chart
# always renders config.json and the bootstrap wizard only appears
# when that file is ABSENT. Turn it off and upgrade again once the
# database is provisioned.
- name: STALWART_RECOVERY_MODE
value: "1"
- name: STALWART_RECOVERY_MODE_LOG_LEVEL
value: {{ $root.Values.bootstrap.logLevel | quote }}
{{- end }}
{{- if $root.Values.secrets.dataStoreSecretRef }}
# Referenced BY NAME from config.json as
# "authSecret": {"@type":"EnvironmentVariable","variableName":"..."}
# which is what keeps the password out of the ConfigMap. An empty
# value counts as "not found" upstream and fails the store open.
- name: {{ include "stalwart.env.dbPassword" $root }}
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-datastore-secret
key: password
{{- end }}
{{- if $root.Values.secrets.recoveryAdminSecretRef }}
- name: STALWART_RECOVERY_ADMIN
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-recovery-admin-secret
key: recovery-admin
{{- end }}
{{- if $root.Values.secrets.s3SecretRef }}
# Not read by the chart and not by config.json: these exist so the
# BlobStore object in the DATABASE can reference them instead of
# storing the credentials there.
- name: {{ include "stalwart.env.s3AccessKey" $root }}
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-s3-secret
key: access-key
- name: {{ include "stalwart.env.s3SecretKey" $root }}
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-s3-secret
key: secret-key
{{- end }}
{{- range $value := $spec.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "8080" or "true"
# renders unquoted as a YAML number/bool and the API server
# rejects the whole manifest.
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 }}
ports:
{{- range $portName := $spec.exposedPorts }}
{{- $port := index $ports $portName }}
- name: {{ $portName }}
# containerPort, NOT nodePort. This is the number the application
# binds and the number the NetworkListener object in the database
# has to carry; the nodePort only appears in the Service.
containerPort: {{ int $port.containerPort }}
protocol: {{ $port.protocol | default "TCP" }}
{{- end }}
# 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".
livenessProbe:
{{- include "stalwart.livenessProbe" $root.Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "stalwart.readinessProbe" $root.Values.readinessProbe | nindent 12 }}
startupProbe:
{{- include "stalwart.startupProbe" $root.Values.startupProbe | nindent 12 }}
resources:
{{- include "stalwart.effectiveResources" (dict "group" $group "given" $spec.resources "limitFactor" $root.Values.limitFactor) | nindent 12 }}
securityContext:
{{- include "stalwart.containerSecurityContext" $root.Values.securityContext | nindent 12 }}
volumeMounts:
- name: etc-stalwart
mountPath: /etc/stalwart
- name: var-lib-stalwart
mountPath: /var/lib/stalwart
- name: tmp
mountPath: /tmp
{{- if $root.Values.certificate.enabled }}
- name: mail-tls
mountPath: {{ $root.Values.certificate.mountPath }}
readOnly: true
{{- end }}
volumes:
- name: config
configMap:
name: {{ include "stalwart.fullname" $root }}-configmap
# Writable because Stalwart rewrites config.json itself after its setup
# wizard; the init container seeds it from the ConfigMap.
- name: etc-stalwart
emptyDir: {}
# The image's data volume. An emptyDir because the data store, the blob
# store and the in-memory store are all external.
#
# OPEN MEASUREMENT: whether anything under this path has to survive a
# restart once all three stores are external. If it does, this needs a
# PVC per group. Not verified at runtime.
- name: var-lib-stalwart
emptyDir: {}
- name: tmp
emptyDir: {}
{{- if $root.Values.certificate.enabled }}
- name: mail-tls
secret:
secretName: tls-{{ include "stalwart.fullname" $root }}-mail
# The pods do not block on the certificate: a Secret that
# cert-manager has not written yet would otherwise hold every pod
# in ContainerCreating until the challenge completes. Stalwart
# falls back to a self-signed certificate on the mail ports in the
# meantime, which is the better of the two failures.
optional: true
{{- end }}
restartPolicy: Always
{{- end }}
+512
View File
@@ -0,0 +1,512 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "stalwart values",
"description": "Values schema for the stalwart chart (#68, guideline 12). additionalProperties is false on every chart-owned object level, so a misspelled key fails the render instead of being silently ignored. Three kinds of object stay open: the ones passed verbatim into the pod spec (probes, security contexts, free-form annotation maps), because Kubernetes owns their schema; the whole valkey block, because that schema belongs to the subchart; and 'global'. Two objects are user-extensible MAPS (ports, nodes) - there additionalProperties carries the value schema rather than false, which keeps a new entry legal and its contents strict. Values with no valid default (ingress.domain, ingress.clusterIssuer, dataStore.postgres.host, the secrets.*SecretRef paths) are enforced by required() in the templates, not here: they ship as null, so a JSON Schema 'required' array could never fire against them (#18).",
"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 - and with a subchart present that is not hypothetical: whatever is set here reaches valkey too.",
"type": "object"
},
"scaleDown": {
"description": "true renders every node group's StatefulSet with replicas: 0 and overrides every nodes.<group>.replicas. Services, Ingress, Secrets and the Certificate stay in place, so no certificate is re-issued.",
"type": "boolean"
},
"limitFactor": {
"description": "Chart-wide default limit factor for every node group; a group's resources.limitFactor wins over it.",
"type": ["number", "null"],
"exclusiveMinimum": 0
},
"secrets": {
"type": "object",
"additionalProperties": false,
"properties": {
"dataStoreSecretRef": {
"description": "op:// path of the item carrying the field 'password'. Only the password: authUsername is a plain String on the DataStore object with no secret form, so the username is the public value dataStore.postgres.username.",
"type": ["string", "null"]
},
"recoveryAdminSecretRef": {
"description": "op:// path of the item carrying the field 'recovery-admin' with the complete 'admin:<password>' value of STALWART_RECOVERY_ADMIN. null switches the variable off, which is the correct end state once a permanent administrator exists.",
"type": ["string", "null"]
},
"s3SecretRef": {
"description": "op:// path of the item carrying 'access-key' and 'secret-key'. Exposed to the container as environment variables for the BlobStore object in the database to reference; the chart itself does not configure the blob store.",
"type": ["string", "null"]
}
}
},
"dataStore": {
"description": "The only part of Stalwart this chart configures: the contents of config.json, which is exactly the DataStore object.",
"type": "object",
"additionalProperties": false,
"properties": {
"postgres": {
"type": "object",
"additionalProperties": false,
"properties": {
"host": {
"description": "REQUIRED (enforced by required() in the template).",
"type": ["string", "null"]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"database": {
"type": "string",
"minLength": 1
},
"username": {
"description": "Rendered into the ConfigMap as authUsername. Public by necessity - see secrets.dataStoreSecretRef.",
"type": "string",
"minLength": 1
},
"tls": {
"type": "object",
"additionalProperties": false,
"properties": {
"enable": {
"description": "Rendered as the object's useTls field. Defaults to true here, the opposite of upstream's default.",
"type": "boolean"
},
"allowInvalidCerts": {
"description": "Accepting an invalid certificate makes TLS obfuscation rather than protection. Only ever a temporary diagnostic.",
"type": "boolean"
}
}
},
"poolMaxConnections": {
"type": "integer",
"minimum": 1,
"maximum": 8192
},
"timeoutMs": {
"description": "MILLISECONDS as an integer, rendered into config.json as the 'timeout' field. Upstream's object encoding: 'A duration is an integer count of milliseconds... Neither accepts a string.' The key is named for its unit so that a '15s' is rejected here rather than by the server at startup. Integer-typed on purpose: a string form cannot be accepted at all.",
"type": "integer",
"minimum": 1
},
"options": {
"description": "Extra libpq connection options, passed through verbatim. null omits the field.",
"type": ["string", "null"]
}
}
}
}
},
"bootstrap": {
"description": "Recovery mode, which a first install needs because this chart always renders config.json and the bootstrap wizard only appears when that file is absent.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Sets STALWART_RECOVERY_MODE=1: management HTTP on 8080 only, every mail port closed. true for the first install, then false. Leaving it on keeps the mail ports shut while every Kubernetes object looks healthy.",
"type": "boolean"
},
"logLevel": {
"description": "STALWART_RECOVERY_MODE_LOG_LEVEL. No effect once bootstrap.enabled is false. Not enumerated: the accepted set is upstream's tracing level parser, which this chart has not pinned down.",
"type": "string",
"minLength": 1
}
}
},
"initImage": {
"description": "The init container that seeds config.json into the writable config volume. An auxiliary image, so it is pinned here rather than taken from appVersion (guideline 3).",
"type": "object",
"additionalProperties": false,
"properties": {
"repository": {
"type": "string",
"minLength": 1
},
"tag": {
"type": "string",
"minLength": 1
}
}
},
"ports": {
"description": "Port name -> port spec. A user-extensible map, so additionalProperties carries the value schema instead of false: adding 587 or 4190 is a legal, additive change. containerPort is what the application binds (and what the NetworkListener object in the database must say); nodePort is what the cluster publishes. They are different numbers here and are not interchangeable.",
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["containerPort"],
"properties": {
"containerPort": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"nodePort": {
"description": "Required while public is true (checked in the template). Must be inside the API server's --service-node-port-range, default 30000-32767.",
"type": ["integer", "null"],
"minimum": 1,
"maximum": 65535
},
"protocol": {
"type": "string",
"enum": ["TCP", "UDP", "SCTP"]
},
"public": {
"description": "true renders a NodePort Service for this port, shared by every node group that exposes it.",
"type": "boolean"
}
}
}
},
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"externalTrafficPolicy": {
"description": "Enumerated, and Local is admissible only in a topology where a pod runs on every node that receives mail. In this deployment no Stalwart pod is scheduled on a control-plane node while the HA proxy delivers to the control-plane nodes, so Local would drop every packet - not some. See values.yaml.",
"type": "string",
"enum": ["Cluster", "Local"]
},
"annotations": {
"description": "Extra annotations for every Service this chart renders. Free-form keys by nature, so this object stays open.",
"type": "object"
}
}
},
"nodes": {
"description": "Cluster-role name -> node group. A MAP and not a list, so that an override never depends on a position: with a list, inserting a group would silently repoint every existing --set nodes[n]... at the wrong one. The key is passed in STALWART_ROLE and a ClusterRole object of exactly that name must exist in the database - if it does not, the node fail-opens into a full node with every listener AND every task, while staying Running with a green probe. There is deliberately no 'tasks' key: that is a property of the database object and a values key that did nothing would be worse than its absence.",
"type": "object",
"minProperties": 1,
"$comment": "The two groups the chart ships are spelled out under properties rather than left to additionalProperties alone, and that is not redundancy: ci/run-quantity-render-test.sh discovers a chart's resources blocks by walking 'properties' only, so a resources block reachable solely through additionalProperties is invisible to it - and the test then fails the chart for declaring none while its templates call the helper. Writing them out gives that test two real --set paths (nodes.full.resources, nodes.mx.resources). additionalProperties keeps a user-added group legal and equally strict. Keep the three in sync; resources and env are $refs precisely to limit how far they can drift.",
"properties": {
"full": {
"type": "object",
"additionalProperties": false,
"required": ["exposedPorts"],
"properties": {
"replicas": {
"description": "0 is allowed and scales the group down; the chart-global scaleDown wins over it.",
"type": "integer",
"minimum": 0
},
"exposedPorts": {
"$ref": "#/definitions/exposedPorts"
},
"resources": {
"$ref": "#/definitions/resources"
},
"env": {
"$ref": "#/definitions/env"
}
}
},
"mx": {
"type": "object",
"additionalProperties": false,
"required": ["exposedPorts"],
"properties": {
"replicas": {
"description": "Ships as 0. Raising it before the 'mx' ClusterRole exists in the database produces a SECOND FULL NODE rather than an SMTP-only one, because a node whose role is not found enables every listener and every task.",
"type": "integer",
"minimum": 0
},
"exposedPorts": {
"$ref": "#/definitions/exposedPorts"
},
"resources": {
"$ref": "#/definitions/resources"
},
"env": {
"$ref": "#/definitions/env"
}
}
}
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["exposedPorts"],
"properties": {
"replicas": {
"type": "integer",
"minimum": 0
},
"exposedPorts": {
"$ref": "#/definitions/exposedPorts"
},
"resources": {
"$ref": "#/definitions/resources"
},
"env": {
"$ref": "#/definitions/env"
}
}
}
},
"networkPolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "On by default. Only a real boundary where the cluster's CNI enforces NetworkPolicies; an unenforced policy is accepted by the API server and filters nothing.",
"type": "boolean"
},
"probeSources": {
"description": "Source CIDRs allowed to reach the http port, where the kubelet's probe traffic has to be permitted - probes come from the IP of the worker node running the pod. EMPTY MEANS OPEN (the rule renders without a 'from' selector), which is the deliberate choice: a guessed CIDR that misses the real node addresses blocks every probe, and a pod that never becomes Ready reads as a broken application rather than as a policy.",
"type": ["array", "null"],
"items": {
"type": "string",
"minLength": 1
}
},
"allowedNamespaces": {
"description": "Namespaces allowed to reach the http port in addition to the release's own, matched on the kubernetes.io/metadata.name label Kubernetes sets itself. The ingress controller's namespace belongs here as soon as probeSources is non-empty, or the Ingress goes dark. A name that does not exist matches nothing and is silently ineffective.",
"type": ["array", "null"],
"items": {
"type": "string",
"minLength": 1
}
},
"egress": {
"description": "Egress is restricted by PORT, not by destination: a NetworkPolicy matches CIDRs and selectors and never hostnames, so no host value from this file can appear in it.",
"type": "object",
"additionalProperties": false,
"properties": {
"dns": {
"type": "boolean"
},
"smtp": {
"description": "Outbound port 25 to any destination, required for delivery. Uniform for every group on purpose - the chart does not know which groups run the outboundMta task, and the failure mode of guessing is mail silently not leaving.",
"type": "boolean"
},
"extraPorts": {
"description": "Additional outbound TCP ports beyond the data store, S3 and Valkey ports the chart derives. Add 443 for outbound HTTPS (DNS-01 provider APIs, AI model endpoints, remote lookup lists).",
"type": ["array", "null"],
"items": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
}
}
}
}
},
"externalServices": {
"description": "KUBERNETES ONLY - these do not configure Stalwart. The blob store and the in-memory store are objects in the database. The chart uses the ports for the egress rules, the valkey host to decide whether the in-memory store has an address at all, and both hosts to print the WebUI checklist in NOTES.txt.",
"type": "object",
"additionalProperties": false,
"properties": {
"s3": {
"$ref": "#/definitions/externalService"
},
"valkey": {
"$ref": "#/definitions/externalService"
}
}
},
"certificate": {
"description": "The cert-manager Certificate for the MAIL ports. The Ingress certificate covers only the WebUI and JMAP; the HA proxy terminates no TLS, so Stalwart presents this one itself for STARTTLS on 25 and implicit TLS on 465/993. This object only obtains the files - pointing Stalwart at them is a TLS object in the database.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"clusterIssuer": {
"description": "null falls back to ingress.clusterIssuer. Usually a DIFFERENT issuer: the same reasoning that rules out Stalwart's own ACME rules out HTTP-01 here, because port 80 does not reach this cluster for the mail hostname, so a DNS-01 issuer is normally required.",
"type": ["string", "null"]
},
"extraDnsNames": {
"description": "Additional names for the mail certificate; ingress.domain is always included. For the hostnames other MTAs and clients actually connect to when they differ from the WebUI host.",
"type": ["array", "null"],
"items": {
"type": "string",
"minLength": 1
}
},
"mountPath": {
"description": "Where the issued certificate is mounted read-only. A top-level path so no mount is nested inside the config emptyDir. The TLS object in the database points at <mountPath>/tls.crt and <mountPath>/tls.key.",
"type": "string",
"minLength": 1
}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the Ingress template only. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1). clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.",
"type": "boolean"
},
"clusterIssuer": {
"description": "REQUIRED (enforced by required() in the template).",
"type": ["string", "null"]
},
"domain": {
"description": "REQUIRED (enforced by required() in the template), and read in two places - the Ingress host and the mail Certificate.",
"type": ["string", "null"]
},
"className": {
"type": ["string", "null"]
},
"nodeGroup": {
"description": "Which node group the Ingress routes to, through that group's own http Service. Must be a key of nodes: and must expose http; both are checked in the template. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.",
"type": "string",
"minLength": 1
},
"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"
}
}
},
"securityContext": {
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"pod": {
"description": "Merged onto the chart's pod hardening defaults; open because the chart does not interpret the individual fields. seccompProfile lives here so it also covers the init container.",
"type": ["object", "null"]
},
"container": {
"description": "Merged onto the chart's container hardening defaults and applied to BOTH the server and the config-seeding init container; open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
}
}
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"valkeySubchart": {
"description": "The dependency condition for the valkey subchart. A key of THIS chart, deliberately outside the 'valkey' subtree: everything under 'valkey:' is handed to the subchart as its values, and this repo's valkey chart has additionalProperties: false with no 'enabled' property, so 'valkey.enabled' is rejected by the subchart itself. Helm skips a disabled subchart's schema validation, so that error would only have surfaced for whoever first set it to true.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"valkey": {
"description": "Values passed through to the valkey subchart - this repo's OWN valkey chart, resolved from the published index. Open on purpose and at every level: the subchart owns this schema (it ships its own, and a stricter one than this), and a closed copy here would reject whatever that chart adds in its next release while also duplicating a validation that already happens. This chart reads NONE of these keys; the dependency condition is valkeySubchart.enabled instead. Note the field shape that is easy to get wrong: secrets.authSecretRef points at an item whose single field 'auth-conf' holds the complete line 'requirepass <password>', not a bare password. values.yaml argues the cache-vs-queue choice.",
"type": "object"
}
},
"definitions": {
"exposedPorts": {
"description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"externalService": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"host": {
"description": "For valkey, empty plus valkey.enabled resolves to the subchart's Service; empty with the subchart off and more than one Stalwart pod aborts the render, because Stalwart's cluster coordinator rides on the in-memory store connection. Does not point Stalwart at anything by itself.",
"type": ["string", "null"]
},
"port": {
"description": "Used for the egress rule of every NetworkPolicy.",
"type": "integer",
"minimum": 1,
"maximum": 65535
}
}
},
"resources": {
"description": "May be null / empty: the chart then falls back to that GROUP's default requests and computed limits (#99) instead of rendering a container without resource accounting. The per-group fallback matters here - 'mx' is deliberately lighter than 'full'.",
"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": {
"description": "Additive environment entries for a node group's container. Additive means additive: an entry repeating a name the chart already sets does not replace it - at install both reach the pod and the later one wins, but on an existing release Kubernetes merges env by name, the pair collapses to the chart's own value, and helm upgrade still reports success.",
"type": ["array", "null"],
"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"
}
}
},
"probe": {
"description": "Merged onto the chart's probe defaults and rendered as a container probe; open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (#99) rather than the probe disappearing. The defaults carry the probe's CONTEXT - the /healthz paths, the port name AND the calculated intervals: Stalwart rate-limits anonymous /healthz requests to 100 per 60s per SOURCE IP, probes arrive from the node IP, so every pod on a node shares one bucket. 20/20/10 costs 6 requests/min/pod and leaves room for roughly 16 pods per node; 10/10/5 would allow about 8. Tightening these produces 429s, and a 429 on liveness is a restart loop.",
"type": ["object", "null"]
}
}
}
+732
View File
@@ -0,0 +1,732 @@
# Stalwart — a cluster-capable mail server (SMTP, IMAP, POP3, JMAP, ManageSieve).
#
# READ THIS FIRST: THIS CHART CONFIGURES ALMOST NOTHING.
#
# Since v0.16 Stalwart keeps its entire configuration — over 2400 settings in
# 117 object types, including every listener, the blob store, the in-memory
# store, TLS objects, domains and the cluster roles — INSIDE the data store,
# managed over the JMAP API (WebUI or stalwart-cli). The only thing left on
# disk is a small config.json that names the data store and nothing else:
# "It only needs to contain the DataStore object"
# (https://stalw.art/docs/configuration/declarative-deployments).
#
# So this chart renders config.json, the workloads with their STALWART_ROLE,
# the Services, the NetworkPolicies, the Ingress and a cert-manager
# Certificate. Everything else is operator work in the WebUI, and the values
# below say for each block whether it configures Stalwart or only Kubernetes.
# A value that only describes something to Kubernetes is marked as such rather
# than left to look like application configuration.
# Scale the whole chart down without uninstalling it: scaleDown: true renders
# every StatefulSet with replicas: 0 and takes precedence over every
# nodes.<group>.replicas. Services, Ingress, Secrets and the Certificate stay
# in place, so no certificate is re-issued when you scale back up.
scaleDown: false
# Chart-wide default for the limit factor of every node group. A group may
# override it as nodes.<group>.resources.limitFactor, which always wins. Memory
# and ephemeral-storage limits are computed as limitFactor x the request; a CPU
# limit is only set when configured explicitly.
limitFactor: 3
secrets:
# op:// path of the 1Password item carrying the data-store password in the
# field "password".
#
# ONLY the password. The username cannot live here, and that is an upstream
# constraint rather than a choice: on the DataStore object authSecret has the
# type SecretKeyOptional (which has an EnvironmentVariable variant, and that
# is what this chart uses) while authUsername is a plain String with no
# secret form at all. So the username is the public value
# dataStore.postgres.username below and ends up in the ConfigMap, while the
# password never does.
dataStoreSecretRef: null
# op:// path of the 1Password item carrying the field "recovery-admin" with
# the COMPLETE value of STALWART_RECOVERY_ADMIN, i.e. "admin:<password>" in
# one string — the same shape as valkey's auth-conf field. The chart does not
# assemble it from two fields, because a half-built credential ("admin:" with
# an empty password) is accepted by the server as a working back door.
#
# Why it is worth pinning at all: without it Stalwart generates a temporary
# administrator password on every bootstrap or recovery start and prints it to
# stdout — in Kubernetes that means a fresh password in the pod log on every
# restart, visible to anyone who can read logs, and lost as soon as it
# scrolls. Set to null to switch the variable off entirely once a permanent
# administrator exists in the database; that is the documented end state, see
# NOTES.txt.
recoveryAdminSecretRef: null
# op:// path of the 1Password item for the S3 blob store, carrying the fields
# "access-key" and "secret-key".
#
# This chart does NOT configure the blob store — that object lives in the
# database. What it does is expose both fields to the container as the
# environment variables STALWART_S3_ACCESS_KEY and STALWART_S3_SECRET_KEY, so
# that the BlobStore object you create in the WebUI can reference them
# instead of storing the credentials in the database:
# accessKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_ACCESS_KEY"}
# secretKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_SECRET_KEY"}
# Both fields accept that variant (accessKey is PublicStringOptional,
# secretKey is SecretKeyOptional; both offer Value, EnvironmentVariable and
# File). Leave null when the blob store is not S3 or when you prefer to keep
# the credentials in the database — the variables are then not set and
# nothing references them.
s3SecretRef: null
# ---------------------------------------------------------------------------
# config.json — the ONLY part of Stalwart this chart actually configures
# ---------------------------------------------------------------------------
dataStore:
postgres:
# REQUIRED, no default (enforced by required() in the template).
host: null
port: 5432
database: stalwart
# Public on purpose — see secrets.dataStoreSecretRef above for why this is
# not a secret reference. It is rendered into the ConfigMap.
username: stalwart
tls:
# Rendered as the object's "useTls" field. The names differ because this
# chart groups the two TLS switches that upstream keeps flat; the mapping
# is tls.enable -> useTls and tls.allowInvalidCerts -> allowInvalidCerts.
#
# Note the default is the OPPOSITE of upstream's (false). A mail server's
# data store carries every message's metadata, and this chart would
# rather fail to connect to a plaintext-only Postgres than reach it
# silently unencrypted.
enable: true
# Accepting an invalid certificate turns TLS into obfuscation: the
# connection is encrypted against a passive listener and wide open to an
# active one. Only ever a temporary diagnostic.
allowInvalidCerts: false
poolMaxConnections: 10
# MILLISECONDS, as an integer — not "15s".
#
# This is not a styling preference. Stalwart's object encoding states it
# outright: "A duration is an integer count of milliseconds, and a size is
# an integer count of bytes. Neither accepts a string."
# (https://stalw.art/docs/configuration/object-encoding). The DataStore
# reference gives this field as "Duration? default: 15000". A config.json
# carrying "15s" is rejected by the server at startup, so the key is named
# for its unit instead of accepting a suffix the file cannot hold.
timeoutMs: 15000
# Extra libpq connection options, passed through verbatim as the object's
# "options" field. null omits the field.
options: null
# ---------------------------------------------------------------------------
# First start: Stalwart needs recovery mode when config.json is provided
# ---------------------------------------------------------------------------
bootstrap:
# Sets STALWART_RECOVERY_MODE=1 on every node group.
#
# WHY THIS EXISTS, AND WHY IT IS NOT OPTIONAL ON A FIRST INSTALL. The setup
# wizard everyone reaches for runs in BOOTSTRAP mode, and bootstrap mode is
# entered only when there is no config.json: "The first time Stalwart is
# started without a config.json, it enters bootstrap mode"
# (https://stalw.art/docs/configuration/bootstrap-mode). This chart always
# renders one, so the wizard never appears. Against an empty database that
# leaves a server with no listeners, no domain and no administrator, and
# nothing to fix it with.
#
# The documented path for exactly this case is recovery mode: "template
# config.json onto the host, start Stalwart once with STALWART_RECOVERY_MODE=1
# and a STALWART_RECOVERY_ADMIN supplied from a secrets manager, run
# stalwart-cli apply against the recovery listener to load the initial
# configuration, and then restart Stalwart without the recovery variables"
# (https://stalw.art/docs/configuration/declarative-deployments). Recovery
# mode brings up the management HTTP endpoint on port 8080 — the WebUI
# included — and keeps every mail port closed.
#
# So: true for the first install, then false and upgrade again. Leaving it on
# is not a small untidiness — the mail ports stay shut while the Services and
# the Ingress look perfectly healthy, which is the loudest-looking silent
# failure this chart has. NOTES.txt prints a warning for as long as it is on.
enabled: false
# Log verbosity while recovery mode is on (STALWART_RECOVERY_MODE_LOG_LEVEL).
# Has no effect once bootstrap.enabled is false.
logLevel: info
# ---------------------------------------------------------------------------
# Ports. KUBERNETES ONLY — these numbers do not reach Stalwart.
# ---------------------------------------------------------------------------
# THE DUPLICATION BELOW CANNOT BE AVOIDED, SO IT IS WRITTEN DOWN INSTEAD.
#
# Every listener Stalwart binds is a NetworkListener object in the database.
# This chart cannot create those objects, so containerPort here is a second
# copy of a decision that is really made in the WebUI. If the two disagree, the
# Service points at a port nothing listens on: connections are refused or time
# out, every pod stays Ready, and no object reports a problem. Check both sides
# whenever you touch one.
#
# TWO NUMBERS PER ENTRY, AND THEY ARE NOT INTERCHANGEABLE:
# containerPort — what the application binds inside the pod, and what the
# NetworkListener object in the database must say.
# nodePort — what the cluster publishes on every node, and what the HA
# proxy in front of the cluster connects to.
# The Service maps one onto the other: nodePort 30025 -> targetPort 25.
#
# WHY THE APPLICATION BINDS THE ORDINARY 25/465/993. An earlier draft had it
# bind 30025/30465/30993 so that nothing privileged was ever bound and the
# NET_BIND_SERVICE capability could be dropped. That reasoning collapsed: the
# image ships "setcap cap_net_bind_service=+ep" on the binary, which makes it a
# capability-dumb binary, and the kernel then fails the execve ITSELF when the
# bounding set does not carry the capability — before the program binds
# anything. The capability has to be granted either way (see securityContext
# below), so high ports bought no hardening at all. What the ordinary numbers
# buy instead is predictability: the listener objects an operator creates in
# the WebUI carry the numbers from every page of Stalwart's documentation and
# from the image's own EXPOSE list, which takes most of the surprise out of the
# duplication above.
#
# The nodePorts must be inside the API server's --service-node-port-range
# (default 30000-32767). 30025/30465/30993 are, with the default — UNVERIFIED
# against this cluster's actual setting.
#
# Only these three plus HTTP are published. 587, 143, 110, 995 and 4190 are
# deliberately absent and can be added later without a breaking change: add an
# entry here and list it in the groups that should answer on it.
ports:
smtp:
containerPort: 25
nodePort: 30025
protocol: TCP
public: true
submissions:
containerPort: 465
nodePort: 30465
protocol: TCP
public: true
imaps:
containerPort: 993
nodePort: 30993
protocol: TCP
public: true
http:
# The management/JMAP/WebUI port, and the port the probes use. Never
# published as a NodePort: it is reached through the Ingress (ClusterIP
# Service) and by the kubelet.
containerPort: 8080
protocol: TCP
public: false
# The init container that seeds /etc/stalwart/config.json out of the ConfigMap.
# Pinned here rather than hardcoded in the template, and reviewed on every
# app-update round, because it is an auxiliary image and its tag does not come
# from appVersion (README, guideline 3).
initImage:
repository: docker.io/library/busybox
tag: "1.38.0-uclibc"
service:
# Cluster, and Local is not an option here — this is a property of the
# deployment's topology, not a preference.
#
# externalTrafficPolicy: Local only forwards to pods on the node that
# received the packet. Mail enters through an HA proxy that distributes
# 25/465/993 across the three control-plane nodes, and no Stalwart pod is
# ever scheduled on a control-plane node — so under Local EVERY packet would
# be dropped, not some.
#
# The price of Cluster is that kube-proxy masquerades the source address to
# the receiving node's IP (it has to: the reply would otherwise leave through
# a different node than the one the sender holds its connection with). For a
# mail server a lost source address is not cosmetic; it switches off SPF,
# takes DMARC with it, makes every blocklist report "clean", collapses
# per-IP rate limits onto one address, removes greylisting's key, and makes
# the auto-ban lock out every sender at once by banning the node IP. The
# Received: chain becomes worthless, which is the one item on that list that
# destroys evidence retroactively instead of just failing.
#
# What pays that price back is the PROXY protocol, and only because of this
# topology: the HA proxy terminates the inbound connection and opens its OWN
# to the node, so it can prepend a PROXY v2 header on that connection — for
# port 25 as well, where no remote mail server would ever send one. The real
# client address then travels in the header and the masquerading stops
# mattering. This is a MANDATORY companion step, not an optimisation; the
# wiki page has the all-or-nothing warning that goes with it.
externalTrafficPolicy: Cluster
# Extra annotations for every Service this chart renders (ClusterIP and
# NodePort alike). Free-form by nature.
annotations: {}
# ---------------------------------------------------------------------------
# Node groups. One StatefulSet, one headless Service and one NetworkPolicy each.
# ---------------------------------------------------------------------------
# A MAP, NOT A LIST, and on purpose: with a list every override would be
# index-based (--set nodes[1].replicas=2), so inserting a group later would
# silently repoint every existing override at the wrong group.
#
# The key is the cluster role name and goes into STALWART_ROLE. A ClusterRole
# object of exactly that name has to exist in the database — see the fail-open
# warning below, which is the most important paragraph in this file.
#
# THERE IS NO nodes.<group>.tasks KEY. Which tasks and listeners a role runs is
# a property of the ClusterRole object in the database, and this chart cannot
# write it. A values key that looked like it set them and did not would be
# worse than its absence.
#
# FAIL-OPEN: A NODE WHOSE ROLE IS MISSING FROM THE DATABASE BECOMES A FULL NODE.
#
# From the source, not the documentation. The bootstrap registry looks a role
# up by name; when it finds none it records a non-fatal error and leaves the
# role unset, and the listener check then reads
# bp.role.as_ref().is_none_or(|r| ...)
# which yields true for EVERY listener when the role is None. The same holds
# for the task set. So a typo in a group name here, or a rename here without
# the matching rename in the database, turns that group into a node that binds
# everything — the mx group would serve IMAP and the admin interface. The pod
# is Running, the log holds one line ('Cluster role "mx" not found in
# registry'), and the readiness probe is GREEN. Kubernetes cannot detect it.
#
# Three things in this chart contain that failure, and they are the reason the
# layout looks the way it does: mx ships with replicas: 0, the per-port
# Services publish only what was asked for, and the NetworkPolicy is a ceiling
# on reachability that holds no matter which ports the process actually bound.
# Documented as a trap rather than explained away.
nodes:
# Everything: all tasks, all listeners. The group the Ingress points at.
full:
# 0 is allowed and scales this group down; the chart-global scaleDown wins.
replicas: 1
# Which ports this group may be reached on. NOT a copy of the listener
# configuration in the database but a statement of its own: "this group is
# reachable here and nowhere else". That is why a disagreement in either
# direction is harmless — a role that binds more is unreachable on the
# extra ports, and a policy that permits more has nothing listening — and
# it is exactly what takes the edge off the fail-open above.
#
# Every group needs http, including mx: it is the port all three probes
# use. It is not published for mx.
exposedPorts: [smtp, submissions, imaps, http]
resources:
requests:
cpu: 200m
memory: 512Mi
ephemeral-storage: 64Mi
# Additive environment entries for this group's container, appended to the
# ones the chart sets (STALWART_ROLE, STALWART_HOSTNAME and the secret-fed
# ones). Supports the value, secretKeyRef and configMapKeyRef forms and is
# rendered through tpl.
#
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
# does not replace it. At install both reach the pod and the later one wins;
# on an EXISTING release Kubernetes merges env by name, the pair collapses
# to the chart's own value, and helm upgrade still reports success. Do not
# use this to override STALWART_ROLE.
env: []
# SMTP intake only. Add the matching ClusterRole in the database first:
# {"name":"mx","tasks":{"@type":"DisableAll"},"listeners":{"@type":"EnableSome", ...}}
mx:
# Zero until the role "mx" exists in the database. Raising this before the
# role is there does not fail — it produces a second full node (see
# fail-open above), which is the opposite of what this group is for.
replicas: 0
exposedPorts: [smtp, http]
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 64Mi
env: []
# ---------------------------------------------------------------------------
# NetworkPolicy — the ceiling on reachability, independent of what bound what
# ---------------------------------------------------------------------------
networkPolicy:
# On by default. Only a real boundary where the cluster's CNI enforces
# NetworkPolicies; an unenforced policy is accepted by the API server and
# filters nothing.
enabled: true
# Source CIDRs allowed to reach the http port, in addition to
# allowedNamespaces. This is where the kubelet's probe traffic has to be
# permitted: probes come from the IP of the WORKER node running the pod, an
# address this chart cannot know.
#
# EMPTY MEANS OPEN, not blocked. With no entry the http rule is rendered
# without a "from" selector, so the port is reachable from anywhere the CNI
# routes to. That is the deliberate choice between two bad defaults: a
# guessed CIDR that excludes the real node addresses blocks every probe, and
# a pod that never becomes Ready reads as a broken application rather than as
# a policy. http is not a NodePort, so "open" here means open inside the
# cluster, not from the internet. Narrow it by listing the worker node IPs
# (e.g. ["10.0.1.11/32", "10.0.1.12/32"]).
#
# And mind the companion trap: once this list is non-empty the http rule is
# restricted, which cuts off the ingress controller too unless its namespace
# is in allowedNamespaces below.
probeSources: []
# Namespaces allowed to reach the http port, matched on the
# kubernetes.io/metadata.name label Kubernetes sets on every namespace
# itself. The release's own namespace is always allowed. Put the ingress
# controller's namespace here (e.g. ["ingress-nginx"]) as soon as
# probeSources is set. A name that does not exist matches nothing and is
# silently ineffective.
allowedNamespaces: []
# Egress is restricted BY PORT, not by destination, and the reason is a limit
# of the object rather than laziness: a NetworkPolicy matches ipBlock CIDRs
# and pod/namespace selectors, never hostnames — so dataStore.postgres.host
# and externalServices.*.host cannot appear in it. Each rule below therefore
# permits its port to any destination. What it still buys is real: a
# compromised pod cannot open arbitrary ports outbound.
#
# Outbound SMTP is the one that must stay wide: delivering mail means
# connecting to port 25 on any host on the internet.
egress:
# DNS (UDP and TCP 53) to any destination. Switching this off breaks
# everything, including MX lookups and SPF.
dns: true
# Outbound SMTP on port 25 to any destination — required for delivery.
# Deliberately NOT derived per group from the outboundMta task: this chart
# does not know which tasks a role runs, the value would drift away from
# the database, and the failure mode would be mail silently not leaving.
# So it is uniform for every group.
smtp: true
# Additional outbound TCP ports, beyond the data store, S3 and Valkey ports
# the chart already derives from the values above. Add 443 here if the
# deployment needs outbound HTTPS (ACME over DNS-01 through a provider API,
# an AI model endpoint, a remote lookup list).
extraPorts: []
# ---------------------------------------------------------------------------
# External services — KUBERNETES ONLY. These do not configure Stalwart.
# ---------------------------------------------------------------------------
# The blob store and the in-memory store are objects in the database and are
# created in the WebUI. What the chart uses these values for is exactly three
# things, and nothing else:
# - the port goes into the egress rule of every NetworkPolicy,
# - the valkey host decides whether the in-memory store has an address at all
# (see the fail below), and
# - both hosts are printed in NOTES.txt as the addresses to enter in the
# WebUI, so the installation checklist is not a separate document.
# Setting a host here does not point Stalwart at it.
externalServices:
s3:
host: ""
port: 443
valkey:
# Empty plus valkey.enabled: true resolves to the subchart's Service,
# "<release>-valkey". Empty with the subchart off and more than one
# Stalwart pod ABORTS THE RENDER with a message naming both values, because
# that combination is a cluster without an in-memory store: from v0.16 the
# coordinator's Default variant "reuses the Redis connection already
# configured on the in-memory store", so a multi-node install has no
# coordination path at all. Failing the render is the loud version of a
# cluster that comes up and then does not agree with itself.
host: ""
port: 6379
# ---------------------------------------------------------------------------
# TLS for the mail ports — cert-manager, not Stalwart's own ACME
# ---------------------------------------------------------------------------
# Why not Stalwart's built-in ACME: the HA proxy forwards 25, 465 and 993, and
# NOT 80, so an HTTP-01 challenge answered by Stalwart is unreachable from
# outside. DNS-01 inside Stalwart would work but would duplicate what
# cert-manager already does in this cluster.
#
# Why a certificate is needed at all when there is an Ingress: the Ingress
# covers only the WebUI and JMAP. The HA proxy terminates no TLS, so Stalwart
# itself has to present the certificate for STARTTLS on 25 and implicit TLS on
# 465 and 993.
certificate:
enabled: true
# Cluster issuer for the MAIL certificate. null falls back to
# ingress.clusterIssuer.
#
# MIND THE CHALLENGE TYPE. The same reasoning that rules out Stalwart's ACME
# rules out an HTTP-01 issuer here: port 80 does not reach this cluster for
# the mail hostname. A DNS-01 issuer is usually the right answer, and it is
# usually a DIFFERENT issuer from the one the Ingress uses — which is why
# this is its own value instead of reusing ingress.clusterIssuer silently.
clusterIssuer: null
# Additional DNS names for the mail certificate. ingress.domain is always
# included, so this is for the hostnames clients and other MTAs actually
# connect to when they differ from the WebUI host — typically the MX name and
# the autoconfig names (mail., smtp., imap.).
extraDnsNames: []
# Mount path of the issued certificate inside the container. A separate
# top-level path rather than something under /etc/stalwart, so there is no
# mount nested inside the config emptyDir.
#
# These two files are what the TLS object in the DATABASE has to point at:
# certificate: /etc/stalwart-tls/tls.crt
# privateKey: /etc/stalwart-tls/tls.key
mountPath: /etc/stalwart-tls
ingress:
# Gates the whole Ingress template. ingress.domain stays REQUIRED either way:
# it is read by the Certificate's dnsNames as well, so switching the Ingress
# off would otherwise render a certificate with no name in it. Only
# ingress.clusterIssuer becomes unnecessary — and only if
# certificate.clusterIssuer is set, since that falls back to it.
enabled: true
# REQUIRED, no default.
clusterIssuer: null
# REQUIRED, no default.
domain: null
className: nginx
# Which node group the Ingress routes to. Must be a key of nodes: above, and
# that group must list http in its exposedPorts — both are checked, and the
# render aborts naming the value. It matters that this is a single group: the
# WebUI's setup and management screens are not safe to spread over a group
# that may not run the management tasks, and a Service selecting several
# groups would send every request to a random one of them.
nodeGroup: full
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
annotations: {}
# ---------------------------------------------------------------------------
# Hardening — chart-wide, applied to every node group
# ---------------------------------------------------------------------------
securityContext:
pod:
# uid/gid 2000 is the image's own "stalwart" user: the image already runs
# as non-root, so nothing has to be bent here.
runAsNonRoot: true
runAsUser: 2000
runAsGroup: 2000
# In the pod context so it also covers the init container that places
# config.json; a container-level profile would have to be repeated for
# every container and would be missed on the next one added.
seccompProfile:
type: RuntimeDefault
# No fsGroup and no fsGroupChangePolicy: this chart mounts no PVC. Both
# volumes are emptyDirs (state lives in Postgres, S3 and Valkey) and the
# kubelet creates those owned by the pod's user already.
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
# DOCUMENTED DEVIATION from the repo's hardening baseline (README,
# guideline 5), forced by the stalwartlabs/stalwart image. Precedent for a
# justified capability: samba.
#
# NET_BIND_SERVICE IS REQUIRED TO *START* THE CONTAINER, NOT TO BIND A PORT.
# Both published image variants run
# setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
# (Dockerfile.build at v0.16.24, line 164 for the glibc target and line 186
# for the musl/-alpine one). The "e" in "+ep" sets the file's effective
# bit, which makes the binary a "capability-dumb binary" in the kernel's
# terms, and capabilities(7) then applies this check at execve time:
#
# "When executing a capability-dumb binary, the kernel checks if the
# process obtained all permitted capabilities that were specified in the
# file permitted set... If the process did not obtain the full set of
# file permitted capabilities, then execve(2) fails with the error
# EPERM."
#
# drop: [ALL] empties the bounding set, so the capability cannot be
# obtained, so the exec fails outright:
# exec /usr/local/bin/stalwart: operation not permitted
# The check runs BEFORE the program binds anything, and it does not ask
# whether the capability is needed. This is why the three escape routes
# that look obvious all fail for the same reason: high ports do not help,
# the -alpine image carries the identical setcap, and the
# net.ipv4.ip_unprivileged_port_start sysctl addresses binding rather than
# exec.
#
# AND THE SECOND HALF, WHICH IS WHY THIS LINE MUST NOT BE "CLEANED UP":
# THE CAPABILITY IS NEVER ACTUALLY USED. It is spent entirely on satisfying
# the execve check. Stalwart does bind 25/465/993 here, which are
# privileged — but inside a pod that needs no capability either way: the
# kubelet sets net.ipv4.ip_unprivileged_port_start=0 in the pod network
# namespace, so a non-root container binds low ports regardless (measured
# in k3d for template-chart, see its ports comment). So a hardening pass
# that greps for "a capability nobody uses" will find exactly this one and
# removing it stops the chart from starting at all. Do not remove it
# without first checking whether upstream still sets the file capability;
# the day that setcap disappears from Dockerfile.build is the day this
# deviation can go with it.
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
# Probes for every node group, on the two separate endpoints the image
# provides. Values-configurable; an erased block falls back to these defaults
# rather than removing the probe.
#
# THE INTERVALS ARE CALCULATED, NOT TASTE. DO NOT TIGHTEN THEM WITHOUT REDOING
# THE ARITHMETIC BELOW.
#
# Stalwart applies an anonymous HTTP rate limit to /healthz as to everything
# else, and its default is 100 requests per 60 seconds KEYED ON THE SOURCE IP
# (registry default rate_limit_anonymous: count 100, period 60000ms). Kubelet
# probes arrive from the IP of the NODE running the pod, not from the pod, so
# every Stalwart pod on one node draws from the SAME bucket. Exceeding it
# returns 429, a 429 fails the probe, and failing liveness restarts the pod —
# a self-inflicted restart loop that gets worse the more pods a node holds.
#
# The budget per pod, with the values below:
# steady state liveness 60/20 = 3/min + readiness 60/20 = 3/min = 6/min
# while starting startup 60/10 = 6/min = 6/min
# The startup probe GATES liveness and readiness, so the three never run at
# once and 6 requests/min/pod is the worst case in both phases. Against a
# 100/60s bucket that leaves room for about 16 Stalwart pods per node before
# the limit is in sight.
#
# For comparison, the obvious 10/10/5 defaults would cost 12/min/pod in steady
# state and allow only about 8 pods per node — which is not far from a
# plausible deployment. That is the whole reason these numbers are 20/20/10.
#
# What the wider intervals cost, stated so the trade is visible: up to 20s
# before unreadiness removes a pod from its Services, and up to 60s
# (3 x 20s) before a hung pod is restarted. For a mail server both are
# acceptable — SMTP senders retry for days.
#
# WHY HTTP AND NOT A tcpSocket READINESS PROBE. A tcpSocket probe on the mail
# port would bypass the bucket entirely, and it was considered for exactly that
# reason. It is rejected on three grounds, none of them about convenience:
# 1. It would be GREEN in precisely the failure this chart is built to
# contain. A node that fail-opens (its ClusterRole missing from the
# database) binds every listener, so a TCP connect to 25 succeeds on a
# broken "mx" node just as it does on a healthy "full" one.
# 2. It proves strictly less. A TCP accept says a socket is bound;
# /healthz/ready additionally says the server reached the point where a
# data store handle exists at all.
# 3. It would have to differ per node group (the "mx" group has no 465 or
# 993), so the fallback default in _helpers.tpl could no longer carry the
# probe's context — the failure mode guideline 12.5 exists for.
# If a node ever does need to carry more than ~16 pods, the right move is to
# raise the limit on the server (the allow-list bypasses the limiter outright
# for listed addresses) rather than to blind the probes.
#
# WHAT THESE PROVE, AND WHAT THEY DO NOT — three honest limits:
# - Liveness is deliberately on /healthz/live so a Postgres outage cannot
# restart every pod in the cluster at once; the restart would fix nothing
# and would drop every connection in flight (guideline 12.3).
# - /healthz/ready DOES NOT QUERY THE DATABASE. Read from the source at
# v0.16.24: it returns 200 iff the data store handle is not Store::None.
# So readiness here does not hang on the dependency the way guideline 12.3
# asks, and it cannot — the endpoint upstream offers is shallow. Named
# rather than papered over, because a green pod proves less than it looks.
# - It also does not catch the missing-config.json trap (a server that fell
# back to an ephemeral in-memory store still has a data store handle).
#
# NOT VERIFIED AGAINST A RUNNING SERVER: whether /healthz answers a plain
# kubelet probe at all. The source shows no auth, no loopback check and no
# trusted-network restriction, but the request still passes the configurable
# Http.allowedEndpoints expression (default "200", i.e. everything allowed) —
# and its loopback escape hatch does NOT cover a node IP, so an operator who
# later writes a restrictive expression breaks every probe. The image's own
# healthcheck sends "X-Forwarded-For: 127.0.0.1", which is how it puts itself
# in the loopback rate-limit bucket; that only has an effect when
# Http.useXForwarded is on, and it is off by default.
livenessProbe:
httpGet:
path: /healthz/live
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness and readiness, so neither needs an
# initialDelaySeconds. Budget = periodSeconds x failureThreshold = 300s, which
# is generous on purpose: the first start of a node has to reach Postgres,
# acquire its cluster node-id lease and read the whole configuration registry
# out of the database.
startupProbe:
httpGet:
path: /healthz/ready
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
# ---------------------------------------------------------------------------
# The Valkey subchart — THIS REPO'S OWN valkey CHART, off by default
# ---------------------------------------------------------------------------
# Stalwart needs an in-memory store for a cluster, and the coordinator rides on
# the same connection. The subchart is the path for a deployment that has no
# Valkey yet; an installation that already runs one points
# externalServices.valkey.host at it and leaves this off.
#
# THE SWITCH IS HERE AND NOT UNDER "valkey:", and it has to be. Everything under
# "valkey:" is handed to the subchart as its values, and our valkey chart ships
# a strict schema (additionalProperties: false) with no "enabled" property — so
# "valkey.enabled" is rejected BY THE SUBCHART. Measured; and because Helm skips
# a disabled subchart's schema validation, the error would only ever appear for
# whoever first switched it on. Chart.yaml has the error text.
valkeySubchart:
enabled: false
# ---------------------------------------------------------------------------
# Values passed THROUGH to the valkey subchart
# ---------------------------------------------------------------------------
# Read as the subchart's own values, so the key names are ITS schema, not
# upstream's and not this chart's. `valkey/values.yaml` in the repo is the
# reference; its header comment explains the pairs. Nothing here has any effect
# while valkeySubchart.enabled is false.
#
# WHY THIS IS NOT "upstream's chart as it comes": it is our chart, so there is
# no third party's defaults to leave unpatched and no shortfall against this
# repo's conventions to document — it follows them because it IS one of them.
# What still applies in full is the subchart being invisible downstream (a
# Valkey fix reaches a cluster only when THIS chart is republished — Chart.yaml
# has the detail and the PR-ordering warning), that scaleDown and
# checksum/config stop at the subchart boundary, and that every helm command
# needs ci/helm-dependencies.sh to have run first.
valkey:
secrets:
# REQUIRED once the subchart is on — the subchart's own required() aborts
# the render without it, which is the correct loud failure.
#
# MIND THE FIELD, it is not a password. The 1Password item carries ONE
# field, `auth-conf`, whose value is the COMPLETE configuration line
# requirepass <password>
# The valkey chart mounts that file and `include`s it, and an init container
# refuses to start the pod unless the line is really there — because the
# alternative failure is an open server that looks perfectly healthy. A bare
# password in that field does not work.
authSecretRef: null
# CACHE, not queue — a decision, with its cost named.
#
# The valkey chart treats persistence and eviction as a PAIR and warns against
# mixing them: QUEUE is `persistence.enabled: true` + `noeviction`, CACHE is
# `persistence.enabled: false` + `allkeys-lru`.
#
# Stalwart's in-memory store holds rate-limiter counters, auth tokens and
# sessions, auto-ban entries, and the cluster coordination pub/sub. Every one
# of those is reconstructible: counters and bans restart from zero, tokens and
# sessions force a re-authentication, and pub/sub messages are not stored keys
# at all. Nothing in it is the system of record — messages and metadata are in
# Postgres, blobs in S3, and the cluster node-id lease is in the data store
# too, not here. Paying for a PVC and for AOF replay to protect data that is
# worthless after a restart is the wrong trade, so: cache.
#
# THE COST, because `allkeys-lru` means keys really are discarded under memory
# pressure: the rate limiters and the auto-ban FAIL OPEN. An attacker who can
# drive memory usage can age out their own ban entry. The answer is to size
# memory for the working set rather than to switch to `noeviction` — with
# `noeviction` Stalwart's writes to the store start erroring instead, which
# takes out authentication rather than just the limits. Size it, and treat the
# limiters as a nuisance filter, not as a security boundary.
persistence:
enabled: false
valkey:
maxmemoryPolicy: allkeys-lru
# The subchart derives maxmemory from the computed memory limit and aborts
# its render if an explicit maxmemory exceeds it. Defaults left alone.
resources:
requests:
cpu: 50m
memory: 128Mi
ephemeral-storage: 32Mi
# The subchart's own NetworkPolicy, with its own semantics — this chart's
# networkPolicy block does not reach it. On by default there, allowing the
# release's own namespace. It needs an entry here only when Stalwart runs in a
# DIFFERENT namespace from the Valkey, which with the subchart it does not.
networkPolicy:
allowedNamespaces: []
+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: 10.0.1+up6.13.1
appVersion: 6.13.1
version: 10.0.2+up6.14.0
appVersion: 6.14.0