{{/* 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" "factor" . */}} {{- 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" "replicas" . - 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" "given" "path" . 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" "key" "path" . Returns YAML of dict "value" . */}} {{- 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 /data/admin and getStateDir() to /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, AND CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): it used to say that nothing outside the Ingress template FUNCTIONALLY reads ingress.domain. That was true and is no longer - bulwark.httproute.yaml reads it as the route's hostname, with a required() of its own. What still holds is the half that made the claim worth making: Bulwark derives its own public URL from the request origin and takes no base-URL variable, so with BOTH modes off the release is complete without a domain - the calibre-web-automated case, where the gate frees both values. The enforcement sits in whichever routing template is active; this helper still renders a visible placeholder instead of failing, because `helm template` does not render NOTES.txt. IN GATEWAY MODE THE PUBLIC URL IS THE ROUTE'S HOSTNAME, not ingress.domain, and the two differ exactly when httpRoute.hostnames is set explicitly - which is how a migration off the Ingress runs. Printing ingress.domain there would hand the operator an OIDC redirect URI for the host being migrated AWAY from, and that failure surfaces as a rejected login at the identity provider rather than as anything about this chart. So the helper follows the active mode. */}} {{- define "bulwark.publicUrl" -}} {{- $httpRoute := .Values.httpRoute | default dict -}} {{- $host := .Values.ingress.domain -}} {{- if $httpRoute.enabled -}} {{- $host = (first ($httpRoute.hostnames | default list)) | default .Values.ingress.domain -}} {{- end -}} {{- if $host -}} https://{{ $host }} {{- else if $httpRoute.enabled -}} https:// {{- else -}} https:// {{- end -}} {{- end -}}