Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6007ebe3
|
||
|
|
b39f36ff5c
|
No files matched your search
@@ -43,6 +43,7 @@ on:
|
||||
- stalwart
|
||||
- garage
|
||||
- keycloak
|
||||
- bulwark
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Publish Bulwark Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- bulwark/**
|
||||
jobs:
|
||||
publish-bulwark:
|
||||
name: Publish Bulwark Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
with:
|
||||
chart-name: 'bulwark'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -16,6 +16,7 @@ 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 `{}`) |
|
||||
| `bulwark` | Bulwark Webmail (JMAP client for Stalwart) | **The companion to `stalwart`, and a pure client**: no mail, no database, no cache, no broker — the messages belong to the Stalwart release named by `bulwark.jmapServerUrl`. StatefulSet with **one** PVC at `/app/data`, carrying all four of Bulwark's directories (admin config, admin state with the audit log, encrypted user settings, telemetry) as subdirectories — one volume because snapshots are atomic per volume and never across them (the `garage` lesson, #81). No ConfigMap, so no `checksum/config` (§9 has nothing to attach to); the `op://` paths are covered by `checksum/secret-ref`. **The defining property, and it governs every value: the VOLUME BEATS THE CHART.** `configManager.get` resolves `admin override > env var > default`, the admin dashboard may write *any* key, and nothing says it just disconnected the chart — so a `helm upgrade` with a changed value can succeed and change nothing. Upstream documents this only for the server URL; it is true of everything. Exactly three values escape it (`SESSION_SECRET(_FILE)`, `ADMIN_PASSWORD`, `BULWARK_TELEMETRY`), and `bulwark.adminConfigReadOnly` closes it at a price that is **threefold and measured**: it freezes the settings policy (and with it the only way to move Web Push off the vendor relay), it freezes an override that *already* exists rather than removing it, and on a **fresh** install it stops `admin.json` from being written at all — so the admin dashboard never comes up and answers HTTP 500 while the pod stays Ready. Hence the default `false`. Sign-in is **OIDC-only** (`oauth.only`), and the way back out of a lockout is **not** `helm upgrade` with it `false` — `oauthOnly` is read through the same precedence chain, so the repair is removing the override in the **admin dashboard**, which is why `ADMIN_PASSWORD` is set at all (it generates no random password and logs nothing — the `#65` analogy does not hold here). Its **redirect URI carries the UI language** (`/<locale>/auth/callback`, 27 locales, and the language is not in the visible URL), so a single registered URI is the likeliest cause of a failed first sign-in. Both `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` exist and are used — the better path `garage` and `keycloak` did not have — mounted **without `subPath`** so that a rotation takes effect without a restart, since both are re-read on every use. Two backup statements, both sharp: without the session secret the synchronized settings are unreadable **out of an intact volume**, and if the dashboard's OAuth setup was ever used the client secret sits in `config.json` on that volume in **cleartext**. All three probes are a plain GET on `/api/health` — **not** HEAD, which a `HTTPGetAction` cannot send — and the limit is written at the value: it carries process, HTTP acceptance, a free event loop and Next.js routing, and **nothing** about Stalwart, so with the mail server gone the pod is green. Served only at a host **root** (`NEXT_PUBLIC_BASE_PATH` is compiled in), one replica (single RWO PVC), two emptyDirs for `readOnlyRootFilesystem` (`/tmp`, `/app/.next/cache` — image optimization is active because `images.unoptimized` is in the lite branch only) |
|
||||
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
|
||||
| `cyberchef` | CyberChef | Stateless Deployment |
|
||||
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr, 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) |
|
||||
@@ -34,7 +35,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
||||
| `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" |
|
||||
| `stalwart` | Stalwart mail server (SMTP, IMAP, POP3, JMAP, ManageSieve) | A webmail client for it is the separate `bulwark` chart (above), pointed at this release's JMAP endpoint — the two are only useful together, and nothing in either reports the other being away. **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 |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,36 @@
|
||||
apiVersion: v2
|
||||
name: bulwark
|
||||
description: A Helm chart to deploy Bulwark Webmail, a JMAP webmail client (mail, calendar, contacts, files) for a Stalwart mail server
|
||||
icon: https://avatars.githubusercontent.com/u/266882946
|
||||
|
||||
type: application
|
||||
|
||||
# appVersion is the exact image tag of ghcr.io/bulwarkmail/webmail.
|
||||
#
|
||||
# THE SUFFIX-FREE FORM, AND THAT IS A CHOICE RATHER THAN THE ONLY OPTION. This
|
||||
# registry publishes every release TWICE - 1.12.0 and v1.12.0 are the same image
|
||||
# under two names (counted 2026-10-05 against the ghcr.io tags API: 315 tags, and
|
||||
# both families run the whole way back to 1.3.9). So unlike stalwart, garage,
|
||||
# matrix-synapse and calibre-web-automated, where the "v" IS the tag and
|
||||
# therefore part of appVersion and of the "+up" suffix, here it is optional and
|
||||
# this chart pins the form without it. Do not "normalize" it to v1.12.0 later:
|
||||
# that would rewrite appVersion and the chart version for an identical image.
|
||||
#
|
||||
# The tag census, Renovate's handling of each shape and the three dry-run
|
||||
# fixtures that prove it are in renovate.json5's bulwark block rather than
|
||||
# duplicated here. The short version: eight tag shapes, six of them things
|
||||
# nobody should pin, and plain `docker` versioning excludes all six - so that
|
||||
# block deliberately carries NO versioningTemplate.
|
||||
version: 1.0.0+up1.12.0
|
||||
appVersion: "1.12.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
|
||||
# NO DEPENDENCIES, and nothing here wants one. Bulwark is a client: it holds no
|
||||
# mail, has no database, no cache and no broker. The mail server it talks to is
|
||||
# the `stalwart` chart (Gitea #65), a SEPARATE RELEASE reached over the network
|
||||
# by the URL in bulwark.jmapServerUrl - not a subchart, because the two are
|
||||
# installed, scaled, upgraded and backed up independently and a JMAP endpoint is
|
||||
# an address rather than a bundled component. README, "Chart dependencies", for
|
||||
# the bar a dependency has to clear.
|
||||
@@ -0,0 +1,252 @@
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
{{- $replicas := int (include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas)) -}}
|
||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
{{- $url := include "bulwark.publicUrl" . -}}
|
||||
Bulwark Webmail {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{ $replicas }} replica(s). This is a CLIENT: it holds no mail and has no database.
|
||||
The messages and the accounts belong to the Stalwart server at
|
||||
{{ $settings.jmapServerUrl }}
|
||||
If that server is away, this release is still Ready and still green - see
|
||||
"WHAT THE PROBES DO NOT TELL YOU" below.
|
||||
|
||||
ENDPOINTS
|
||||
{{ if .Values.ingress.enabled }} Web: {{ $url }}
|
||||
{{ else }} Web: no Ingress (ingress.enabled: false) - in-cluster only
|
||||
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.port | int64 }}
|
||||
{{ if .Values.ingress.enabled }} Admin: {{ $url }}/admin/login
|
||||
{{ else }} Admin: /admin/login on the in-cluster address above. With no Ingress, reach it with
|
||||
kubectl -n {{ .Release.Namespace }} port-forward statefulset/{{ $sfs }} 3000:{{ $settings.port | int64 }}
|
||||
and open http://localhost:3000/admin/login
|
||||
{{ end }}{{ if .Values.ingress.enabled }}
|
||||
Bulwark is served at the ROOT of that host and cannot be moved under a path:
|
||||
NEXT_PUBLIC_BASE_PATH is compiled into the image.
|
||||
{{ end }}
|
||||
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
|
||||
The operator materializes one Secret key per item FIELD. A missing or misnamed
|
||||
field here does NOT stop the pod - the two file-form secrets simply read as
|
||||
absent - so the symptom is a feature that is quietly off rather than an error.
|
||||
|
||||
{{ .Values.secrets.appSecretRef | default "<secrets.appSecretRef NOT SET>" }}
|
||||
session-secret openssl rand -base64 32
|
||||
MANDATORY. Persistent sessions and the encryption of the
|
||||
synchronized settings both hang on it, and the second
|
||||
fails SILENTLY without it: settings sync is computed as
|
||||
"enabled AND a secret exists", so with no secret it is
|
||||
simply off and nothing says so.
|
||||
admin-password the admin dashboard's initial password. CONSUMED ONCE -
|
||||
see step 5 below.
|
||||
{{ if $settings.oauth.enabled }}
|
||||
{{ .Values.secrets.oauthSecretRef | default "<secrets.oauthSecretRef NOT SET>" }}
|
||||
oauth-client-secret the confidential OIDC client's secret, as issued by
|
||||
the identity provider when the client was registered.
|
||||
{{ else }}
|
||||
(no OAuth item - bulwark.oauth.enabled is false, so sign-in is by password)
|
||||
{{ end }}
|
||||
The file-form secret{{ if $settings.oauth.enabled }}s are{{ else }} is{{ end }} mounted as a DIRECTORY, not with subPath, and
|
||||
re-read on every use - so rotating {{ if $settings.oauth.enabled }}either field{{ else }}that field{{ end }} in the vault takes effect
|
||||
WITHOUT a restart. admin-password is the exception and no restart helps it.
|
||||
|
||||
{{ if $settings.oauth.enabled }}
|
||||
BEFORE YOU INSTALL - THE OIDC CLIENT HAS TO EXIST FIRST
|
||||
{{ if $settings.oauth.only }}
|
||||
THIS IS NOT ADVICE AND IT IS NOT A FOLLOW-UP STEP. bulwark.oauth.only is ON,
|
||||
which means the password form is hidden, which means that WITHOUT A WORKING
|
||||
OIDC CLIENT THE FIRST SIGN-IN IS IMPOSSIBLE. There is no fallback for users.
|
||||
{{ end }}
|
||||
Register a CONFIDENTIAL client with the provider at
|
||||
{{ $settings.oauth.issuerUrl }}
|
||||
client id: {{ $settings.oauth.clientId }}
|
||||
secret: into the vault item's oauth-client-secret field
|
||||
|
||||
THE REDIRECT URI CARRIES THE UI LANGUAGE, AND THAT IS THE ONE THING THAT WILL
|
||||
COST YOU THE FIRST SIGN-IN IF YOU REGISTER A SINGLE URI. Bulwark builds it as
|
||||
{{ $url }}/<locale>/auth/callback
|
||||
where <locale> is the language the VISITOR'S BROWSER resolved to - so a German
|
||||
browser sends /de/auth/callback and an English one /en/auth/callback, from the
|
||||
same deployment. The language is not in the visible URL (the locale prefix is
|
||||
"never" in this image), which is exactly why this is surprising: the address
|
||||
bar shows no language and the redirect_uri contains one.
|
||||
|
||||
Register ONE PER LANGUAGE YOU EXPECT, or a wildcard if the provider allows it.
|
||||
On Keycloak the wildcard is the practical answer:
|
||||
{{ $url }}/*
|
||||
Otherwise the 27 locales Bulwark supports are:
|
||||
ar ca cs da de en es fa fr he hu it ja ko lv mn nb nl pl pt ro ru sk tr uk
|
||||
zh zh-TW
|
||||
|
||||
If a sign-in fails with a redirect_uri mismatch, read the URI out of the
|
||||
failed request rather than guessing which language it was - that is the value
|
||||
the provider compares byte for byte.
|
||||
|
||||
POST-LOGOUT, IF YOU USE IT: bulwark.oauth.endSession is {{ $settings.oauth.endSession }}, and a
|
||||
post-logout redirect URI is only sent when OAUTH_POST_LOGOUT_REDIRECT_URI is
|
||||
set (this chart does not render it). If you add it through bulwark.env, it has
|
||||
to be registered with the provider too.
|
||||
|
||||
The issuer must be reachable BY THE POD, and the endpoints it advertises must
|
||||
share the issuer's origin or be publicly routable - Bulwark rejects a
|
||||
discovered endpoint on a private address from a different origin. A
|
||||
split-horizon DNS setup needs OAUTH_ALLOW_PRIVATE_ENDPOINTS through
|
||||
bulwark.env; this chart does not render it.
|
||||
{{ end }}
|
||||
FIRST INSTALL
|
||||
|
||||
1. kubectl -n {{ .Release.Namespace }} rollout status statefulset/{{ $sfs }}
|
||||
Ready means the HTTP server is accepting. It does NOT mean the mail server
|
||||
is reachable - nothing in this chart checks that.
|
||||
|
||||
2. Confirm the setup wizard really is OFF. It is, as long as
|
||||
bulwark.jmapServerUrl is set, and the chart refuses to render without it -
|
||||
but this is the one thing worth seeing rather than trusting, because a
|
||||
wizard that ran once changes the rules for good (step 4):
|
||||
kubectl -n {{ .Release.Namespace }} logs statefulset/{{ $sfs }} | grep -i "SETUP REQUIRED"
|
||||
Nothing found is the correct result. A "SETUP REQUIRED" banner with a token
|
||||
means Bulwark is in bootstrap mode and something is wrong with the value.
|
||||
|
||||
3. Sign in{{ if $settings.oauth.only }} through the identity provider{{ end }} at {{ $url }}
|
||||
|
||||
4. READ THIS BEFORE YOU OPEN THE ADMIN DASHBOARD. The dashboard writes
|
||||
OVERRIDES into config.json on the volume, and an override BEATS this chart
|
||||
for that key - for good, and silently.
|
||||
|
||||
Priority is: admin override > environment variable > default.
|
||||
|
||||
So if you save a server URL in the dashboard, changing bulwark.jmapServerUrl
|
||||
and running helm upgrade afterwards does nothing and reports success. The
|
||||
same is true of every other key the dashboard can write. What the chart
|
||||
sets is a DEFAULT, not a setting.
|
||||
|
||||
To see which keys are currently overridden:
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $sfs }}-0 -- cat /app/data/admin/config.json
|
||||
An empty object, or a file that does not exist, means this chart is in
|
||||
force.
|
||||
|
||||
5. THE ADMIN PASSWORD IS CONSUMED EXACTLY ONCE. On the first start it is
|
||||
hashed into /app/data/admin/admin.json and the environment variable is
|
||||
never read again. Rotating the vault field afterwards has NO EFFECT -
|
||||
changing the password goes through the dashboard, or by deleting admin.json
|
||||
from the volume and restarting. Know that before you treat the vault field
|
||||
as the source of truth.
|
||||
|
||||
IF NOBODY CAN SIGN IN - THE WAY BACK, AND IT IS NOT A helm upgrade
|
||||
|
||||
Read this now, not when it happens, because the obvious repair does not work.
|
||||
|
||||
The situation: the identity provider is down, or its configuration is wrong,
|
||||
and bulwark.oauth.only hides the password form. No user can get in.
|
||||
|
||||
WHAT DOES NOT WORK RELIABLY: helm upgrade with bulwark.oauth.only=false.
|
||||
oauthOnly is an ordinary config key, and every part of the app reads it
|
||||
through the priority chain above - not from the environment. If the admin
|
||||
dashboard's OAuth setup has ever been used, "oauthOnly": true is sitting in
|
||||
config.json on the volume, and from then on the chart's value is ignored. The
|
||||
upgrade succeeds and changes nothing.
|
||||
|
||||
WHAT DOES WORK, and it does not depend on the identity provider:
|
||||
|
||||
a) Open {{ $url }}/admin/login and sign in with the admin-password field
|
||||
from the vault item. This door has its own password and is not behind
|
||||
OIDC.{{ if eq $settings.stalwartAdminAccess "auto" }} (bulwark.stalwartAdminAccess is "auto", so a Stalwart
|
||||
admin also gets in here without that password.){{ end }}
|
||||
b) In the dashboard, REVERT the override - the dashboard's own "remove
|
||||
override" on the OAuth settings. That is what puts the chart's value back
|
||||
in charge.
|
||||
|
||||
IF THE DASHBOARD ITSELF IS UNREACHABLE, the last resort is the volume:
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $sfs }}-0 -- cat /app/data/admin/config.json
|
||||
# remove the "oauthOnly" key (and any other override in the way), then
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }}
|
||||
Deleting admin.json in the same directory resets the admin password to
|
||||
whatever the vault field currently holds, on the next start.
|
||||
|
||||
WHAT THE PROBES DO NOT TELL YOU
|
||||
|
||||
All three probes are a plain HTTP GET on /api/health, and that endpoint returns
|
||||
200 whatever state the application is in - for an unauthenticated caller the
|
||||
body even says "healthy" unconditionally. So a green pod here means: the
|
||||
process is alive, HTTP is being accepted, the event loop is free, and Next.js
|
||||
can route.
|
||||
|
||||
IT SAYS NOTHING ABOUT THE MAIL SERVER. With Stalwart completely gone, this
|
||||
release is Ready, green, and serves a web app that cannot fetch a single
|
||||
message. Monitor the stalwart release for that - there is no endpoint here
|
||||
that could cover it, and the one that would (/api/setup/test-jmap) sits behind
|
||||
the setup token this chart disables.
|
||||
|
||||
It also says nothing about the reported state: /api/health reports "degraded"
|
||||
on high V8 heap usage, but only to an authenticated caller asking for details,
|
||||
and always with HTTP 200. A memory limit set too low therefore produces a pod
|
||||
that is permanently degraded and permanently Ready.
|
||||
|
||||
BACKUPS - THE VOLUME ALONE IS NOT ONE, AND IT MAY NEED PROTECTING ITSELF
|
||||
|
||||
Two statements, both of them about {{ .Release.Name }}-{{ .Chart.Name }}-pvc:
|
||||
|
||||
1. A BACKUP OF THE VOLUME ALONE IS NOT A BACKUP.{{ if $settings.settingsSync }} The synchronized user
|
||||
settings on it are encrypted with session-secret.{{ else }} The user settings are not
|
||||
synchronized in this release, but persistent sessions still are.{{ end }} Lose the vault
|
||||
item and that data is unreadable out of a perfectly intact volume. Back up
|
||||
the 1Password item together with the volume, or the restore is a directory
|
||||
of ciphertext.
|
||||
|
||||
2. A BACKUP OF THE VOLUME MAY CONTAIN A SECRET IN CLEARTEXT. If anybody has
|
||||
used the dashboard's OAuth setup, the OIDC client secret is written as
|
||||
PLAINTEXT into config.json on this volume. So the backup may need the same
|
||||
protection the vault gets. Check with:
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $sfs }}-0 -- grep -l oauthClientSecret /app/data/admin/config.json
|
||||
|
||||
TRAPS WORTH KNOWING BEFORE YOU NEED THEM
|
||||
|
||||
- WEB PUSH GOES THROUGH THE VENDOR'S RELAY until somebody changes it, and the
|
||||
change is NOT a chart value. The built-in default is
|
||||
https://notifications.relay.bulwarkmail.org and it is compiled into the
|
||||
image. A self-hosted relay CAN be made the effective default and even
|
||||
mandatory for all users - through the settings policy in the admin
|
||||
dashboard, because that setting has no environment variable at all. So this
|
||||
is a choice you have, not a limitation you are stuck with; it just lives in
|
||||
the dashboard.
|
||||
|
||||
- THERE IS NO ConfigMap IN THIS CHART, so no checksum/config annotation, and
|
||||
that is not an omission: there is no rendered config file whose change could
|
||||
fail to restart anything. Repointing secrets.* at a different vault item
|
||||
DOES roll the pod (checksum/secret-ref). Changing a value INSIDE an item
|
||||
does not - but for the two file-form secrets it does not need to, because
|
||||
they are re-read on every use.
|
||||
{{ if $settings.adminConfigReadOnly }}
|
||||
- bulwark.adminConfigReadOnly IS ON. The chart is authoritative and no
|
||||
override can appear - and three things are frozen with it: the settings
|
||||
policy (so Web Push cannot be moved to your own relay any more), any
|
||||
override that was ALREADY on the volume (it cannot be removed either), and -
|
||||
if this release has never started with it off - the admin password, which
|
||||
means the admin dashboard never comes up and its login answers HTTP 500
|
||||
while this pod stays Ready.
|
||||
If you have just enabled this on a fresh install, turn it off, let the pod
|
||||
start once so admin.json is written, and only then turn it back on.
|
||||
{{ else }}
|
||||
- bulwark.adminConfigReadOnly is OFF, which is the default and keeps the
|
||||
dashboard working. The cost is the precedence trap of step 4: the volume can
|
||||
override this chart at any time. Turning it on makes the chart authoritative
|
||||
but freezes the settings policy, freezes any override already present, and
|
||||
must never be the state of a FIRST install - see values.yaml for the
|
||||
sequence.
|
||||
{{ end }}
|
||||
- THE SETTINGS POLICY, THE THEMES AND THE BRANDING ARE NOT IN THIS CHART.
|
||||
They live in policy.json on the volume and are set in the dashboard. This
|
||||
chart neither renders nor backs them up beyond the volume itself.
|
||||
|
||||
WHAT THIS CHART DOES NOT RENDER
|
||||
|
||||
- No ConfigMap (see above) and no NetworkPolicy. The pod needs egress to the
|
||||
JMAP server and to the OIDC issuer; a policy added later has to allow both,
|
||||
or sign-in breaks in a way that looks like a provider outage.
|
||||
- No PodDisruptionBudget: with one replica a minAvailable of 1 would forbid
|
||||
every voluntary eviction and make `kubectl drain` hang forever on a pod
|
||||
nothing may move (README, guideline 11).
|
||||
- No sub-path Ingress, and no value that could add one - the base path is
|
||||
compiled into the image.
|
||||
- No proxy-body-size annotation. Attachment uploads go through the Ingress and
|
||||
nginx's default of 1m rejects larger ones with a 413 that reads like an
|
||||
application fault; the right number is the mail server's limit, which this
|
||||
chart does not know. Set it in ingress.annotations.
|
||||
@@ -0,0 +1,464 @@
|
||||
{{/*
|
||||
Render the body of a container "resources:" block from a values dict of the form
|
||||
|
||||
resources:
|
||||
limitFactor: 3 # optional, default 3
|
||||
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
|
||||
limits: { ... } # optional, every entry optional
|
||||
|
||||
Rules (variant B, see issue #19):
|
||||
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
|
||||
to the Kubernetes key "ephemeral-storage").
|
||||
- an explicitly set limit always wins.
|
||||
- a missing memory / ephemeral-storage limit is computed as
|
||||
limitFactor x the corresponding request.
|
||||
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
|
||||
unlimited; CPU is compressible, so this cannot kill the workload).
|
||||
|
||||
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||
the chart's name as define prefix, because sharing it would mean a library
|
||||
chart, and a dependency is admissible but expensive (README, guideline 14) -
|
||||
this chart takes none. The reference implementation is
|
||||
template-chart/templates/_helpers.tpl.
|
||||
*/}}
|
||||
{{- define "bulwark.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 "bulwark.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $limits := dict -}}
|
||||
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $limits (include "bulwark.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- range $key, $request := $requests -}}
|
||||
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||
{{- $_ := set $limits $key (include "bulwark.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 "bulwark.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 "bulwark.multiplyQuantity" -}}
|
||||
{{- $quantity := include "bulwark.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 "bulwark.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation
|
||||
(issue #157): the null-safe fallback helpers pass values through fromYaml,
|
||||
which parses every number as float64, and Go's "%v" switches float64 to
|
||||
exponential notation from about 1e6 upwards - "1.048576e+06" is not a valid
|
||||
Kubernetes quantity. Strings ("512Mi") are passed through untouched.
|
||||
*/}}
|
||||
{{- define "bulwark.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render the replica count for the StatefulSet.
|
||||
|
||||
Input: dict "scaleDown" <flag|nil> "replicas" <value|nil>.
|
||||
|
||||
- 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 kindIs "invalid"
|
||||
nil check.
|
||||
- a missing replicas value defaults to 1.
|
||||
|
||||
A NOTE SPECIFIC TO THIS CHART: the schema caps this at 1, and the reason is the
|
||||
single ReadWriteOnce PVC rather than anything about the application. Two pods
|
||||
cannot both mount it, so a second replica would sit in ContainerCreating
|
||||
forever - and the PVC is not optional here, because /app/data carries the admin
|
||||
configuration, the audit log and the synchronized user settings. An RWX volume
|
||||
would not make it a cluster either: nothing in Bulwark coordinates two
|
||||
processes over that directory.
|
||||
*/}}
|
||||
{{- define "bulwark.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, #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
|
||||
<string>. Output: YAML of the merged dict (consume it with fromYaml).
|
||||
|
||||
An override key written without children is YAML null, and Helm treats that
|
||||
null as a deletion of the chart's default. This helper defines the opposite
|
||||
semantics - an empty block means "chart defaults apply" - while an explicitly
|
||||
set value still wins, including false and 0.
|
||||
*/}}
|
||||
{{- define "bulwark.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 "bulwark.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>.
|
||||
*/}}
|
||||
{{- define "bulwark.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 BULWARK SETTINGS - everything the StatefulSet, the Service and
|
||||
NOTES.txt read out of .Values.bulwark, merged onto this chart's defaults.
|
||||
|
||||
Why the whole settings block goes through the null-safe helper and not only the
|
||||
three usual suspects (guideline 12.2: "the rule is about the shape, not about
|
||||
those three block kinds"): every value below is dereferenced by a template, and
|
||||
an erased one would not abort the render. An erased bulwark.port renders an
|
||||
empty containerPort and an empty Service targetPort - rejected by nothing until
|
||||
the API server sees it; an erased bulwark.oauth.enabled would silently turn OIDC
|
||||
OFF on a release whose whole login path is OIDC, which is the quiet half and the
|
||||
worse one. Satisfactory shipped exactly this shape with half its blocks
|
||||
null-safe and half not (Gitea #16).
|
||||
|
||||
The defaults here MUST stay in sync with values.yaml, which remains the
|
||||
documented, user-facing place for them; this copy is only the fallback.
|
||||
|
||||
Usage: {{ $settings := fromYaml (include "bulwark.settings" .) }}
|
||||
*/}}
|
||||
{{- define "bulwark.settings" -}}
|
||||
{{- $given := (fromYaml (include "bulwark.subBlock" (dict "block" .Values "key" "bulwark" "path" ".Values"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"replicas" 1
|
||||
"port" 3000
|
||||
"jmapServerUrl" nil
|
||||
"appName" nil
|
||||
"stalwartFeatures" true
|
||||
"stalwartAdminAccess" "auto"
|
||||
"settingsSync" true
|
||||
"adminConfigReadOnly" false
|
||||
"telemetry" false
|
||||
"updateCheck" false
|
||||
"logLevel" "info"
|
||||
"logFormat" "json"
|
||||
"writableCache" (dict
|
||||
"tmpSizeLimit" "256Mi"
|
||||
"nextCacheSizeLimit" "512Mi")
|
||||
"oauth" (dict
|
||||
"enabled" true
|
||||
"only" true
|
||||
"clientId" nil
|
||||
"issuerUrl" nil
|
||||
"scopes" nil
|
||||
"endSession" true) -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" $given "path" "bulwark") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
THE DATA DIRECTORY, defined once because two templates have to agree on it: the
|
||||
StatefulSet mounts the PVC here and NOTES.txt names it.
|
||||
|
||||
/app/data, and it is NOT a value. Bulwark resolves its four directories
|
||||
relative to the process working directory - getConfigDir() falls back to
|
||||
<cwd>/data/admin and getStateDir() to <cwd>/data/admin-state (lib/admin/paths.ts
|
||||
at tag 1.12.0) - and cwd is /app (the image's WORKDIR). A path that disagreed
|
||||
with the mount would put the admin configuration and the audit log into the
|
||||
container's ephemeral layer, which looks like it works until the pod is
|
||||
replaced. The image creates all four subdirectories and chowns them to
|
||||
1001:1001 (Dockerfile: mkdir -p /app/data/{settings,admin,admin-state,telemetry}
|
||||
&& chown -R nextjs:nodejs /app/data) - but that happens in the IMAGE LAYER,
|
||||
which the PVC mount covers, so the subdirectories are created again at runtime
|
||||
by Bulwark itself (ensureConfigDir / ensureStateDir, both mkdir -p). fsGroup is
|
||||
what makes the empty volume writable for uid 1001.
|
||||
*/}}
|
||||
{{- define "bulwark.dataDir" -}}/app/data{{- end -}}
|
||||
|
||||
{{/*
|
||||
WHERE THE TWO FILE-FORM SECRETS ARE MOUNTED, and the one property of this mount
|
||||
that must not be "tidied up": it is a WHOLE-DIRECTORY mount with NO subPath.
|
||||
|
||||
The kubelet never refreshes a subPath mount. Both files are re-read on every
|
||||
use - getSessionSecret() calls readFileEnv on every call
|
||||
(lib/auth/session-secret.ts) and getGlobalClientSecret() does the same on every
|
||||
token exchange (lib/oauth/token-exchange.ts, no cache) - so rotating a field
|
||||
inside the vault item takes effect WITHOUT a pod restart. With a subPath that
|
||||
property is silently lost: the file in the container would keep the old value
|
||||
forever while the vault, the Secret and `helm upgrade` all look correct.
|
||||
*/}}
|
||||
{{- define "bulwark.secretMountPath" -}}/etc/bulwark/secrets{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective security contexts, probes and resources (issue #99).
|
||||
|
||||
The defaults below MUST stay in sync with values.yaml. They reproduce THIS
|
||||
chart's defaults, not the repo standard.
|
||||
*/}}
|
||||
{{- define "bulwark.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "bulwark.subBlock" (dict "block" . "key" "pod" "path" "bulwark.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 1001
|
||||
"runAsGroup" 1001
|
||||
"fsGroup" 1001
|
||||
"fsGroupChangePolicy" "Always"
|
||||
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" $given "path" "bulwark.securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "bulwark.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "bulwark.subBlock" (dict "block" . "key" "container" "path" "bulwark.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" $given "path" "bulwark.securityContext.container") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
ALL THREE PROBES ARE AN HTTP GET ON /api/health, AND WHAT THAT DOES AND DOES NOT
|
||||
CARRY IS MEASURED AGAINST THE SOURCE AT TAG 1.12.0 - not read off the path name,
|
||||
which is the mistake #218 and Gitea #83 each cost this repo once.
|
||||
|
||||
FIRST, WHY GET AND NOT HEAD, because the plan for this chart (Gitea #89) said
|
||||
HEAD and HEAD IS NOT AVAILABLE TO A KUBERNETES PROBE AT ALL. The API type is
|
||||
HTTPGetAction and it has no method field; the kubelet's prober issues a GET and
|
||||
there is no value anywhere in a pod spec that changes that. The only way to send
|
||||
a HEAD would be an exec probe shelling out to a HTTP client - and this image
|
||||
contains no curl and no wget (npm and npx are deliberately removed from the
|
||||
runtime layer), so it would mean `node -e` on every probe tick: a new Node
|
||||
process every few seconds against an endpoint whose GET handler is already
|
||||
cheap. That is strictly worse, so the chart uses the GET that Kubernetes
|
||||
actually offers.
|
||||
|
||||
WHAT CHANGES BECAUSE OF THAT: almost nothing, and in the direction that helps.
|
||||
Upstream's HEAD handler returns `new Response(null, { status: 200 })` and
|
||||
computes nothing. The GET handler does gather diagnostics, which makes it very
|
||||
slightly more capable than HEAD rather than less: its catch block answers 503
|
||||
when `process.memoryUsage()` or `v8.getHeapStatistics()` itself throws. Every
|
||||
limitation argued below is unchanged.
|
||||
|
||||
WHAT THE ENDPOINT DOES. The GET success path returns a hard `status: 200`
|
||||
whatever state it computed, and for an UNAUTHENTICATED caller the body is
|
||||
hard-coded to `healthy` as well (`status: detailed ? status : 'healthy'`);
|
||||
`degraded` appears only with `?detailed=true` AND a caller that passes
|
||||
canSeeInstanceDetails. Upstream tests exactly that -
|
||||
lib/__tests__/health-route.test.ts expects HTTP 200 while `payload.status` is
|
||||
`degraded`. A probe is unauthenticated and asks for no details, so it sees 200
|
||||
and "healthy" always.
|
||||
|
||||
SO THE PROBE CANNOT REPORT THE APPLICATION'S STATE - and this is deliberately
|
||||
NOT filed as "a probe that cannot fail", because that would be wrong and would
|
||||
invite somebody to replace it with something worse. What a 200 here really
|
||||
proves, all of it load-bearing: the Node process is alive, the HTTP server is
|
||||
accepting, the event loop is not blocked, and Next.js can route a request. An
|
||||
OOM kill, a crash, a wedged event loop and a container that never finished
|
||||
booting all fail this probe. That is more than #218's endpoint carried and it is
|
||||
exactly what a liveness and a startup probe are for.
|
||||
|
||||
WHAT IT DOES NOT CARRY, and no probe in this chart can: anything about
|
||||
STALWART. Bulwark holds no mail and this endpoint does not ask the JMAP server
|
||||
anything, so with the mail server gone /api/health is green and the pod stays
|
||||
Ready. There is no better endpoint - /api/setup/test-jmap would really test the
|
||||
connection, but it lives behind the bootstrap setup token and is unreachable in
|
||||
this chart (the wizard is disabled by jmapServerUrl). The limit is written at
|
||||
the readiness value in values.yaml because readiness is the probe whose promise
|
||||
it weakens.
|
||||
*/}}
|
||||
{{- define "bulwark.livenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/api/health" "port" "http" "scheme" "HTTP")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "bulwark.readinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/api/health" "port" "http" "scheme" "HTTP")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
STARTUP: the same endpoint, and it gates the other two - which is why neither of
|
||||
them carries an initialDelaySeconds.
|
||||
|
||||
BUDGET = periodSeconds x failureThreshold = 5 x 24 = 120s. What it has to cover
|
||||
is not Bulwark's own boot, which is a Next.js standalone server starting in
|
||||
about a second: it is attaching and mounting the network volume, and the
|
||||
instrumentation hook that runs BEFORE the first request is served -
|
||||
instrumentation.node.ts migrates the legacy admin layout, loads config.json and
|
||||
policy.json off the volume, and on a first install hashes ADMIN_PASSWORD with
|
||||
scrypt and writes admin.json. The scrypt hash is the one deliberately slow step
|
||||
in there.
|
||||
*/}}
|
||||
{{- define "bulwark.startupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/api/health" "port" "http" "scheme" "HTTP")
|
||||
"periodSeconds" 5
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 24 -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.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.
|
||||
*/}}
|
||||
{{- define "bulwark.effectiveResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" "100m" "memory" "256Mi" "ephemeral-storage" "64Mi") -}}
|
||||
{{- $merged := fromYaml (include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.resources")) -}}
|
||||
{{- include "bulwark.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The additive env block, rendered through tpl with the value, secretKeyRef and
|
||||
configMapKeyRef forms (guideline 10).
|
||||
|
||||
ADDITIVE MEANS ADDITIVE: an entry repeating a name this chart already sets does
|
||||
not replace it. At install time both reach the pod and the later one wins; on an
|
||||
EXISTING release the pair collapses to the chart's own value, because Kubernetes
|
||||
merges env by name - and helm upgrade still reports success. So do not use this
|
||||
block to override JMAP_SERVER_URL, OAUTH_ONLY or SESSION_SECRET_FILE; change the
|
||||
values that produce them.
|
||||
|
||||
AND THERE IS A SECOND, SHARPER REASON NOT TO USE IT FOR BULWARK'S OWN SETTINGS,
|
||||
specific to this application: an environment variable is only the MIDDLE layer
|
||||
of three. configManager.get resolves "admin override > env var > default"
|
||||
(lib/admin/config-manager.ts), so any key the admin dashboard has written into
|
||||
config.json on the PVC beats whatever arrives here - silently. An entry added
|
||||
through this block is therefore not a guarantee, it is a default that the volume
|
||||
may already be overriding. values.yaml, under bulwark.adminConfigReadOnly, has
|
||||
the whole precedence story.
|
||||
*/}}
|
||||
{{- define "bulwark.env" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- range $value := .env }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name | quote }}
|
||||
# quote is required, not cosmetic: a value like "3000" or "true" renders
|
||||
# unquoted as a YAML number/bool, and the API server then rejects the whole
|
||||
# manifest with "cannot unmarshal number into Go struct field EnvVar.value of
|
||||
# type string".
|
||||
value: {{ tpl $value.value $root | quote }}
|
||||
{{- else if $value.valueFrom }}
|
||||
{{- if $value.valueFrom.secretKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
|
||||
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The public base URL, derived from ingress.domain. NOTES.txt prints it and builds
|
||||
the OIDC redirect URI from it, which is why it is a helper rather than several
|
||||
copies of the same printf.
|
||||
|
||||
DELIBERATELY NO required() HERE, and that is the opposite of what it first looks
|
||||
like it should have. The only caller is NOTES.txt, and `helm template` does not
|
||||
render NOTES.txt (README, guideline 12.2) - so a required() in this helper would
|
||||
be a guard that cannot fire in lint, in the render test or in CI, and would fire
|
||||
for the first time during a real `helm install`. A guard that only runs in
|
||||
production is worse than no guard.
|
||||
|
||||
The honest position for THIS chart, checked in both directions as guideline 12.1
|
||||
asks: nothing outside the Ingress template FUNCTIONALLY reads ingress.domain.
|
||||
Bulwark derives its own public URL from the request origin and takes no base-URL
|
||||
variable, so with ingress.enabled: false the release is complete without a
|
||||
domain - the calibre-web-automated case, where the gate frees both values. The
|
||||
enforcement therefore sits entirely in bulwark.ingress.yaml, where it is reached
|
||||
by every render that has the Ingress on (the default), and this helper renders a
|
||||
visible placeholder instead of failing.
|
||||
*/}}
|
||||
{{- define "bulwark.publicUrl" -}}
|
||||
{{- if .Values.ingress.domain -}}
|
||||
https://{{ .Values.ingress.domain }}
|
||||
{{- else -}}
|
||||
https://<ingress.domain NOT SET>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,67 @@
|
||||
{{- /*
|
||||
ON BY DEFAULT (ingress.enabled: true): a webmail client nobody can reach is not
|
||||
a webmail client.
|
||||
|
||||
AT THE ROOT OF THE HOST, AND THERE IS NO VALUE TO PUT IT UNDER A PATH.
|
||||
NEXT_PUBLIC_BASE_PATH is compiled into the bundle - upstream: "These are
|
||||
compiled into the bundle and cannot be changed at runtime" - so a sub-path
|
||||
deployment needs a rebuilt image, which is outside what this repository does
|
||||
(same limit as garage #81 and keycloak #83). A rewrite-target annotation is not a
|
||||
way around it either: the bundle's own asset and API URLs are built for the root,
|
||||
so stripping a prefix at the proxy produces a page that loads and then fetches
|
||||
everything from the wrong place. The rule below is therefore "/" with pathType
|
||||
Prefix, deliberately and permanently.
|
||||
|
||||
GUIDELINE 1, CHECKED IN BOTH DIRECTIONS (README, "Values that must not lie"),
|
||||
AND FOR THIS CHART THE GATE FREES BOTH VALUES. Checked rather than assumed:
|
||||
nothing outside this template FUNCTIONALLY reads ingress.domain. Bulwark derives
|
||||
its own public URL from the request origin and takes no base-URL environment
|
||||
variable - there is no KC_HOSTNAME equivalent here - so a release with
|
||||
ingress.enabled: false is complete without a domain. That is the
|
||||
calibre-web-automated case rather than the matrix-synapse one.
|
||||
|
||||
The one other READER is NOTES.txt, which prints the access URL and the OIDC
|
||||
redirect URI. That read is deliberately NOT guarded with a required(): `helm
|
||||
template` does not render NOTES.txt, so a guard there would fire for the first
|
||||
time during a real `helm install` and never in lint, in the render test or in CI.
|
||||
The bulwark.publicUrl helper renders a visible placeholder instead, and the
|
||||
enforcement lives here - in the template that is reached by every render with the
|
||||
Ingress on, which is the default.
|
||||
*/ -}}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
|
||||
{{- /* Merged, not replaced. The one annotation worth adding here for this
|
||||
application is nginx.ingress.kubernetes.io/proxy-body-size: attachment
|
||||
uploads and the Files view go through this Ingress, and nginx's
|
||||
default of 1m rejects anything larger with a 413 that reads like an
|
||||
application fault. The chart does not set it because the right number
|
||||
is the mail server's limit, which this chart does not know. */}}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port:
|
||||
# BY NAME, so bulwark.port and this file cannot drift apart.
|
||||
name: http
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- end }}
|
||||
@@ -0,0 +1,50 @@
|
||||
{{- /*
|
||||
ONE claim, mounted at /app/data, holding all FOUR of Bulwark's directories as
|
||||
subdirectories of it:
|
||||
|
||||
admin config.json, policy.json, admin.json (the password hash),
|
||||
plugin-config/, plugins/, themes/, branding uploads
|
||||
admin-state admin-state.json (login timestamps), audit.log, the bootstrap
|
||||
setup token
|
||||
settings the encrypted per-account user settings
|
||||
telemetry consent and instance id
|
||||
|
||||
ONE VOLUME AND NOT FOUR, because a snapshot is atomic per volume and never
|
||||
across volumes - the durability lesson garage (#81) was built on. Split up, a
|
||||
backup can capture an audit log that records logins the configuration does not
|
||||
know about yet, or settings encrypted against a secret the configuration has
|
||||
since been repointed from. There is no argument on the other side here: this is
|
||||
a few hundred kilobytes of JSON plus an append-only log, not an object store.
|
||||
|
||||
Bulwark creates all four subdirectories itself on start (ensureConfigDir and
|
||||
ensureStateDir, both mkdir -p). It has to: the image's own `mkdir -p
|
||||
/app/data/...` ran in a layer that this mount covers. fsGroup is what makes the
|
||||
freshly provisioned volume writable for uid 1001.
|
||||
|
||||
DELIBERATELY NOT null-SAFE: an erased size yields an invalid PVC that is
|
||||
rejected at apply time, which is loud. A chart-side fallback would silently size
|
||||
somebody's volume - and on an existing release it could not help anyway, because
|
||||
a PVC cannot shrink. Same reasoning as garage's storage block and satisfactory's
|
||||
documented exception (Gitea #39).
|
||||
|
||||
NEVER RENAMED. A renamed PVC makes Kubernetes provision a new, empty volume and
|
||||
orphans the data - here that means admin.json, the only copy of the admin
|
||||
password hash, and audit.log, which by its nature cannot be reconstructed.
|
||||
*/ -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
accessModes:
|
||||
# ReadWriteOnce, and the single replica follows from it rather than the other
|
||||
# way round - see the bulwark.replicas helper.
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.storage.amount }}
|
||||
{{- if .Values.storage.storageClass }}
|
||||
storageClassName: {{ .Values.storage.storageClass }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,109 @@
|
||||
{{- /*
|
||||
The 1Password items this chart materializes. TWO of them, split BY AUDIENCE
|
||||
rather than by count:
|
||||
|
||||
<release>-bulwark-app-secret this release's own secrets - the session
|
||||
secret and the admin password. Nobody else
|
||||
knows or needs them.
|
||||
<release>-bulwark-oauth-secret the OIDC client secret. NOT ours: the
|
||||
identity provider (keycloak, #83) creates it
|
||||
when the client is registered and rotates it
|
||||
there, so it is the one of the three that a
|
||||
second system also holds. A separate item is
|
||||
what lets it be repointed and rotated without
|
||||
touching the other two.
|
||||
|
||||
Same reasoning as garage's split between its RPC secret and its S3 key.
|
||||
|
||||
WHAT THE FIELD NAMES ARE AND WHY THEY ARE NOT FREE-FORM: the operator
|
||||
materializes one Secret key per FIELD of the item, and this chart references
|
||||
those keys by name - two as file paths in the StatefulSet's volumeMounts, one by
|
||||
secretKeyRef. An item without the expected field leaves the container in
|
||||
CreateContainerConfigError, or - worse for the file form - produces a container
|
||||
that starts with a MISSING file, which readFileEnv answers with null rather than
|
||||
an error. NOTES.txt lists every field for the installer.
|
||||
|
||||
TWO SECRETS, TWO DIFFERENT ROTATION STORIES, and the difference is a property of
|
||||
the application rather than of this chart:
|
||||
|
||||
session-secret, oauth-client-secret FILE form. Re-read on every use with no
|
||||
cache, so a rotation is live WITHOUT a
|
||||
restart - see the whole-directory mount
|
||||
in bulwark.sfs.yaml, which is what makes
|
||||
that true.
|
||||
admin-password CONSUMED ONCE. initAdminPassword hashes
|
||||
it on the first start, writes admin.json
|
||||
to the PVC, and never looks at the
|
||||
variable again. Rotating the field after
|
||||
that has NO effect, restart or not.
|
||||
|
||||
AND THE LIMIT THAT CANNOT BE CLOSED FOR EITHER: a change to the CONTENT of an
|
||||
item restarts nothing, because Helm never sees a secret the 1Password operator
|
||||
materializes and there is nothing to hash. What this chart does cover is the
|
||||
op:// PATH, through checksum/secret-ref on the pod template. The 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 advertising a guarantee it does not provide (README, guideline 9).
|
||||
*/ -}}
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-app-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
# Must carry the fields: session-secret, admin-password
|
||||
#
|
||||
# session-secret: 32 bytes of randomness, base64 - `openssl rand -base64 32`.
|
||||
# MANDATORY. Persistent "remember me" sessions and the encryption of the
|
||||
# synchronized user settings both hang on it, and the second one fails
|
||||
# SILENTLY without it: app/api/config/route.ts computes
|
||||
# `settingsSyncEnabled: configManager.get(...) && hasSessionSecret()`, so
|
||||
# bulwark.settingsSync: true plus a missing secret means synchronization is
|
||||
# simply off, with no error anywhere.
|
||||
#
|
||||
# LOSING IT LOSES DATA THAT AN INTACT VOLUME STILL HOLDS: the settings on the
|
||||
# PVC are encrypted with it. This item belongs in the backup next to the
|
||||
# volume - a backup of the volume alone is not a backup.
|
||||
#
|
||||
# admin-password: the initial password of the ADMIN DASHBOARD.
|
||||
# It is set because the dashboard is the way back out of an OIDC lockout
|
||||
# (see bulwark.oauth.only in values.yaml), not because of any secret leak -
|
||||
# measured, there is none: without ADMIN_PASSWORD Bulwark generates no
|
||||
# random password and logs nothing, it just disables the dashboard.
|
||||
#
|
||||
# CONSUMED ON THE FIRST START ONLY. After admin.json exists on the PVC this
|
||||
# field is never read again, so rotating it here changes nothing; the reset
|
||||
# goes through the dashboard or by deleting admin.json.
|
||||
itemPath: {{ required "You must provide an app secret reference (secrets.appSecretRef) - the 1Password item holding the fields 'session-secret' (openssl rand -base64 32; mandatory, the settings sync is silently disabled without it) and 'admin-password' (the admin dashboard, which is the only way back out of an OIDC lockout)" .Values.secrets.appSecretRef }}
|
||||
{{- if $settings.oauth.enabled }}
|
||||
---
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-oauth-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
# Must carry the field: oauth-client-secret
|
||||
#
|
||||
# The confidential OIDC client's secret, as issued by the identity provider
|
||||
# when the client is registered. The registration is a PRE-INSTALL step, not a
|
||||
# follow-up: with bulwark.oauth.only (the default) and no working client, the
|
||||
# first sign-in is impossible. NOTES.txt prints the redirect URI to register.
|
||||
#
|
||||
# It arrives as a FILE, through OAUTH_CLIENT_SECRET_FILE, so it is not in the
|
||||
# container's process environment - which garage (#81) and keycloak (#83) could
|
||||
# not achieve because neither application offered a file form. #81's other half
|
||||
# was checked here and does not apply: lib/read-file-env.ts is readFileSync
|
||||
# plus try/catch with no mode check, so the group-read bit fsGroup adds is
|
||||
# harmless.
|
||||
#
|
||||
# AND THE ONE WAY A ROTATION SILENTLY DOES NOTHING: getGlobalClientSecret
|
||||
# checks the ADMIN OVERRIDE FIRST. If the dashboard's OAuth setup has ever
|
||||
# run, config.json on the PVC carries oauthClientSecret in PLAINTEXT and that
|
||||
# value beats this file forever. That is also why a backup of the volume can
|
||||
# contain this secret in the clear.
|
||||
itemPath: {{ required "You must provide an OAuth client secret reference (secrets.oauthSecretRef) - the 1Password item holding the field 'oauth-client-secret', as issued by the identity provider when the OIDC client was registered. Required while bulwark.oauth.enabled is true; set it to false for a password-login install" .Values.secrets.oauthSecretRef }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,48 @@
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
# The one port this application serves. targetPort addresses the container
|
||||
# port BY NAME, so bulwark.port can be changed freely while renaming the port
|
||||
# would break this and all three probes at once.
|
||||
- protocol: TCP
|
||||
port: {{ $settings.port | int64 }}
|
||||
targetPort: http
|
||||
name: http
|
||||
# ClusterIP, and no value to change it. The way out of the cluster is the
|
||||
# Ingress with a certificate: this is a webmail client that handles session
|
||||
# cookies and mail content, so a NodePort would put it on a routable address
|
||||
# over plain HTTP by accident.
|
||||
type: ClusterIP
|
||||
---
|
||||
{{/*
|
||||
The headless Service a StatefulSet needs for its serviceName. It exists because
|
||||
a StatefulSet requires one, not because anything addresses the pod by its stable
|
||||
DNS name - with one replica and no peer coordination there is nothing to address.
|
||||
Kept separate from the ClusterIP Service above so that the latter stays a plain
|
||||
load-balanced endpoint for the Ingress; pointing serviceName at a Service with a
|
||||
ClusterIP is accepted by the API server but gives the pod no per-pod DNS record,
|
||||
which is the kind of half-working arrangement that only shows up later.
|
||||
*/}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-headless
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: {{ $settings.port | int64 }}
|
||||
targetPort: http
|
||||
name: http
|
||||
@@ -0,0 +1,329 @@
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
{{- /*
|
||||
A StatefulSet and not a Deployment, and the reason is the single ReadWriteOnce
|
||||
PVC rather than any cluster identity: a rolling Deployment update blocks itself,
|
||||
because the new pod waits for a volume the old pod still holds. The repo's
|
||||
convention requires a StatefulSet for a stateful application anyway, and this one
|
||||
is stateful - /app/data carries the admin configuration, the admin password hash,
|
||||
the audit log and the encrypted user settings.
|
||||
|
||||
NO volumeClaimTemplates: this repo uses standalone PVC templates throughout, so
|
||||
the StatefulSet can be renamed in a major PR with a --cascade=orphan note without
|
||||
the data following the name (README, guideline 1).
|
||||
|
||||
NO INIT CONTAINER, and that is checked rather than skipped. Bulwark creates its
|
||||
four data subdirectories itself on start (ensureConfigDir / ensureStateDir, both
|
||||
mkdir -p), which it has to because the image's own mkdir ran in a layer this
|
||||
mount covers; fsGroup makes the fresh volume writable for uid 1001. There is
|
||||
nothing to seed and nothing to chown, so an init container would only be a second
|
||||
image to pin and update.
|
||||
|
||||
GUARD AT RENDER TIME, not at start: a missing OIDC client id or issuer with
|
||||
oauth.enabled would otherwise produce a release that installs cleanly, becomes
|
||||
Ready, and then answers every sign-in attempt with a broken redirect - and with
|
||||
oauth.only there is no password form to fall back on. The render fails instead,
|
||||
naming the value. It lives HERE rather than in NOTES.txt because `helm template`
|
||||
does not render NOTES.txt (guideline 12.2), so a guard there would pass every
|
||||
check in CI while being absent.
|
||||
*/ -}}
|
||||
{{- if $settings.oauth.enabled }}
|
||||
{{- if not $settings.oauth.clientId }}
|
||||
{{- fail "bulwark.oauth.clientId is required while bulwark.oauth.enabled is true - without it the OIDC redirect is built against an empty client and every sign-in fails. With bulwark.oauth.only (the default) there is no password form to fall back to, so this would be an install nobody can log into. Register the client with the identity provider first; NOTES.txt prints the redirect URI." }}
|
||||
{{- end }}
|
||||
{{- if not $settings.oauth.issuerUrl }}
|
||||
{{- fail "bulwark.oauth.issuerUrl is required while bulwark.oauth.enabled is true - Bulwark fetches the OIDC discovery document from it, and without it there is nothing to discover. For keycloak this is https://<host>/realms/<realm>." }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if not $settings.jmapServerUrl }}
|
||||
{{- /* Not merely "the mail server is unset": an empty JMAP_SERVER_URL puts
|
||||
detectSetupState() into "bootstrap" (lib/setup/state.ts), which serves the
|
||||
FIRST-RUN WIZARD at /setup and 302s everything else there. The wizard then
|
||||
writes its result into config.json on the PVC - and from that moment the
|
||||
volume beats every value in this chart, permanently and silently. So an
|
||||
unset value here does not produce a non-working release, it produces a
|
||||
SILENTLY DIFFERENT ONE, which is worse. */}}
|
||||
{{- fail "bulwark.jmapServerUrl is required - it is the address of the Stalwart server AND the switch that disables the first-run setup wizard. Left empty, Bulwark starts in bootstrap mode, serves its wizard at /setup, and whatever is clicked through there lands in config.json on the PVC - which from then on OVERRIDES every value this chart sets, silently. Set it to the stalwart release's JMAP endpoint." }}
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
replicas: {{ include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas) }}
|
||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-headless
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
template:
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
# NO checksum/config ANNOTATION, AND THAT IS NOT AN OMISSION (#82).
|
||||
# This chart renders no ConfigMap: Bulwark is configured through
|
||||
# environment variables plus the two JSON files it writes itself onto the
|
||||
# PVC. There is no rendered config file whose change could fail to
|
||||
# restart the workload, so the rule has nothing to attach to. A changed
|
||||
# environment variable changes the pod template directly and rolls the
|
||||
# pod, which is what the annotation exists to achieve elsewhere.
|
||||
#
|
||||
# Hash of the rendered OnePasswordItems - the op:// PATHS, not their
|
||||
# contents. Repointing secrets.appSecretRef or secrets.oauthSecretRef at
|
||||
# a different vault item therefore rolls the pod instead of leaving the
|
||||
# old credentials in the running process. A change INSIDE an item is not
|
||||
# covered and cannot be, because Helm never sees it - but for the two
|
||||
# FILE-form secrets it does not need to be: both are re-read on every use
|
||||
# (see the secrets volume below), so a rotation is live without a
|
||||
# restart. The exception is admin-password, which is consumed exactly
|
||||
# once on the first start and which no restart brings back.
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/bulwark.secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "bulwark.podSecurityContext" $.Values.bulwark.securityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
# Fully qualified. The suffix-free tag family, see Chart.yaml - this
|
||||
# registry publishes every release twice (1.12.0 and v1.12.0) and the
|
||||
# chart pins the form without the "v".
|
||||
image: "ghcr.io/bulwarkmail/webmail:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
# No command and no args: the image's CMD is ["node", "server.js"] with
|
||||
# WORKDIR /app, and the working directory is load-bearing here rather
|
||||
# than incidental - Bulwark resolves its data directories relative to
|
||||
# process.cwd(), so overriding either would move /app/data out from
|
||||
# under the volume mount.
|
||||
env:
|
||||
# THE MAIL SERVER, AND THE SWITCH THAT KILLS THE SETUP WIZARD. With
|
||||
# this set, detectSetupState() returns "env-managed" and /setup is
|
||||
# not served at all, which is what makes this chart declarative.
|
||||
#
|
||||
# IT IS STILL BEATEN BY A SERVER URL SAVED IN THE ADMIN DASHBOARD -
|
||||
# the one precedence trap upstream documents itself. See values.yaml.
|
||||
- name: JMAP_SERVER_URL
|
||||
value: {{ $settings.jmapServerUrl | quote }}
|
||||
{{- with $settings.appName }}
|
||||
- name: APP_NAME
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
- name: STALWART_FEATURES
|
||||
value: {{ $settings.stalwartFeatures | quote }}
|
||||
- name: STALWART_ADMIN_ACCESS
|
||||
value: {{ $settings.stalwartAdminAccess | quote }}
|
||||
# The settings sync. Note that this flag alone is not enough: without
|
||||
# a session secret, app/api/config/route.ts turns it off SILENTLY
|
||||
# (`configManager.get(...) && hasSessionSecret()`). The secret below
|
||||
# is therefore mandatory rather than recommended.
|
||||
- name: SETTINGS_SYNC_ENABLED
|
||||
value: {{ $settings.settingsSync | quote }}
|
||||
# THE SESSION SECRET, AS A FILE PATH. Everything about why it is the
|
||||
# file form and why the mount has no subPath is at the volume below.
|
||||
#
|
||||
# This is also ONE OF ONLY TWO VALUES IN THIS CHART THAT THE VOLUME
|
||||
# CANNOT OVERRIDE: getSessionSecret (lib/auth/session-secret.ts) reads
|
||||
# SESSION_SECRET, then SESSION_SECRET_FILE, and only then the admin
|
||||
# override - the reverse of configManager.get's normal order.
|
||||
- name: SESSION_SECRET_FILE
|
||||
value: {{ include "bulwark.secretMountPath" . }}/session-secret
|
||||
# THE ADMIN PASSWORD, AS AN ENVIRONMENT VARIABLE, AND THAT IS FORCED
|
||||
# RATHER THAN CHOSEN: ADMIN_PASSWORD has no _FILE form in
|
||||
# CONFIG_ENV_MAP, unlike the other two secrets here. The residual
|
||||
# price, stated rather than hidden: it is in the container's process
|
||||
# environment, so anyone who can exec into the pod or read its /proc
|
||||
# can see it until the first start has hashed it into admin.json.
|
||||
#
|
||||
# WHY IT IS SET AT ALL: the admin dashboard is the only way back out
|
||||
# of an OIDC lockout (OAUTH_ONLY below), and without this variable
|
||||
# the dashboard is simply disabled - there is no random password and
|
||||
# nothing in the log, measured in lib/admin/password.ts.
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-app-secret
|
||||
key: admin-password
|
||||
{{- if $settings.adminConfigReadOnly }}
|
||||
# THE CHART IS AUTHORITATIVE FROM HERE ON - and this is the one value
|
||||
# whose price is bigger than its name. It freezes the settings policy
|
||||
# (and with it the only way to move Web Push off the vendor relay),
|
||||
# it freezes an override that ALREADY exists instead of removing it,
|
||||
# and on a FRESH install it prevents admin.json from being written at
|
||||
# all - which takes the admin dashboard, i.e. the rescue door, away
|
||||
# entirely while the pod stays Ready. values.yaml has the measurement
|
||||
# and the only safe sequence.
|
||||
- name: ADMIN_CONFIG_READONLY
|
||||
value: "true"
|
||||
{{- end }}
|
||||
{{- if $settings.oauth.enabled }}
|
||||
- name: OAUTH_ENABLED
|
||||
value: "true"
|
||||
# HIDES THE PASSWORD FORM. The way back out is NOT a helm upgrade
|
||||
# with this false - oauthOnly is read through configManager.get by
|
||||
# every consumer, so an override in config.json on the PVC wins. The
|
||||
# repair is removing that override in the admin dashboard. NOTES.txt
|
||||
# carries it.
|
||||
- name: OAUTH_ONLY
|
||||
value: {{ $settings.oauth.only | quote }}
|
||||
- name: OAUTH_CLIENT_ID
|
||||
value: {{ $settings.oauth.clientId | quote }}
|
||||
- name: OAUTH_ISSUER_URL
|
||||
value: {{ $settings.oauth.issuerUrl | quote }}
|
||||
# The client secret as a FILE, so it stays out of the process
|
||||
# environment - the better path garage (#81) and keycloak (#83) did
|
||||
# not have. Re-read on every token exchange, so a rotation needs no
|
||||
# restart; see the volume below for the subPath condition that makes
|
||||
# that true.
|
||||
- name: OAUTH_CLIENT_SECRET_FILE
|
||||
value: {{ include "bulwark.secretMountPath" . }}/oauth-client-secret
|
||||
{{- with $settings.oauth.scopes }}
|
||||
- name: OAUTH_SCOPES
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
- name: OAUTH_END_SESSION
|
||||
value: {{ $settings.oauth.endSession | quote }}
|
||||
{{- end }}
|
||||
# Both OFF against the image's defaults, and deliberately: a mail
|
||||
# client should not phone home from a private cluster unless somebody
|
||||
# chose it, and Renovate already watches the image tag, so the
|
||||
# version check would answer outbound a question already answered
|
||||
# inbound.
|
||||
- name: BULWARK_TELEMETRY
|
||||
value: {{ ternary "on" "off" $settings.telemetry | quote }}
|
||||
- name: BULWARK_UPDATE_CHECK
|
||||
value: {{ ternary "on" "off" $settings.updateCheck | quote }}
|
||||
- name: LOG_LEVEL
|
||||
value: {{ $settings.logLevel | quote }}
|
||||
- name: LOG_FORMAT
|
||||
value: {{ $settings.logFormat | quote }}
|
||||
# PORT and HOSTNAME are already set in the image (3000 / 0.0.0.0);
|
||||
# PORT is repeated here only because bulwark.port makes it
|
||||
# configurable, and the containerPort below has to agree with it.
|
||||
- name: PORT
|
||||
value: {{ $settings.port | quote }}
|
||||
{{- with $.Values.bulwark.env }}
|
||||
{{- include "bulwark.env" (dict "root" $ "env" .) | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
# The name "http" is what the Service and all three probes reference,
|
||||
# so renaming it here breaks four things at once.
|
||||
- name: http
|
||||
containerPort: {{ $settings.port | int64 }}
|
||||
protocol: TCP
|
||||
# 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.
|
||||
#
|
||||
# All three are an HTTP GET on /api/health - not HEAD, because a
|
||||
# Kubernetes probe cannot send one (HTTPGetAction has no method field).
|
||||
# What that endpoint can and cannot report is argued at the helpers and
|
||||
# at bulwark.readinessProbe in values.yaml; the short version is that it
|
||||
# carries the process, HTTP acceptance, a free event loop and Next.js
|
||||
# routing - and NOTHING about the mail server.
|
||||
livenessProbe:
|
||||
{{- include "bulwark.livenessProbe" $.Values.bulwark.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "bulwark.readinessProbe" $.Values.bulwark.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "bulwark.startupProbe" $.Values.bulwark.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "bulwark.effectiveResources" $.Values.bulwark.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "bulwark.containerSecurityContext" $.Values.bulwark.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
# THE ONE PVC, carrying all four of Bulwark's directories as
|
||||
# subdirectories so that a volume snapshot catches configuration,
|
||||
# runtime state, audit log and user settings atomically.
|
||||
- name: data
|
||||
mountPath: {{ include "bulwark.dataDir" . }}
|
||||
# THE TWO FILE-FORM SECRETS, AS A WHOLE DIRECTORY AND NOT WITH
|
||||
# subPath. This is the line a later round will want to "tidy up" into
|
||||
# two subPath mounts, and that would silently break secret rotation:
|
||||
# the kubelet never refreshes a subPath mount, while both values are
|
||||
# re-read from disk on every use (getSessionSecret and
|
||||
# getGlobalClientSecret both call readFileEnv with no cache). With
|
||||
# this mount a rotated vault field is live without a restart; with
|
||||
# subPath the container would hold the first value forever and
|
||||
# nothing would report it.
|
||||
- name: secrets
|
||||
mountPath: {{ include "bulwark.secretMountPath" . }}
|
||||
readOnly: true
|
||||
# The two writable paths outside the volume, needed because
|
||||
# readOnlyRootFilesystem is true. Both are CACHES, not state - see
|
||||
# bulwark.writableCache in values.yaml for how the set was
|
||||
# established and why neither belongs on the PVC.
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: next-cache
|
||||
mountPath: /app/.next/cache
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
- name: secrets
|
||||
# One projected volume over the two Secrets the 1Password operator
|
||||
# materializes, so both files sit in one directory and the container
|
||||
# needs one mount instead of two.
|
||||
#
|
||||
# defaultMode 0440 - owner and GROUP read, and the group bit is the
|
||||
# part that must not be "tightened" to 0400. Files in a secret volume
|
||||
# are owned by ROOT, not by the container's user; what gives uid 1001
|
||||
# access is fsGroup, which the kubelet applies by chowning the files to
|
||||
# group 1001. Without a group read bit, uid 1001 could not read either
|
||||
# file - and the failure would be silent, because readFileEnv answers
|
||||
# an EACCES with null exactly as it answers a missing file: the
|
||||
# settings sync would switch itself off and every token exchange would
|
||||
# send an empty client secret.
|
||||
#
|
||||
# (The kubelet ORs 0440 onto the mode of a secret volume once fsGroup
|
||||
# is set, so 0400 would in practice be corrected to 0440 anyway. It is
|
||||
# written out because a mode that only works because something else
|
||||
# silently fixes it is not a mode anyone should have to re-derive.)
|
||||
#
|
||||
# #81's permission trap does not apply in the other direction either:
|
||||
# lib/read-file-env.ts is readFileSync plus try/catch with no mode
|
||||
# check, so unlike garage, Bulwark does not REJECT a group-readable
|
||||
# secret file. That is what makes the file form usable here at all.
|
||||
projected:
|
||||
defaultMode: 0440
|
||||
sources:
|
||||
- secret:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-app-secret
|
||||
items:
|
||||
# Only the session secret becomes a file. admin-password is
|
||||
# in the same Secret but arrives as an environment variable
|
||||
# (it has no _FILE form), so projecting it here as well would
|
||||
# put it on disk for no reader.
|
||||
- key: session-secret
|
||||
path: session-secret
|
||||
# optional: the 1Password operator fills this Secret in-cluster
|
||||
# AFTER Helm has applied the manifests, so on a first install
|
||||
# the Secret does not exist yet. Without this the pod would sit
|
||||
# in ContainerCreating until the operator caught up, and with a
|
||||
# misconfigured operator it would sit there forever with the
|
||||
# reason only in the pod's events. It starts instead, and the
|
||||
# missing secret surfaces where it is actionable: the settings
|
||||
# sync stays off and the log says so.
|
||||
optional: true
|
||||
{{- if $settings.oauth.enabled }}
|
||||
- secret:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-oauth-secret
|
||||
items:
|
||||
- key: oauth-client-secret
|
||||
path: oauth-client-secret
|
||||
optional: true
|
||||
{{- end }}
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: {{ $settings.writableCache.tmpSizeLimit }}
|
||||
# Next.js's own cache directory in standalone mode - the optimized-image
|
||||
# cache and the fetch cache. It is a SUBDIRECTORY of .next, which is what
|
||||
# makes this possible at all: /app/.next/static holds the build output and
|
||||
# has to stay readable, and mounting the cache path does not shadow it.
|
||||
- name: next-cache
|
||||
emptyDir:
|
||||
sizeLimit: {{ $settings.writableCache.nextCacheSizeLimit }}
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "bulwark values",
|
||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key this chart does not read is rejected at render time instead of being silently ignored; the objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotation maps) stay open, because the chart does not interpret their contents. The blocks the chart dereferences additionally accept null, because an erased block means 'chart defaults apply' (issue #99) rather than a vanished setting. Note what this schema CANNOT enforce: secrets.appSecretRef, bulwark.jmapServerUrl and the two OIDC values all ship as null and JSON Schema's 'required' tests presence, not null-ness, so a 'required' array would never fire - the enforcement is required() in bulwark.secret.yaml and fail() in bulwark.sfs.yaml (issue #18). And note what no schema can express at all for this chart: an admin override in config.json on the PVC beats every value below, silently (values.yaml, point 1).",
|
||||
"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 takes precedence over bulwark.replicas. The PVC, Secrets, Service and Ingress stay in place.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"appSecretRef": {
|
||||
"description": "op:// item path. The item must carry the fields 'session-secret' and 'admin-password'. Required always. The session secret is mandatory rather than recommended: without it the settings sync is switched off SILENTLY, and the encrypted settings already on the volume become unreadable. The admin password is consumed exactly once, on the first start.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"oauthSecretRef": {
|
||||
"description": "op:// item path. The item must carry the field 'oauth-client-secret', as issued by the identity provider when the OIDC client was registered. Required while bulwark.oauth.enabled is true.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulwark": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "0 or 1. Capped at 1 because of the single ReadWriteOnce PVC, not because of the application: two pods cannot both mount it, so a second replica would sit in ContainerCreating forever. An RWX volume would not help either - nothing coordinates two processes over that directory, and the admin config is read into memory once at start.",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"port": {
|
||||
"$ref": "#/definitions/port"
|
||||
},
|
||||
"jmapServerUrl": {
|
||||
"description": "The Stalwart JMAP endpoint. Required - and it does two things: it is the mail server's address AND the switch that disables the first-run setup wizard. Left empty, Bulwark starts in bootstrap mode and whatever is clicked through its wizard lands in config.json on the PVC, which then overrides every value here. Enforced by fail() in the StatefulSet, not by this schema (see the title).",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"appName": {
|
||||
"description": "APP_NAME - the product name in the UI and the PWA. null leaves upstream's 'Webmail'.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"stalwartFeatures": {
|
||||
"description": "STALWART_FEATURES - the Stalwart-specific UI parts (password change, Sieve rules). Does not gate the server-side credential passthrough, which has its own upstream switch this chart does not render.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stalwartAdminAccess": {
|
||||
"description": "STALWART_ADMIN_ACCESS - who may open the admin dashboard, which after bulwark.oauth.only is the only way back into a broken login path. 'auto' (upstream's default, kept here) signs a Stalwart admin in without the Bulwark admin password; 'password' requires it; 'off' ignores Stalwart admin status entirely. The consequence of 'auto' is at the value in values.yaml: a Stalwart admin can write the config overrides that disconnect this chart from its own values.",
|
||||
"type": "string",
|
||||
"enum": ["auto", "password", "off"]
|
||||
},
|
||||
"settingsSync": {
|
||||
"description": "SETTINGS_SYNC_ENABLED - per-account preferences follow the user instead of living in one browser. true means /app/data/settings on the PVC carries USER DATA, encrypted with the session secret. Without that secret this switch is ignored, silently.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"adminConfigReadOnly": {
|
||||
"description": "ADMIN_CONFIG_READONLY - refuse every write to the admin config directory at the application layer, which makes this file authoritative and closes the precedence trap for good. FALSE by default because the price is threefold and measured: it also freezes the settings policy (and with it the only way to move Web Push off the vendor relay); it freezes an override that ALREADY exists rather than removing it, because removeAdminOverride is guarded by the same check; and on a FRESH install it prevents admin.json from being written at all, so the admin dashboard - the rescue door - never comes up and its login endpoint answers HTTP 500 while the pod stays Ready. values.yaml has the only safe sequence.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"telemetry": {
|
||||
"description": "BULWARK_TELEMETRY. false. Upstream's default is already off (telemetry is opt-in), so what this buys is the LOCK: the environment beats the state file and upstream blocks the admin UI from changing it while the override is active.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateCheck": {
|
||||
"description": "BULWARK_UPDATE_CHECK - the hourly version check. Unlike telemetry this is ON unless switched off, so false does change behaviour. Off because Renovate already watches the image tag inbound.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logLevel": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warn", "info", "debug"]
|
||||
},
|
||||
"logFormat": {
|
||||
"description": "text or json. json here, against upstream's default of text, because the logs go to a cluster log collector rather than to a terminal.",
|
||||
"type": "string",
|
||||
"enum": ["text", "json"]
|
||||
},
|
||||
"oauth": {
|
||||
"description": "May be null / empty: the chart then falls back to its defaults (OIDC on, password form hidden). That fallback is deliberate and is the safe direction - an erased block must not silently turn the whole sign-in path off on a release whose login is OIDC.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "OAUTH_ENABLED. Gates the OIDC values, the OAuth OnePasswordItem and the secret file mount.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"only": {
|
||||
"description": "OAUTH_ONLY - hide the password form, so the identity provider is the only way in for a user. This chart's riskiest default, taken deliberately. The way back out of a lockout is NOT a helm upgrade with this false: oauthOnly is read through configManager.get, so an override in config.json on the PVC wins. The repair is removing that override in the admin dashboard.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clientId": {
|
||||
"description": "OAUTH_CLIENT_ID. Required while enabled - enforced by fail() in the StatefulSet.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"issuerUrl": {
|
||||
"description": "OAUTH_ISSUER_URL, the issuer Bulwark fetches the discovery document from (keycloak: https://<host>/realms/<realm>). Required while enabled - enforced by fail() in the StatefulSet. The endpoints it advertises must share the issuer's origin or be public, or Bulwark's discovery validator rejects them.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"scopes": {
|
||||
"description": "OAUTH_SCOPES - REPLACES the scope list rather than adding to it. null leaves upstream's default, which is the right starting point: an incomplete list here is a login that succeeds and then has no identity to work with. Use OAUTH_EXTRA_SCOPES through bulwark.env to add.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"endSession": {
|
||||
"description": "OAUTH_END_SESSION - signing out also ends the provider's session. true (upstream's default): on a shared browser a sign-out that leaves the provider signed in means the next click is straight back in.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"writableCache": {
|
||||
"description": "The sizeLimit of the two emptyDirs that readOnlyRootFilesystem: true makes necessary - /tmp and Next.js's own cache at /app/.next/cache. Both are CACHES, not state: losing them on a restart costs one round of image re-optimization. They belong here rather than on the PVC, because a cache on a ReadWriteOnce network volume is slower than regenerating it and would grow the only volume that has to be backed up. These limits and the pod's ephemeral-storage request under bulwark.resources are two halves of one accounting and should move together.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tmpSizeLimit": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"nextCacheSizeLimit": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"securityContext": {
|
||||
"$ref": "#/definitions/securityContext"
|
||||
},
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"description": "The single PVC at /app/data, carrying all four of Bulwark's directories as subdirectories - one volume, because a snapshot is atomic per volume and never across volumes (the lesson of Gitea #81). Deliberately NOT null-safe: an erased size yields an invalid PVC, which fails loudly at apply time. Issue #99 covers only the silent class, and on an existing release a chart-side fallback could not help anyway, because a PVC cannot shrink.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"amount": {
|
||||
"description": "What actually grows here is audit.log, and it grows with admin activity rather than with mail volume - the application does not rotate it.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"storageClass": {
|
||||
"description": "null uses the cluster's default StorageClass. ReadWriteOnce is enough and is what the single replica needs.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the whole Ingress template (issue #93). Checked in both directions for this chart: nothing outside that template FUNCTIONALLY reads ingress.domain - Bulwark derives its own public URL from the request origin and takes no base-URL variable - so switching the Ingress off frees domain as well as clusterIssuer. The only other reader is NOTES.txt, which renders a visible placeholder rather than carrying a required() that helm template could never reach.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"domain": {
|
||||
"description": "The public hostname. Bulwark is served at the ROOT of it and cannot be served under a path: NEXT_PUBLIC_BASE_PATH is compiled into the bundle, and a rewrite-target annotation does not help because the bundle's own asset URLs are built for the root.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. The one worth adding for this application is nginx.ingress.kubernetes.io/proxy-body-size: attachment uploads go through this Ingress and nginx's default of 1m rejects anything larger with a 413 that reads like an application fault.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"port": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"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. One thing specific to this chart: the memory LIMIT is what V8's heap limit is derived from, and /api/health reports 'degraded' once heap usage crosses its threshold - a report the probes cannot see, so a limit set too low produces a pod that is permanently degraded and permanently Ready.",
|
||||
"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 container env entries. An entry repeating a name the chart already sets does not replace it - on an existing release the pair collapses to the chart's own value while helm upgrade reports success. And a second caveat specific to this application: an environment variable is only the MIDDLE of three layers, so anything set here is a default that an admin override in config.json on the PVC may already be beating, silently.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99). This chart's fallback reproduces ITS defaults, deviations included - uid/gid/fsGroup 1001 rather than this repo's 20000, because that is the user the image creates (addgroup --gid 1001 nodejs / adduser --uid 1001 nextjs) and the owner of /app/data in the image layer. fsGroupChangePolicy is the repo's 'Always' and is correct here rather than merely default: /app/data holds a few JSON files and an append-only log, so the recursive chown is cheap, and it is what makes a freshly provisioned volume writable on the first start.",
|
||||
"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. All three defaults carry the same CONTEXT: an HTTP GET on /api/health, addressed at the port NAME 'http'. Not HEAD, although Bulwark offers one and it would be cheaper - a Kubernetes HTTPGetAction has no method field and the kubelet always issues a GET, and the only alternative (an exec probe) would mean a Node process every few seconds in an image with no curl or wget. What that proves and what it does not is measured rather than read off the path - it carries the process, HTTP acceptance, a free event loop and Next.js routing, and it carries NOTHING about the reported application state (the handler returns a hard 200 and hard-codes the body to 'healthy' for an unauthenticated caller) and NOTHING about Stalwart (with the mail server gone the probe is green and the pod stays Ready). There is no better endpoint: /api/setup/test-jmap would really test the connection but sits behind the bootstrap setup token, which this chart disables.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
# Bulwark Webmail - a JMAP webmail client (mail, calendar, contacts, files) for
|
||||
# a Stalwart mail server. It is a CLIENT: it holds no mail, has no database, no
|
||||
# cache and no broker. The messages, the accounts and SMTP are stalwart's
|
||||
# (Gitea #65), reached over the network by bulwark.jmapServerUrl.
|
||||
#
|
||||
# READ THIS FIRST. Four properties shape every value below, and not one of them
|
||||
# is visible from the outside (Gitea #89). Everything here was measured against
|
||||
# the source at tag 1.12.0, not taken from the documentation - the documentation
|
||||
# was wrong about two of them.
|
||||
#
|
||||
# 1. THE VOLUME BEATS THE CHART, FOR EVERY VALUE, SILENTLY.
|
||||
# This is the single most important sentence in this file.
|
||||
# lib/admin/config-manager.ts resolves a config value in this order:
|
||||
#
|
||||
# admin override (config.json on the PVC) > environment variable > default
|
||||
#
|
||||
# Every setting this chart renders as an environment variable is therefore a
|
||||
# DEFAULT that the admin dashboard may already be overriding. The override is
|
||||
# a key in config.json on the PVC, the dashboard may write ANY key of
|
||||
# CONFIG_ENV_MAP (app/api/admin/config/route.ts validates against exactly
|
||||
# that key list), and nothing in the dashboard says that it has just
|
||||
# disconnected the chart. A `helm upgrade` with a changed value then changes
|
||||
# nothing, reports success, and leaves no trace of why.
|
||||
#
|
||||
# The documentation names this only for the server URL ("A server URL saved
|
||||
# by the wizard takes priority over the environment variable"). It is not
|
||||
# special to the server URL. It is the behaviour of every key.
|
||||
#
|
||||
# THERE ARE EXACTLY THREE EXCEPTIONS, all verified, and they are the only
|
||||
# values in this chart that the volume cannot silently beat:
|
||||
# - SESSION_SECRET / SESSION_SECRET_FILE: getSessionSecret()
|
||||
# (lib/auth/session-secret.ts) reads the environment FIRST and the admin
|
||||
# override last - the reverse of the normal order.
|
||||
# - ADMIN_PASSWORD: initAdminPassword() reads the environment only. (It is
|
||||
# still consumed exactly once - see secrets.appSecretRef.)
|
||||
# - BULWARK_TELEMETRY: effectiveConsent() takes the environment over the
|
||||
# state file, and upstream blocks the admin UI from changing it while the
|
||||
# override is set.
|
||||
# The OAuth client secret is NOT an exception, which is the one most likely to
|
||||
# be assumed - see secrets.oauthSecretRef.
|
||||
#
|
||||
# The way out, if you want the chart to be authoritative, is
|
||||
# bulwark.adminConfigReadOnly - and it has a price that is bigger than it
|
||||
# looks. Read it there before setting it.
|
||||
#
|
||||
# 2. THERE IS NO ConfigMap IN THIS CHART, AND THAT IS NOT AN OMISSION.
|
||||
# Bulwark is configured entirely through environment variables plus the two
|
||||
# JSON files it writes itself onto the PVC (config.json, policy.json). No
|
||||
# config file is rendered, mounted or hashed here, so the repo's
|
||||
# checksum/config rule (#82) has nothing to attach to: there is no ConfigMap
|
||||
# whose change could fail to restart the workload. What IS covered is the
|
||||
# op:// PATHS of the vault items, through checksum/secret-ref on the pod
|
||||
# template, so repointing a secrets.* value really does roll the pod. A
|
||||
# change INSIDE an item is not covered and cannot be - Helm never sees it -
|
||||
# and for the two FILE-form secrets it does not need to be: both are re-read
|
||||
# on every use, so a rotation takes effect without a restart (point 3).
|
||||
#
|
||||
# 3. THE TWO FILE-FORM SECRETS ARE MOUNTED WITHOUT subPath, ON PURPOSE.
|
||||
# SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE both exist (upstream calls
|
||||
# them the file form of SESSION_SECRET and OAUTH_CLIENT_SECRET), and taking
|
||||
# them keeps both values out of the container's process environment, which is
|
||||
# what garage (#81) and keycloak (#83) could not do because neither
|
||||
# application offered a file form at all. Here the file form is also
|
||||
# ROTATABLE: readFileEnv() is called on every use with no cache, so a changed
|
||||
# field in the vault item takes effect without a pod restart - but only
|
||||
# because the Secret is mounted as a whole directory. The kubelet never
|
||||
# refreshes a subPath mount, so a subPath here would silently freeze both
|
||||
# secrets at their first value while everything else looked correct.
|
||||
# ADMIN_PASSWORD has NO file form and stays an environment variable.
|
||||
#
|
||||
# 4. THREE OPTIONS ARE BAKED IN AT BUILD TIME AND THIS CHART CANNOT MOVE THEM.
|
||||
# Upstream: "These are compiled into the bundle and cannot be changed at
|
||||
# runtime ... To change build-time variables with the published Docker image,
|
||||
# rebuild with matching --build-arg." Building our own image is outside what
|
||||
# this repository does - the same limit as garage (#81) and keycloak (#83).
|
||||
# - NEXT_PUBLIC_BASE_PATH: so Bulwark is served at the ROOT of a hostname.
|
||||
# There is no sub-path deployment and no value here that could make one.
|
||||
# - NEXT_PUBLIC_DEFAULT_LOCALE: the fallback UI language stays "en". A
|
||||
# visitor with a matching Accept-Language gets their own language;
|
||||
# everybody else gets English.
|
||||
# - NEXT_PUBLIC_PUSH_RELAY_URL: see bulwark.pushRelay below. This one is
|
||||
# NOT the disclosure it looks like.
|
||||
|
||||
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
||||
# the StatefulSet with replicas: 0 and takes precedence over bulwark.replicas.
|
||||
# The PVC, the Secrets, the Service and the Ingress stay in place;
|
||||
# scaleDown: false brings the workload back up.
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// path of the 1Password item carrying THIS RELEASE'S OWN secrets.
|
||||
# REQUIRED - the chart does not render without it.
|
||||
#
|
||||
# Must carry the fields:
|
||||
# session-secret -> mounted as a file, read via SESSION_SECRET_FILE
|
||||
# admin-password -> ADMIN_PASSWORD (environment variable, no file form)
|
||||
#
|
||||
# WHY session-secret IS MANDATORY AND NOT OPTIONAL. Two things hang on it, and
|
||||
# one of them fails silently without it: persistent "remember me" sessions,
|
||||
# and the encryption of the synchronized user settings. app/api/config/route.ts
|
||||
# computes `settingsSyncEnabled: configManager.get(...) && hasSessionSecret()`
|
||||
# - so with bulwark.settingsSync true and no secret, synchronization is
|
||||
# switched OFF with no error anywhere. Generate it with
|
||||
# openssl rand -base64 32
|
||||
#
|
||||
# AND THE BACKUP STATEMENT, WHICH IS TWO-PART AND IS THE SHARPEST THING ON
|
||||
# THIS PAGE. It belongs wherever this release's backups are described:
|
||||
#
|
||||
# (a) A BACKUP OF THE VOLUME ALONE IS NOT A BACKUP. The synchronized
|
||||
# settings on the PVC are encrypted with session-secret. Lose the vault
|
||||
# item and they are unreadable out of a perfectly intact volume. The
|
||||
# secret has to be in the backup just as much as the volume is.
|
||||
# (b) A BACKUP OF THE VOLUME MAY CONTAIN A SECRET IN CLEARTEXT. If anybody
|
||||
# has ever used the admin dashboard's OAuth setup,
|
||||
# app/api/admin/oauth/setup/route.ts has written oauthClientSecret as
|
||||
# PLAINTEXT into config.json on the PVC. So the volume backup is not
|
||||
# merely useless without the vault - it may itself need the protection
|
||||
# the vault gets.
|
||||
#
|
||||
# WHY admin-password IS SET AT ALL, and the reason is NOT the one the issue
|
||||
# originally gave. Measured: without ADMIN_PASSWORD, Bulwark does NOT generate
|
||||
# a random password and writes NOTHING to stdout - lib/admin/password.ts
|
||||
# simply logs "Admin dashboard disabled (no ADMIN_PASSWORD set)" and the
|
||||
# dashboard stays off. (The only random value in the project is the bootstrap
|
||||
# setup token, which is written into the state directory and never logged.) So
|
||||
# there is no leak into the pod log here and the analogy to stalwart's
|
||||
# STALWART_RECOVERY_ADMIN (#65) does not hold.
|
||||
#
|
||||
# The reason it is set is point 1 at the top of this file combined with
|
||||
# bulwark.oauth.only: THE ADMIN DASHBOARD IS THE ONLY WAY BACK OUT OF AN
|
||||
# OIDC LOCKOUT. Switching it off means taking away the door.
|
||||
#
|
||||
# THE FIELD IS CONSUMED EXACTLY ONCE, which is the part that surprises people
|
||||
# later: initAdminPassword() returns as soon as admin.json exists on the PVC
|
||||
# and never looks at the variable again; on the first start it hashes it with
|
||||
# scrypt and persists the hash. ROTATING THE VAULT FIELD AFTERWARDS HAS NO
|
||||
# EFFECT. A reset goes through the dashboard itself, or by deleting admin.json
|
||||
# from the PVC. Same cause as point 1: the volume beats the chart.
|
||||
appSecretRef: null
|
||||
|
||||
# op:// path of the 1Password item carrying the OIDC CLIENT SECRET.
|
||||
# Required while bulwark.oauth.enabled is true (the default).
|
||||
#
|
||||
# Must carry the field:
|
||||
# oauth-client-secret -> mounted as a file, read via OAUTH_CLIENT_SECRET_FILE
|
||||
#
|
||||
# A SEPARATE ITEM FROM appSecretRef, BY AUDIENCE: this value is not ours, it
|
||||
# is created by the identity provider when the client is registered (keycloak,
|
||||
# #83), it is rotated there, and it is the one of the three that another
|
||||
# system also knows. Two items mean it can be repointed and rotated without
|
||||
# touching this release's own secrets. Same reasoning as garage's split
|
||||
# between its RPC secret and its S3 key.
|
||||
#
|
||||
# ROTATION NEEDS NO RESTART - with one condition the chart satisfies.
|
||||
# getGlobalClientSecret() (lib/oauth/token-exchange.ts) calls readFileEnv on
|
||||
# EVERY token exchange with no cache, so a new value in the vault is live as
|
||||
# soon as the operator has written it. The condition is the whole-directory
|
||||
# mount of point 3; a subPath mount would never be refreshed.
|
||||
#
|
||||
# AND THE ONE WAY A ROTATION STILL DOES NOTHING, which is point 1 again and is
|
||||
# why this is not filed under "rotation just works": getGlobalClientSecret
|
||||
# checks the ADMIN OVERRIDE FIRST. If the admin dashboard's OAuth setup has
|
||||
# ever run, config.json on the PVC carries oauthClientSecret in plaintext and
|
||||
# that value wins over this file - forever, and with no error. The repair is
|
||||
# the same as for every other override: remove it in the dashboard (see
|
||||
# bulwark.oauth.only).
|
||||
#
|
||||
# #81 DOES NOT APPLY HERE, and it was checked rather than assumed, because it
|
||||
# is exactly what forced garage onto an environment variable: lib/read-file-env.ts
|
||||
# is readFileSync plus try/catch and nothing else - no statSync, no
|
||||
# `mode & 0o077` permission check. The group-read bit the kubelet ORs onto
|
||||
# every file of a secret volume once fsGroup is set is therefore harmless, and
|
||||
# the file form is load-bearing rather than merely available.
|
||||
oauthSecretRef: null
|
||||
|
||||
bulwark:
|
||||
# ONE, and the schema caps it there. The reason is this chart's single
|
||||
# ReadWriteOnce PVC, not anything about the application: two pods cannot both
|
||||
# mount it, so a second replica would sit in ContainerCreating forever. An RWX
|
||||
# volume would not help either - nothing in Bulwark coordinates two processes
|
||||
# over that directory, and the admin config is read into memory once at start
|
||||
# (configManager.load() in instrumentation.node.ts), so two pods would hold
|
||||
# two divergent views of it.
|
||||
#
|
||||
# An explicit 0 is allowed and is the per-workload way to stop the release;
|
||||
# the chart-global scaleDown flag does the same for everything at once.
|
||||
replicas: 1
|
||||
|
||||
# THE JMAP SERVER - the stalwart release this client talks to. REQUIRED, and
|
||||
# it does two things at once, which is why it has no default.
|
||||
#
|
||||
# (a) it is the address of the mail server. Without it there is no mail.
|
||||
# (b) IT SWITCHES OFF THE FIRST-RUN SETUP WIZARD. detectSetupState()
|
||||
# (lib/setup/state.ts) returns "env-managed" as soon as this variable is
|
||||
# non-empty, and in that state /setup is not served at all. That is what
|
||||
# makes this chart declarative instead of "install, then click through a
|
||||
# wizard whose result lands on a volume".
|
||||
#
|
||||
# An in-cluster stalwart release is reached at its http Service, e.g.
|
||||
# http://<stalwart-release>-stalwart-<group>-http.<namespace>.svc.cluster.local:8080
|
||||
# A published one is reached at its public https URL. Either works - this is
|
||||
# server-to-server, the browser never sees it.
|
||||
#
|
||||
# THE TRAP, AND IT IS THE ONE THE UPSTREAM DOCUMENTATION ACTUALLY WARNS ABOUT:
|
||||
# a server URL saved in the admin dashboard WINS over this value (point 1 at
|
||||
# the top). If the dashboard has ever been used to set it, changing this value
|
||||
# and running `helm upgrade` moves nothing, succeeds, and says nothing. Read
|
||||
# the effective value back off the running app rather than off the chart:
|
||||
# kubectl -n <ns> exec <pod> -- cat /app/data/admin/config.json
|
||||
# An absent jmapServerUrl key in that file means this value is in force.
|
||||
jmapServerUrl: null
|
||||
|
||||
# The product name in the UI and the PWA. null leaves upstream's "Webmail".
|
||||
appName: null
|
||||
|
||||
# STALWART_FEATURES - the Stalwart-specific parts of the UI (password change,
|
||||
# Sieve rule management). True, which is upstream's default and is the point
|
||||
# of pairing this chart with stalwart at all.
|
||||
#
|
||||
# Note what it does NOT gate: the server-side credential-bearing passthrough
|
||||
# at /api/account/stalwart/jmap has its own switch upstream
|
||||
# (STALWART_JMAP_PASSTHROUGH_ENABLED, default true). This chart does not render
|
||||
# it; an installation that wants the UI features but not the passthrough sets
|
||||
# it through bulwark.env and should read the precedence note on that value
|
||||
# first.
|
||||
stalwartFeatures: true
|
||||
|
||||
# WHO MAY OPEN THE ADMIN DASHBOARD, and this value matters more here than its
|
||||
# name suggests, because after bulwark.oauth.only the dashboard is the ONLY
|
||||
# door back into a broken login path.
|
||||
#
|
||||
# Upstream's three modes (STALWART_ADMIN_ACCESS):
|
||||
# auto a Stalwart admin sees the shield and is signed into /admin
|
||||
# WITHOUT the Bulwark admin password. Upstream's default and the
|
||||
# legacy behaviour.
|
||||
# password a Stalwart admin sees the shield but must enter the Bulwark
|
||||
# admin password like everybody else.
|
||||
# off Stalwart admin status is ignored; /admin is reachable only via
|
||||
# /admin/login with the Bulwark admin password.
|
||||
#
|
||||
# "auto", i.e. upstream's default, is kept DELIBERATELY and the consequence is
|
||||
# stated rather than left to be discovered: with it, anybody who is an admin
|
||||
# on the stalwart side can write config overrides here - and an override is
|
||||
# exactly the thing that can disconnect this chart from its own values
|
||||
# (point 1) or lock the login path (bulwark.oauth.only). That is usually the
|
||||
# same person, which is why it is not changed; if it is not, "password"
|
||||
# narrows the door to whoever holds the vault item, and "off" also removes the
|
||||
# shield from the UI. Both require admin-password to be configured - which
|
||||
# this chart always does - or the dashboard would become unreachable.
|
||||
stalwartAdminAccess: auto
|
||||
|
||||
# SETTINGS_SYNC_ENABLED - per-account user preferences follow the user between
|
||||
# browsers instead of living in one browser's local storage.
|
||||
#
|
||||
# ON. The consequence is that /app/data/settings on the PVC carries USER DATA,
|
||||
# encrypted with the session secret - which is where the two-part backup
|
||||
# statement at secrets.appSecretRef comes from. It also means the secret is
|
||||
# mandatory rather than recommended: without it this switch is ignored,
|
||||
# silently.
|
||||
settingsSync: true
|
||||
|
||||
# ADMIN_CONFIG_READONLY - refuse every write to the admin CONFIG directory at
|
||||
# the application layer, which makes the values in this file authoritative and
|
||||
# closes the precedence trap of point 1 for good: with no override able to
|
||||
# appear, bulwark.oauth.only (and every other value here) always takes effect.
|
||||
#
|
||||
# OFF BY DEFAULT, AND THE PRICE IS WHY. It is not one cost but three, and the
|
||||
# third was measured rather than inferred - it is bigger than the plan for
|
||||
# this chart assumed:
|
||||
#
|
||||
# (a) POLICY, THEMES AND BRANDING FREEZE TOO. assertWritable
|
||||
# (lib/admin/paths.ts) guards setPolicy, markSetupComplete, the plugin
|
||||
# and theme registries and the branding upload - not just the config
|
||||
# override. So the settings policy becomes unchangeable, and with it the
|
||||
# ONLY way to point Web Push at your own relay (see bulwark.pushRelay).
|
||||
# (b) AN OVERRIDE THAT ALREADY EXISTS IS FROZEN IN PLACE, not removed.
|
||||
# removeAdminOverride is guarded by the same assertWritable. Turning this
|
||||
# on while config.json already carries `oauthOnly: true` therefore makes
|
||||
# that lockout permanent and takes away the documented repair at the same
|
||||
# time. If you are turning this on, read config.json first.
|
||||
# (c) ON A FRESH INSTALL IT TAKES THE ADMIN DASHBOARD AWAY ENTIRELY - i.e.
|
||||
# exactly the door this chart keeps a password for. initAdminPassword
|
||||
# persists the hashed ADMIN_PASSWORD by calling writeConfigData, which
|
||||
# begins with assertWritable('save admin password'). With no admin.json
|
||||
# on the volume yet, that throws: the startup hook logs "Admin dashboard
|
||||
# init skipped", the retry inside /api/admin/auth throws again, and the
|
||||
# login endpoint answers HTTP 500. The pod is Ready and serving mail the
|
||||
# whole time, so nothing reports it.
|
||||
#
|
||||
# THE ONLY SAFE SEQUENCE, which is also upstream's own framing ("Operators set
|
||||
# ADMIN_CONFIG_READONLY=true after running the setup wizard"):
|
||||
# 1. install with this false, let the pod start once, so admin.json exists
|
||||
# 2. set every policy you want (push relay included) - after step 3 you
|
||||
# cannot
|
||||
# 3. check that config.json carries no override you still need removed
|
||||
# 4. then helm upgrade with this true
|
||||
# The chart cannot verify step 1 for you: it has no way to know what is on the
|
||||
# volume. NOTES.txt repeats the sequence.
|
||||
adminConfigReadOnly: false
|
||||
|
||||
oauth:
|
||||
# OIDC SIGN-IN against the identity provider (keycloak, #83). ON.
|
||||
enabled: true
|
||||
|
||||
# OAUTH_ONLY - hide the password form entirely, so the identity provider is
|
||||
# the only way for a USER to sign in. ON, and this is the chart's riskiest
|
||||
# default, taken deliberately with the way back out written down.
|
||||
#
|
||||
# THE RISK: if the provider is down or the OIDC configuration is wrong,
|
||||
# nobody reaches their mail, and there is no password form to fall back to.
|
||||
#
|
||||
# THE WAY BACK, AND IT IS NOT WHAT IT LOOKS LIKE. `helm upgrade` with this
|
||||
# set to false is NOT reliably the repair, and believing it was would be the
|
||||
# trap of point 1 sitting inside the rescue instruction itself: oauthOnly is
|
||||
# an ordinary key of CONFIG_ENV_MAP, every consumer reads it through
|
||||
# configManager.get (app/api/auth/session/route.ts,
|
||||
# app/api/auth/verify/route.ts, app/api/config/route.ts - none of them reads
|
||||
# process.env), and the admin dashboard's OAuth setup writes
|
||||
# `oauthOnly: true` into config.json when it is used. Once that override is
|
||||
# on the PVC, this value is ignored and the upgrade reports success.
|
||||
#
|
||||
# WHAT REALLY WORKS, and it does not depend on the identity provider:
|
||||
# OPEN THE ADMIN DASHBOARD (its own password, from the vault item) AND
|
||||
# REMOVE THE OVERRIDE - the dashboard's revert, i.e. DELETE on
|
||||
# /api/admin/config, which calls configManager.removeAdminOverride.
|
||||
# After that the chart's value applies again. If the dashboard itself is
|
||||
# unreachable, the remaining repair is editing or deleting config.json on
|
||||
# the PVC directly. Both are in NOTES.txt.
|
||||
#
|
||||
# AND THE PRE-INSTALL STEP THIS DEFAULT MAKES MANDATORY: the OIDC client
|
||||
# must be registered with the provider BEFORE the first install, not after.
|
||||
# With oauth.only true and no working client, the very first sign-in is
|
||||
# impossible. NOTES.txt prints the redirect URI to register.
|
||||
only: true
|
||||
|
||||
# OAUTH_CLIENT_ID. Required while oauth.enabled is true.
|
||||
clientId: null
|
||||
|
||||
# OAUTH_ISSUER_URL - the issuer, from which Bulwark fetches the discovery
|
||||
# document. Required while oauth.enabled is true. For keycloak (#83) this is
|
||||
# https://<keycloak-host>/realms/<realm>
|
||||
#
|
||||
# It must be reachable BY THE POD and the endpoints it advertises must share
|
||||
# the issuer's origin or be public: getDiscoveryValidator
|
||||
# (lib/oauth/token-exchange.ts) rejects a discovered endpoint on a private
|
||||
# address from a different origin unless OAUTH_ALLOW_PRIVATE_ENDPOINTS is
|
||||
# set. The chart does not render that escape hatch - a split-horizon setup
|
||||
# that needs it can add it through bulwark.env.
|
||||
issuerUrl: null
|
||||
|
||||
# OAUTH_SCOPES - replaces the scope list Bulwark would otherwise request.
|
||||
# null leaves upstream's default, which is the right starting point; an
|
||||
# incomplete list here is a login that succeeds and then has no identity to
|
||||
# work with.
|
||||
scopes: null
|
||||
|
||||
# OAUTH_END_SESSION - signing out of Bulwark also ends the provider's
|
||||
# session through its end_session_endpoint. True, upstream's default: on a
|
||||
# shared browser, a sign-out that leaves the provider signed in means the
|
||||
# next click is straight back in. Set it false where the provider is shared
|
||||
# with applications that should stay signed in.
|
||||
endSession: true
|
||||
|
||||
# WEB PUSH RELAY - read this before writing it down as a disclosure, because
|
||||
# the obvious reading of the build-time variable is wrong and was corrected
|
||||
# against the source.
|
||||
#
|
||||
# NEXT_PUBLIC_PUSH_RELAY_URL is baked into the bundle (point 4 at the top), so
|
||||
# this chart cannot move the BUILT-IN entry, which is upstream's hosted relay
|
||||
# at https://notifications.relay.bulwarkmail.org. It would be wrong to stop
|
||||
# there and declare Web Push "not changeable with the published image": that
|
||||
# variable only sets the FALLBACK. lib/push-relays.ts resolves the effective
|
||||
# relay as `resolveDefaultRelayUrl(policy)`, which takes policy.pushRelayUrl
|
||||
# whenever it is a valid URL and uses the built-in one only otherwise; with
|
||||
# policy.pushRelayUrlLocked an operator can additionally pin every user to it.
|
||||
#
|
||||
# So the correct, two-part statement, and both halves matter:
|
||||
# - WITHOUT further action, Web Push for this release goes through the
|
||||
# VENDOR'S relay. For a mail client that is a fact worth knowing, and it
|
||||
# stays in the users' relay list either way, because the list is built
|
||||
# from the bundle.
|
||||
# - A SELF-HOSTED RELAY CAN BE MADE THE EFFECTIVE DEFAULT AND MANDATORY -
|
||||
# but through policy.json, i.e. the ADMIN DASHBOARD, not through a chart
|
||||
# value. There is deliberately no value here: pushRelayUrl lives in
|
||||
# SettingsPolicy and has no entry in CONFIG_ENV_MAP, so no environment
|
||||
# variable reaches it and a chart value would be a switch wired to
|
||||
# nothing.
|
||||
#
|
||||
# AND THE INTERACTION TO KEEP IN MIND: bulwark.adminConfigReadOnly freezes
|
||||
# setPolicy, so turning that on before setting the relay makes this change
|
||||
# impossible afterwards. Order matters - see the sequence at that value.
|
||||
#
|
||||
# THERE IS DELIBERATELY NO VALUE UNDER THIS COMMENT. A `pushRelayUrl:` key
|
||||
# here would render nothing and change nothing, which is the exact defect
|
||||
# guideline 12 exists to remove - a key that looks like configuration and does
|
||||
# nothing. The knowledge belongs in the file an operator reads; the switch
|
||||
# belongs where it actually is, in the dashboard.
|
||||
|
||||
# BULWARK_TELEMETRY - upstream's anonymous telemetry.
|
||||
#
|
||||
# FALSE, AND THAT IS NOT MERELY RESTATING THE IMAGE'S DEFAULT, which is the
|
||||
# reason this value exists at all. Measured in lib/telemetry/state.ts: consent
|
||||
# defaults to 'off' and telemetry really is opt-in (the comment in
|
||||
# instrumentation.node.ts calling it "on by default" is wrong - DEFAULTS carries
|
||||
# `consent: 'off'`). What rendering the variable buys is the LOCK:
|
||||
# effectiveConsent() takes the environment over the state file, and upstream
|
||||
# blocks the admin UI from changing it while the override is active ("UI changes
|
||||
# are blocked when env override is active"). So this is one of the very few
|
||||
# settings in this chart where the chart beats the volume rather than the other
|
||||
# way round - see the exception list in point 1 at the top of this file.
|
||||
#
|
||||
# Setting it true opts in; /app/data/telemetry on the PVC then holds the
|
||||
# consent record and the instance id, which is why that directory is inside the
|
||||
# single volume rather than an emptyDir.
|
||||
telemetry: false
|
||||
|
||||
# BULWARK_UPDATE_CHECK - an hourly version check against
|
||||
# version.telemetry.bulwarkmail.org.
|
||||
#
|
||||
# FALSE, AND HERE THE DEFAULT REALLY IS THE OTHER WAY: unlike telemetry, this
|
||||
# check is ON unless switched off (lib/version-check/state.ts, disabledByEnv()
|
||||
# recognizes only off/false/0/no - an absent variable means enabled). So this
|
||||
# value does change behaviour.
|
||||
#
|
||||
# Off for two reasons: a mail client should not phone home from a private
|
||||
# cluster unless somebody chose it, and Renovate already watches the image tag
|
||||
# (renovate.json5) - so the check would answer outbound a question that is
|
||||
# already answered inbound, and answer it to a third party.
|
||||
updateCheck: false
|
||||
|
||||
# LOG_LEVEL - error, warn, info or debug.
|
||||
logLevel: info
|
||||
|
||||
# LOG_FORMAT - text or json. JSON here, against upstream's default of text,
|
||||
# because the logs go to a cluster log collector rather than to a terminal.
|
||||
logFormat: json
|
||||
|
||||
# The HTTP port. 3000, which is what the image binds (ENV PORT=3000,
|
||||
# EXPOSE 3000) and what HOSTNAME=0.0.0.0 makes reachable. Both the Service and
|
||||
# all three probes address this port BY NAME ("http"), so changing the number
|
||||
# here is safe and renaming the port is not.
|
||||
port: 3000
|
||||
|
||||
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 - CPU is compressible, so
|
||||
# leaving it unlimited throttles instead of OOM-killing.
|
||||
#
|
||||
# 256Mi x 3 = 768Mi. A Next.js standalone server serving a SPA and proxying
|
||||
# JMAP holds no mail and caches no mailbox, so the footprint is the Node
|
||||
# heap plus the image-optimization cache - not a function of how much mail
|
||||
# the users have.
|
||||
#
|
||||
# THE MEMORY LIMIT IS LOAD-BEARING IN ONE DIRECTION, though: /api/health
|
||||
# reports `degraded` once V8 heap usage crosses its warning threshold, and
|
||||
# the heap limit is derived from the container limit. That report is not
|
||||
# visible to the probes (see bulwark.readinessProbe), so a limit set too low
|
||||
# produces a pod that is permanently degraded, permanently Ready, and says
|
||||
# so only to an authenticated caller asking for details.
|
||||
#
|
||||
# The ephemeral-storage request covers the two emptyDirs (/tmp and the
|
||||
# Next.js cache) plus container logs. It does NOT cover /app/data, which is
|
||||
# the PVC - see storage.amount.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 64Mi
|
||||
|
||||
# ALL THREE PROBES ARE AN HTTP GET ON /api/health, and the limit of that is
|
||||
# written at the readiness probe below rather than left for somebody to assume.
|
||||
#
|
||||
# A NOTE ON GET VERSUS HEAD, so nobody "fixes" this back. Bulwark offers a HEAD
|
||||
# handler on this path and it would be the cheaper call - but a KUBERNETES PROBE
|
||||
# CANNOT SEND HEAD. The API type is HTTPGetAction, it has no method field, and
|
||||
# the kubelet always issues a GET. The only way to send a HEAD is an exec probe
|
||||
# calling an HTTP client, and this image has neither curl nor wget (npm and npx
|
||||
# are removed from the runtime layer), so it would mean starting a Node process
|
||||
# every few seconds instead of one cheap HTTP request. The GET handler does
|
||||
# gather diagnostics, which makes it marginally MORE capable than HEAD, not
|
||||
# less: it can answer 503 when the diagnostics themselves throw.
|
||||
#
|
||||
# LIVENESS: the process is alive, accepting HTTP, its event loop is free and
|
||||
# Next.js can route. An OOM kill, a crash and a wedged event loop all fail it.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# READINESS: the same endpoint, and HERE IS THE LIMIT, stated at the value
|
||||
# because a probe path is not a promise (README guideline 12.3; #218 and
|
||||
# Gitea #83 are the two times this repo paid for assuming otherwise).
|
||||
#
|
||||
# WHAT THIS PROBE CARRIES: the Node process is alive, the HTTP server accepts,
|
||||
# the event loop is not blocked, Next.js routes. That is real and it is not
|
||||
# nothing.
|
||||
#
|
||||
# WHAT IT DOES NOT CARRY, measured in app/api/health/route.ts at tag 1.12.0:
|
||||
# - THE REPORTED STATE. The success path returns a hard `status: 200`
|
||||
# regardless of what it computed, and for an unauthenticated caller the
|
||||
# body is hard-coded to "healthy" as well
|
||||
# (`status: detailed ? status : 'healthy'`); "degraded" appears only with
|
||||
# ?detailed=true AND a caller that passes canSeeInstanceDetails. Upstream
|
||||
# tests precisely this - lib/__tests__/health-route.test.ts expects HTTP
|
||||
# 200 while payload.status is "degraded". A probe is unauthenticated and
|
||||
# asks for no details, so it sees 200 and "healthy" always. The only 503
|
||||
# this endpoint can produce comes from its catch block, i.e. when
|
||||
# process.memoryUsage() or v8.getHeapStatistics() itself throws.
|
||||
# - ANYTHING ABOUT STALWART. Bulwark holds no mail and this endpoint asks the
|
||||
# JMAP server nothing. WITH THE MAIL SERVER COMPLETELY GONE, THIS PROBE IS
|
||||
# GREEN AND THE POD STAYS Ready. Monitor the stalwart release for that;
|
||||
# nothing here can.
|
||||
#
|
||||
# AND WHY IT IS STILL THIS ENDPOINT: there is no better one.
|
||||
# /api/setup/test-jmap really would test the connection, but it lives behind
|
||||
# the bootstrap setup token and this chart disables the wizard, so it is
|
||||
# unreachable. Pointing readiness at "/" instead would only add Next.js
|
||||
# rendering to the same set of guarantees, and would start failing on an
|
||||
# unauthenticated redirect rather than on anything about the mail path.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# STARTUP: gates the other two, which is why neither carries an
|
||||
# initialDelaySeconds.
|
||||
#
|
||||
# BUDGET = periodSeconds x failureThreshold = 5 x 24 = 120s. What that has to
|
||||
# cover is not the Next.js server, which starts in about a second: it is
|
||||
# attaching and mounting the network volume, and the instrumentation hook that
|
||||
# runs before the first request is served (instrumentation.node.ts migrates
|
||||
# the legacy admin layout, loads config.json and policy.json off the volume,
|
||||
# and on a FIRST install hashes ADMIN_PASSWORD with scrypt and writes
|
||||
# admin.json - the scrypt hash being the one deliberately slow step).
|
||||
#
|
||||
# NOT MEASURED AGAINST A RUNNING POD AT THE TIME OF WRITING, and said so
|
||||
# rather than presented as calibrated: the budget is derived from what the
|
||||
# start does, with head room, and the cost of head room is nothing because a
|
||||
# pod that IS healthy becomes Ready on the first probe either way.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 24
|
||||
|
||||
securityContext:
|
||||
pod:
|
||||
runAsNonRoot: true
|
||||
# uid/gid 1001, NOT this repo's 20000, and that is the image's own user
|
||||
# rather than a free choice: the Dockerfile creates
|
||||
# addgroup --system --gid 1001 nodejs
|
||||
# adduser --system --uid 1001 nextjs
|
||||
# and copies the application tree plus /app/data with --chown=nextjs:nodejs.
|
||||
# 1001 therefore OWNS everything the process reads, and the four data
|
||||
# subdirectories are chowned to it in the image. Read off the Dockerfile at
|
||||
# tag 1.12.0, not guessed.
|
||||
#
|
||||
# fsGroupChangePolicy: Always, the repo standard, and here it is the right
|
||||
# one rather than the default one: /app/data holds a handful of JSON files,
|
||||
# an audit log and a small settings tree, so the recursive chown is
|
||||
# cheap - unlike garage's block store or satisfactory's game files, where
|
||||
# it is the reason both deviate to OnRootMismatch. It also matters on the
|
||||
# FIRST start, because a freshly provisioned volume arrives owned by root
|
||||
# and Bulwark has to create its four subdirectories in it.
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
fsGroupChangePolicy: Always
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
# TRUE, with two emptyDirs carrying the writes that are not state. Which
|
||||
# paths those are, and how the set was established, is at
|
||||
# bulwark.writableCache below - it is the one part of this chart that was
|
||||
# derived from the source and then PROVEN by the install-test rather than
|
||||
# the other way round.
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# EMPTY, and it can stay empty: the process binds 3000, which is above
|
||||
# 1024, and the entrypoint is `node server.js` with no file
|
||||
# capabilities - so there is no execve-time capability check like
|
||||
# stalwart's.
|
||||
add: []
|
||||
|
||||
# THE WRITABLE PATHS OUTSIDE THE VOLUME, as emptyDirs, because
|
||||
# readOnlyRootFilesystem is true and Next.js writes in two places that are not
|
||||
# application state.
|
||||
#
|
||||
# HOW THIS SET WAS ESTABLISHED, and the honest order of events: it was DERIVED
|
||||
# from next.config.ts at tag 1.12.0 - `output: "standalone"`, and
|
||||
# `images: { unoptimized: true }` applies only in the isLite branch, so the
|
||||
# published image has image optimization ACTIVE and writes its cache under
|
||||
# /app/.next/cache - and it was then PROVEN by the install-test, which starts
|
||||
# the real image with this exact hardening and requires the pod to become
|
||||
# Ready. A missing writable path here is not a subtle defect: it is an EACCES
|
||||
# or EROFS at the first write.
|
||||
#
|
||||
# /tmp the conventional scratch path. Node, Next.js and the
|
||||
# image-optimization pipeline all use it.
|
||||
# /app/.next/cache Next.js's own cache directory in standalone mode: the
|
||||
# optimized-image cache and the fetch cache. It is a
|
||||
# SUBDIRECTORY of .next, which is why it can be an
|
||||
# emptyDir at all - /app/.next/static holds the build
|
||||
# output and must stay readable, and mounting the cache
|
||||
# path does not shadow it.
|
||||
#
|
||||
# Both are CACHES and neither is state: losing them on a restart costs one
|
||||
# round of image re-optimization. Do not "simplify" either one onto the PVC -
|
||||
# a cache on a ReadWriteOnce network volume is slower than regenerating it,
|
||||
# and it would grow the only volume that has to be backed up.
|
||||
writableCache:
|
||||
# Size limits on both, so a runaway cache fills an emptyDir instead of the
|
||||
# node's disk. These are the sizeLimit of each emptyDir; the pod's
|
||||
# ephemeral-storage request/limit (bulwark.resources) is the other half of
|
||||
# the same accounting and the two should move together.
|
||||
tmpSizeLimit: 256Mi
|
||||
nextCacheSizeLimit: 512Mi
|
||||
|
||||
# Additive environment entries for the Bulwark container, rendered through tpl
|
||||
# and supporting the value, secretKeyRef and configMapKeyRef forms.
|
||||
#
|
||||
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
|
||||
# does not replace it. At install time both reach the pod and the later one
|
||||
# wins; on an EXISTING release the pair collapses to the chart's own value,
|
||||
# because Kubernetes merges env by name - and helm upgrade still reports
|
||||
# success. So do not use this to override JMAP_SERVER_URL, OAUTH_ONLY or
|
||||
# SESSION_SECRET_FILE; change the values that produce them.
|
||||
#
|
||||
# AND THE SECOND, SHARPER CAVEAT, SPECIFIC TO THIS APPLICATION: an environment
|
||||
# variable is only the MIDDLE of three layers (point 1 at the top of this
|
||||
# file). Anything set here is a default that an admin override in config.json
|
||||
# on the PVC may already be beating, silently. An entry here is therefore not
|
||||
# a guarantee - unless bulwark.adminConfigReadOnly is on, and that has its own
|
||||
# price.
|
||||
#
|
||||
# Genuinely useful here, all of them CONFIG_ENV_MAP keys this chart does not
|
||||
# render: APP_DESCRIPTION, LOGIN_COMPANY_NAME and the other LOGIN_* branding
|
||||
# variables, COOKIE_SAME_SITE, STALWART_JMAP_PASSTHROUGH_ENABLED,
|
||||
# OAUTH_EXTRA_SCOPES, WOPI_CLIENT_URL.
|
||||
env: []
|
||||
|
||||
storage:
|
||||
# THE ONE PVC, mounted at /app/data, and "one" is the decision rather than the
|
||||
# obvious shape - Bulwark keeps FOUR directories under it and they could each
|
||||
# have their own volume:
|
||||
#
|
||||
# /app/data/admin config.json, policy.json, admin.json (the password
|
||||
# hash), plugins, themes, branding uploads
|
||||
# /app/data/admin-state admin-state.json (login timestamps), audit.log, the
|
||||
# bootstrap setup token
|
||||
# /app/data/settings the encrypted per-account user settings
|
||||
# /app/data/telemetry consent and instance id
|
||||
#
|
||||
# ONE VOLUME, BECAUSE A SNAPSHOT IS ATOMIC PER VOLUME AND NEVER ACROSS THEM -
|
||||
# the lesson garage (#81) was built on. Split over four, a backup could
|
||||
# capture an audit log that records logins the configuration does not know
|
||||
# about yet, or settings encrypted against a secret the config has since been
|
||||
# repointed from. One volume keeps configuration, runtime state, audit trail
|
||||
# and user settings consistent with each other, and there is no performance
|
||||
# argument on the other side: this is a few hundred kilobytes of JSON plus an
|
||||
# append-only log.
|
||||
#
|
||||
# Bulwark creates all four subdirectories itself on start (ensureConfigDir /
|
||||
# ensureStateDir, both mkdir -p), which it has to, because the image's own
|
||||
# mkdir ran in a layer that this mount covers.
|
||||
#
|
||||
# THE PVC IS DELIBERATELY NOT null-SAFE: an erased size yields an invalid PVC
|
||||
# that is rejected at apply time, which is loud. A chart-side fallback would
|
||||
# silently size somebody's volume - and on an existing release it could not
|
||||
# help anyway, because a PVC cannot shrink. Same reasoning as garage's storage
|
||||
# block and satisfactory's documented exception (Gitea #39).
|
||||
#
|
||||
# IT IS ALSO NEVER RENAMED: a renamed PVC makes Kubernetes provision a new,
|
||||
# empty volume and orphans the data - including admin.json, which is the only
|
||||
# copy of the admin password hash, and the audit log, which by its nature
|
||||
# cannot be reconstructed.
|
||||
#
|
||||
# 1Gi is generous for the JSON files and the settings tree; what actually
|
||||
# grows is audit.log, and it grows with admin activity rather than with mail
|
||||
# volume. There is no rotation for it in the application, so this is the value
|
||||
# to watch on a long-lived release.
|
||||
amount: 1Gi
|
||||
|
||||
# null uses the cluster's default StorageClass. ReadWriteOnce is enough and is
|
||||
# what the single replica needs; see bulwark.replicas for why more than one is
|
||||
# not on offer.
|
||||
storageClass: null
|
||||
|
||||
ingress:
|
||||
# ON by default: a webmail client nobody can reach is not a webmail client.
|
||||
enabled: true
|
||||
|
||||
# The public hostname. REQUIRED, AND REQUIRED EVEN WITH ingress.enabled:
|
||||
# false - this is guideline 12.1, checked in both directions for this chart.
|
||||
# The value is read a SECOND time outside the Ingress template: NOTES.txt
|
||||
# derives the OIDC redirect URI from it, and that URI is what has to be
|
||||
# registered with the identity provider before the first install. Gating it
|
||||
# away would produce a release that renders cleanly and whose operator is told
|
||||
# to register `https:///auth/callback`.
|
||||
#
|
||||
# BULWARK IS SERVED AT THE ROOT OF THIS HOST AND CANNOT BE SERVED UNDER A
|
||||
# PATH. NEXT_PUBLIC_BASE_PATH is compiled into the bundle (point 4 at the top
|
||||
# of this file), so a sub-path deployment would need a rebuilt image. The
|
||||
# Ingress rule is therefore "/" and there is no value to change it - a
|
||||
# rewrite-target annotation would not help either, because the bundle's own
|
||||
# asset URLs are built for the root.
|
||||
domain: null
|
||||
|
||||
# cert-manager cluster issuer. Required while the Ingress is enabled.
|
||||
clusterIssuer: null
|
||||
className: nginx
|
||||
|
||||
# Extra annotations, merged with the cert-manager annotation the chart always
|
||||
# sets.
|
||||
#
|
||||
# ONE WORTH KNOWING ABOUT FOR THIS APPLICATION: file attachments and the Files
|
||||
# view upload through this Ingress, and nginx's default
|
||||
# proxy-body-size (1m) will reject anything larger with a 413 that looks like
|
||||
# an application fault. Set
|
||||
# nginx.ingress.kubernetes.io/proxy-body-size: "25m"
|
||||
# (or whatever the mail server accepts) to match stalwart's own limit. The
|
||||
# chart does not set it, because the right number is the mail server's and
|
||||
# this chart does not know it.
|
||||
annotations: {}
|
||||
@@ -0,0 +1,69 @@
|
||||
# Fixtures for the bulwark install-test: the two Secrets the 1Password operator
|
||||
# would otherwise materialize from the OnePasswordItems the chart renders
|
||||
# (release name: ci, stub CRD in ci/common/), plus a name for the JMAP endpoint
|
||||
# to resolve to.
|
||||
#
|
||||
# NO DATABASE, NO CACHE, NO BROKER AND NO MAIL SERVER - and that is a property of
|
||||
# the chart rather than a shortcut taken here. Bulwark is a client: it holds no
|
||||
# mail and contacts the JMAP server per request, not at boot, so it starts and
|
||||
# becomes Ready with nothing behind that URL. The flip side is the limitation the
|
||||
# chart documents at bulwark.readinessProbe, and this file is the clearest
|
||||
# illustration of it: everything needed to make this chart green is in these few
|
||||
# objects, and none of it is a mail server.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-bulwark-app-secret
|
||||
stringData:
|
||||
# The field NAMES are what matter here, not the values: the operator
|
||||
# materializes one Secret key per item field, and the chart consumes these two
|
||||
# in DIFFERENT ways - session-secret as a projected FILE, admin-password
|
||||
# through a secretKeyRef.
|
||||
#
|
||||
# Only the second of those is actually asserted by the run. The projected
|
||||
# volume is `optional: true` by design (so a first install does not hang
|
||||
# waiting for the 1Password operator), so a misspelled session-secret yields a
|
||||
# missing file and a pod that starts fine. A misspelled admin-password leaves
|
||||
# the container in CreateContainerConfigError and fails the run.
|
||||
#
|
||||
# A real base64 32-byte value rather than a placeholder word: Bulwark derives
|
||||
# encryption keys from it, and a value of the wrong shape would be the kind of
|
||||
# thing that works in CI and not in production.
|
||||
session-secret: Y2ktb25seS1zZXNzaW9uLXNlY3JldC1ub3QtYS1yZWFsLW9uZQ==
|
||||
# Visibly throwaway. It is hashed with scrypt into admin.json on the first
|
||||
# start and the cluster is deleted with the job; the only thing it protects is
|
||||
# a dashboard that exists for a few minutes.
|
||||
admin-password: ci-bulwark-admin-password
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-bulwark-oauth-secret
|
||||
stringData:
|
||||
# The OIDC client secret. Nothing in CI performs a token exchange - no identity
|
||||
# provider runs here - so this exists to exercise the SECOND source of the
|
||||
# chart's projected volume and the OAUTH_CLIENT_SECRET_FILE path, not the OAuth
|
||||
# flow.
|
||||
oauth-client-secret: ci-bulwark-oauth-client-secret
|
||||
---
|
||||
# The JMAP endpoint's name. A Service with NO SELECTOR and therefore no
|
||||
# endpoints: the in-cluster DNS name resolves, and a connection to it is refused.
|
||||
#
|
||||
# DELIBERATELY NOT A DEPLOYMENT. ci/run-install-test.sh waits for every fixture
|
||||
# Deployment and StatefulSet to roll out BEFORE it installs the chart, so a stub
|
||||
# web server here would be one more workload able to fail a run that is not about
|
||||
# it - and it would buy nothing, because a refused connection and a served 404
|
||||
# are indistinguishable to a chart that never contacts this address at boot.
|
||||
#
|
||||
# It exists at all so the URL in test-values.yaml is a real name rather than an
|
||||
# invented hostname, which keeps the fixture honest about what the chart does
|
||||
# with it.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: jmap-stub
|
||||
spec:
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
@@ -0,0 +1,162 @@
|
||||
# CI values for the bulwark install-test.
|
||||
#
|
||||
# WHY THIS CHART IS TESTED RATHER THAN EXCLUDED, and what a green run is worth.
|
||||
# Bulwark needs no database, no cache and no broker, and it does not need a
|
||||
# reachable JMAP server to START - the connection is made per request, not at
|
||||
# boot - so a dummy URL is enough to bring the pod up. That makes this one of
|
||||
# the cheapest install-tests in the repo and the only place where the two
|
||||
# riskiest assumptions of the chart are exercised at all. Neither is observable
|
||||
# in a render.
|
||||
#
|
||||
# WHAT A GREEN RUN PROVES:
|
||||
#
|
||||
# 1. readOnlyRootFilesystem: true HOLDS, WITH EXACTLY THE TWO emptyDirs THE
|
||||
# CHART RENDERS - /tmp and /app/.next/cache. This is the measurement point
|
||||
# the plan for Gitea #89 left open, and it is the reason this fixture
|
||||
# exists: the set of writable paths was DERIVED from next.config.ts
|
||||
# (output: "standalone", and images.unoptimized applies only in the isLite
|
||||
# branch, so image optimization is active and writes under
|
||||
# /app/.next/cache) and could not be measured without running the image.
|
||||
# A missing writable path is not subtle - it is an EROFS or EACCES on the
|
||||
# first write - so a pod that reaches Ready and stays Ready is the proof.
|
||||
# A SUPERFLUOUS emptyDir would NOT be caught here; that half stays open and
|
||||
# is named in the chart's wiki page.
|
||||
# 2. THE PROBES CAN REALLY BE SATISFIED. All three are an HTTP GET on
|
||||
# /api/health, which has to exist in the built bundle and answer 200
|
||||
# unauthenticated. If it did not, the startup probe would exhaust its budget
|
||||
# and the rollout would fail - and no render could have told us, because a
|
||||
# path only exists in the built bundle.
|
||||
# 3. The uid/gid 1001 read off the Dockerfile is right, and fsGroup makes a
|
||||
# freshly provisioned volume writable for it: Bulwark creates its four
|
||||
# subdirectories under /app/data itself on start, and the image's own mkdir
|
||||
# ran in a layer this mount covers. Wrong uid, or no fsGroup, and the
|
||||
# instrumentation hook fails to write admin.json.
|
||||
# 4. ADMIN_PASSWORD is really consumed on the first start - the hook hashes it
|
||||
# with scrypt and writes /app/data/admin/admin.json. That write is inside
|
||||
# the startup budget, so a budget that was too short would fail here.
|
||||
# Plus the mechanical half: the projected volume assembles, the Service port
|
||||
# name matches the containerPort, the Ingress is accepted with its extra
|
||||
# annotation alongside the cert-manager one, the resource quantities are valid,
|
||||
# and the schema accepts a realistic non-default values file.
|
||||
#
|
||||
# ONE CLAIM THIS FIXTURE DELIBERATELY DOES NOT MAKE, because it would be
|
||||
# false: it does not prove that the two FILE-form secret keys are spelled
|
||||
# right. The projected volume marks both sources `optional: true` on purpose -
|
||||
# so that a first install does not hang in ContainerCreating while the
|
||||
# 1Password operator catches up - and the consequence is that a misspelled
|
||||
# session-secret or oauth-client-secret key yields a MISSING FILE and a pod
|
||||
# that starts perfectly. Only `admin-password` is proven, because it arrives
|
||||
# through a non-optional secretKeyRef and a wrong name there leaves the
|
||||
# container in CreateContainerConfigError.
|
||||
#
|
||||
# WHAT IT DOES NOT PROVE, and the first two matter most:
|
||||
# - ANYTHING ABOUT JMAP. The URL below points at a Service that answers
|
||||
# nothing useful. Bulwark comes up regardless, which is precisely the
|
||||
# property the readiness probe cannot see (chart values.yaml,
|
||||
# bulwark.readinessProbe) - so this test demonstrates the limitation rather
|
||||
# than closing it. A green run here says nothing about mail working.
|
||||
# - ANYTHING ABOUT OIDC. No identity provider runs in CI, so the sign-in path
|
||||
# is never walked. What IS checked is that the chart's render guards fire on
|
||||
# a missing client id or issuer (unit-testable at render time) and that the
|
||||
# secret file is mounted where OAUTH_CLIENT_SECRET_FILE points.
|
||||
# - THE PRECEDENCE TRAP. config.json never exists in this run, so the
|
||||
# "volume beats the chart" behaviour - the chart's single most important
|
||||
# operating caveat - is not exercised. It cannot be: it needs an admin
|
||||
# writing an override, which needs a browser.
|
||||
# - anything about an upgrade, or about a volume that already carries data.
|
||||
# This installs once, from nothing.
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/bulwark/fixtures.yaml as ci-bulwark-app-secret and
|
||||
# ci-bulwark-oauth-secret (the OnePasswordItem stub CRD comes from ci/common/).
|
||||
appSecretRef: op://ci/bulwark-app
|
||||
oauthSecretRef: op://ci/bulwark-oauth
|
||||
|
||||
bulwark:
|
||||
# A dummy JMAP endpoint. It does NOT have to answer - Bulwark connects per
|
||||
# request - but it does have to be SET, because an empty value puts the app
|
||||
# into bootstrap mode and serves the setup wizard instead. So this value also
|
||||
# exercises the path that keeps the chart declarative.
|
||||
#
|
||||
# It points at the selector-less Service in fixtures.yaml: a name that RESOLVES
|
||||
# and then refuses the connection. That is deliberately not a Deployment -
|
||||
# fixtures with a workload have to roll out before the chart is installed, so a
|
||||
# stub serving nothing would be one more thing able to fail a run that is not
|
||||
# about it. A refused connection and a served 404 are indistinguishable to this
|
||||
# chart, because nothing here contacts the JMAP server at boot.
|
||||
jmapServerUrl: http://jmap-stub.default.svc.cluster.local:8080
|
||||
|
||||
appName: Bulwark CI
|
||||
|
||||
# OIDC LEFT ON, i.e. the chart's default, although no provider answers in CI.
|
||||
# That is deliberate: it is what makes the run exercise the OAuth
|
||||
# OnePasswordItem, the second source of the projected volume and the
|
||||
# OAUTH_CLIENT_SECRET_FILE path. Bulwark does not contact the issuer at boot,
|
||||
# so an unreachable one costs nothing here - it would only fail a sign-in, and
|
||||
# nothing signs in.
|
||||
oauth:
|
||||
enabled: true
|
||||
only: true
|
||||
clientId: bulwark-ci
|
||||
issuerUrl: https://keycloak.ci.example.com/realms/ci
|
||||
|
||||
# ON, i.e. the chart's default, and it is what makes /app/data/settings get
|
||||
# created and the session secret actually read: with the sync off, a missing or
|
||||
# misnamed session-secret field would pass unnoticed.
|
||||
settingsSync: true
|
||||
|
||||
# OFF - the chart's default, spelled out because it is load-bearing for what
|
||||
# this test means. With it ON, a fresh install cannot write admin.json and the
|
||||
# admin dashboard never comes up; the pod would still be Ready, so the run
|
||||
# would be green while the chart's documented rescue door was missing. Leaving
|
||||
# it off is what makes step 4 of "what a green run proves" an assertion.
|
||||
adminConfigReadOnly: false
|
||||
|
||||
# Both off, as in the chart - and here with a second reason: a CI runner should
|
||||
# not send a telemetry heartbeat or an update check to a vendor endpoint on
|
||||
# every pull request.
|
||||
telemetry: false
|
||||
updateCheck: false
|
||||
|
||||
# debug, so that a failed run has something to read. The install-test dumps
|
||||
# container logs on failure, and Bulwark's info level says very little about
|
||||
# why a start went wrong.
|
||||
logLevel: debug
|
||||
# text rather than the chart's json: a human reads these logs out of a failed
|
||||
# job, not a collector.
|
||||
logFormat: text
|
||||
|
||||
resources:
|
||||
# Small, to stay inside the runner's budget, while still exercising the limit
|
||||
# derivation: 192Mi x limitFactor 3 = 576Mi. Not smaller than this on
|
||||
# purpose - the memory LIMIT is what V8's heap limit is derived from, and a
|
||||
# Next.js server with too little heap fails in a way that looks like an
|
||||
# application fault.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 192Mi
|
||||
ephemeral-storage: 64Mi
|
||||
|
||||
storage:
|
||||
amount: 256Mi
|
||||
# k3d's dynamic provisioner. The intended production value does not exist on
|
||||
# the runner - and a pre-bound hostPath PV would be worse than this one,
|
||||
# because the kubelet does not apply fsGroup to a hostPath volume at all, so
|
||||
# the test would silently stop exercising the ownership path that matters most
|
||||
# here: Bulwark has to CREATE four subdirectories in a volume it did not make.
|
||||
storageClass: local-path
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that the
|
||||
# object is accepted and that the Service port it names BY NAME ("http")
|
||||
# actually exists - a typo there is otherwise invisible until someone opens the
|
||||
# URL.
|
||||
enabled: true
|
||||
domain: bulwark.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
annotations:
|
||||
# Exercises the annotation merge: this has to arrive ALONGSIDE the
|
||||
# cert-manager annotation the chart always sets, not instead of it. It is
|
||||
# also the annotation this chart's NOTES.txt tells operators to add, so the
|
||||
# fixture is the shape a real install has.
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "25m"
|
||||
@@ -326,6 +326,67 @@
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"bulwark: the busiest tag landscape of any chart in this file, and the reason this block",
|
||||
"was measured instead of copied. Counted on 2026-10-05 against the ghcr.io tags API,",
|
||||
"315 tags in EIGHT shapes side by side:",
|
||||
"",
|
||||
" 1. release tags, suffix-free 1.12.0, 1.11.2, 1.11.1, ... <- what we track",
|
||||
" 2. the SAME releases with a 'v' v1.12.0, v1.11.2, ...",
|
||||
" 3. a '-always' build flavour 1.12.0-always, v1.12.0-always",
|
||||
" 4. floating minors and majors 1.12, 1.11, 1, v1.12, v1 - they move",
|
||||
" 5. floating names latest, main, dev, latest-always - they move",
|
||||
" 6. commit-SHA tags 0080d27, main-<sha>, dev-<sha> (the bulk of the",
|
||||
" 315, pushed per commit) plus one typo'd tag, mainfc38427",
|
||||
" 7. PRERELEASES 1.11.0-beta.1, -beta.2, -beta.3 and their",
|
||||
" -always twins",
|
||||
" 8. attestation tags sha256-<64 hex>",
|
||||
"",
|
||||
"Shapes 4-7 are each a way to pin something nobody chose into appVersion and republish",
|
||||
"the chart for it - and shape 2 is the subtler one, because it is the same image under a",
|
||||
"second name, so selecting it would rewrite appVersion and the +up suffix for no change",
|
||||
"at all.",
|
||||
"",
|
||||
"LIKE keycloak AND UNLIKE garage, THE ANSWER IS AN ABSENCE: no versioningTemplate. Plain",
|
||||
"`docker` versioning already excludes every unwanted shape, so a constraint here would be",
|
||||
"a line that does nothing and one more thing to keep correct. It splits a tag at the first",
|
||||
"dash and only ever offers candidates carrying the SAME suffix as the current value -",
|
||||
"ours has none, so shapes 3 and 7 are invisible to it; the SHA and floating-name tags",
|
||||
"parse as no version at all; the floating minors have fewer parts than our value and",
|
||||
"cannot be more precise; and a 'v'-prefixed tag normalizes to the same version as ours,",
|
||||
"so it is never GREATER and never an update. `ignoreUnstable` (a Renovate default, also",
|
||||
"pulled in by config:recommended above) is a second, independent guard on shape 7.",
|
||||
"",
|
||||
"MEASURED, NOT ASSUMED, and written down because a later round will otherwise add a",
|
||||
"constraint 'to be safe'. Renovate 44.138.0, --platform=local --dry-run=lookup against",
|
||||
"the real registry, reading THIS file (so separateMinorPatch and the rest of the config",
|
||||
"above are in effect), 2026-10-05:",
|
||||
" appVersion 1.12.0 -> updates [], warnings []",
|
||||
" appVersion 1.11.0 -> exactly two: 1.11.2 (patch), 1.12.0 (minor)",
|
||||
" appVersion 1.10.0 -> exactly one: 1.12.0 (minor)",
|
||||
"Every offered newValue was the suffix-free form. No run offered a beta, an -always tag, a",
|
||||
"'v' form, a floating tag or a SHA tag. The 1.11.0 and 1.10.0 fixtures are the guard the",
|
||||
"nginx and qbittorrent rules taught this repo to run: they prove real updates still come",
|
||||
"through rather than the filter quietly emptying the list.",
|
||||
"",
|
||||
"ONE CAVEAT ABOUT THAT RUN, stated rather than hidden because it was visible in the log:",
|
||||
"ghcr.io served NO releaseTimestamp anonymously, so Renovate marked both updates",
|
||||
"`pendingChecks: true` under the minimumReleaseAge rule below",
|
||||
"(minimumReleaseAgeBehaviour=timestamp-required). Whether the shared instance sees the",
|
||||
"timestamps is NOT verified here - it authenticates to github.com with GITHUB_COM_TOKEN",
|
||||
"and this run did not. If it does not, the consequence is a held PR rather than a wrong",
|
||||
"one, and it would apply to every ghcr.io-based chart in this file alike (bentopdf,",
|
||||
"calibre-web-automated, cyberchef, matrix-synapse, ytdlp-web-player), so it is not a",
|
||||
"decision about this chart.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["bulwark/Chart.yaml"],
|
||||
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||
depNameTemplate: "ghcr.io/bulwarkmail/webmail",
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["calibre-web-automated/Chart.yaml"],
|
||||
|
||||
Reference in new issue
Block a user