Files
helm-charts/bulwark/templates/_helpers.tpl
T
jklein f6eeff87c2 feat(bulwark): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. One route, one rule, at the root - and THREE
EXPECTATIONS ABOUT THIS CHART DID NOT SURVIVE CONTACT WITH IT:

  - #114 says bulwark has TWO Ingress objects. It has ONE. keycloak has two.
  - #114's measured table lists bulwark under rewrite-target. It has no
    rewrite-target annotation and CANNOT have one: NEXT_PUBLIC_BASE_PATH is
    compiled into the bundle, so the application cannot be served under a
    sub-path, and stripping a prefix at the proxy would yield a page that
    loads and then fetches everything from the wrong place. Every occurrence
    of the string "rewrite-target" in this chart - values.yaml, the schema,
    the Ingress template - is an explanation of exactly that. A grep for the
    string finds the refutations and counts them as occurrences.
  - ci/run-httproute-match-test.sh said in its header that bulwark was the
    second rewrite-target chart and would meet the #118 prefix/redirect
    assurance when group 3 landed. Group 3 landed and it does not: with no
    URLRewrite on a prefix other than "/", the assurance's precondition is
    never met here. The header is corrected rather than left standing, and it
    now says freesailarr is the only chart the pairing applies to.

So no redirect rule is added, and none is needed.

WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT rather than inferred. On
2026-10-12 the live Ingress on webmail.bekemeyer-klein.de carried

    nginx.ingress.kubernetes.io/proxy-body-size: 25m

supplied by the OPERATOR through ingress.annotations - values.yaml recommends
exactly that value and explains why the chart cannot pick it, so the chart
sets nothing and a reader of this repository alone would not find it. It is
the single annotation this installation depends on, and the Gateway API has no
body-size counterpart: implementation policy, not routing. Per decisions 1 and
2 of #114 it is NAMED, not rebuilt - in values.yaml, in the schema and in the
Gateway branch of NOTES.txt, in this application's own terms: an attachment
above the Gateway's default is rejected before Bulwark sees a byte, the 413
comes from the proxy, and it arrives after the user has written the mail and
attached the file.

CORRECTED CLAIM, the same one garage and calibre-web-automated needed: the
Ingress template, the bulwark.publicUrl helper and the schema all stated that
nothing outside the Ingress template FUNCTIONALLY reads ingress.domain. The
HTTPRoute reads it, so the claim held only while both modes were off. All
three now say so, and the route carries a required() of its own.

bulwark.publicUrl follows the ACTIVE mode instead of always printing
ingress.domain. The two differ exactly when httpRoute.hostnames is set, which
is how a migration off the Ingress runs - and printing the old host there
would hand the operator an OIDC redirect URI for the hostname being migrated
away from, a failure that surfaces as a rejected login at the identity
provider and says nothing about this chart.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/bulwark/test-values.yaml.

Part of #114
2026-10-12 20:49:56 +00:00

482 lines
21 KiB
Smarty

{{/*
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, 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://<neither httpRoute.hostnames nor ingress.domain SET>
{{- else -}}
https://<ingress.domain NOT SET>
{{- end -}}
{{- end -}}