{{- $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:///realms/." }} {{- 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 }}