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
This commit is contained in:
9 files changed
+416
-30
No files matched your search
+87
-10
@@ -1,4 +1,16 @@
|
||||
{{- /*
|
||||
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
|
||||
workflow or script under ci/ reads it, so every claim below carries
|
||||
review-only assurance - it is checked by being read, not by a stage. A
|
||||
rendering ERROR here does fail `helm template`, which is all the pipeline can
|
||||
see of it.
|
||||
*/ -}}
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
{{- $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 -}}
|
||||
@@ -11,17 +23,75 @@ The messages and the accounts belong to the Stalwart server at
|
||||
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
|
||||
ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
|
||||
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Web: {{ $url }}
|
||||
{{ else }} Web: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
|
||||
both 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
|
||||
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Admin: {{ $url }}/admin/login
|
||||
{{ else }} Admin: /admin/login on the in-cluster address above. With nothing published, 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 }}
|
||||
{{ end }}{{ if or .Values.ingress.enabled $httpRoute.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.
|
||||
NEXT_PUBLIC_BASE_PATH is compiled into the image.{{ if $httpRoute.enabled }} That is also why the
|
||||
HTTPRoute carries no URLRewrite filter - there is no prefix to strip.{{ end }}
|
||||
{{ end }}{{ if $httpRoute.enabled }}
|
||||
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
|
||||
|
||||
This release renders ONE HTTPRoute and NOTHING else of the routing path - no
|
||||
Gateway, no class, no Certificate, no policy object.
|
||||
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
|
||||
|
||||
Three things therefore belong to the cluster, and if any is missing the
|
||||
hostname above simply does not answer:
|
||||
|
||||
1. The Gateway-API CRDs. Without them this install would have failed, so
|
||||
they are present - nothing to check.
|
||||
2. The Gateway named in httpRoute.parentRefs
|
||||
{{- range $httpRoute.parentRefs }}
|
||||
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
||||
{{- end }}
|
||||
must exist AND its listener must allow routes from this namespace
|
||||
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
|
||||
in the route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions - "Accepted: True" and
|
||||
"ResolvedRefs: True" is what a working attachment looks like. An empty
|
||||
status means no controller has looked at the route at all.
|
||||
3. The TLS certificate for the hostname, on the Gateway's listener.
|
||||
ingress.clusterIssuer is NOT used in this mode and is not required: the
|
||||
chart renders no Certificate here, so there is no
|
||||
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
|
||||
for this release.
|
||||
|
||||
THE UPLOAD LIMIT IS NOT ENFORCED BY ANYTHING THIS CHART RENDERS.
|
||||
|
||||
In Ingress mode an operator sets nginx.ingress.kubernetes.io/proxy-body-size
|
||||
through ingress.annotations - this installation runs with 25m - because
|
||||
nginx's default of 1m rejects almost every attachment. IN THIS MODE THAT IS
|
||||
GONE: the Gateway API has no body-size field, it is implementation policy
|
||||
rather than routing, and this chart renders no policy object on purpose (it
|
||||
would bind the chart to one Gateway implementation).
|
||||
|
||||
So the limit in force is THE GATEWAY'S, whatever it happens to be, and no
|
||||
value of this chart has any say in it. The failure mode on a webmail client
|
||||
is worth picturing before it happens: a user has written the mail, attached
|
||||
the file and pressed send, and the 413 comes back FROM THE PROXY. It reads
|
||||
like Bulwark being broken, nothing mentions a limit, and the draft is the
|
||||
thing at risk.
|
||||
|
||||
Set the equivalent next to the Gateway, in your implementation's own object,
|
||||
BEFORE you point DNS at it - and size it to what the MAIL SERVER accepts
|
||||
(the Stalwart release above), not to what looks generous here. A webmail
|
||||
client that accepts a 40 MB attachment the mail server will refuse has only
|
||||
moved the failure later.
|
||||
|
||||
ingress.annotations is inert in this mode in general: the Gateway API has no
|
||||
annotation channel for controller behaviour.
|
||||
{{ end }}
|
||||
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
|
||||
The operator materializes one Secret key per item FIELD. A missing or misnamed
|
||||
@@ -244,9 +314,16 @@ WHAT THIS CHART DOES NOT RENDER
|
||||
- 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 sub-path Ingress and no sub-path route, and no value that could add
|
||||
one - the base path is compiled into the image. Hence also no URLRewrite
|
||||
filter in Gateway mode.
|
||||
- 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.
|
||||
chart does not know. Set it in ingress.annotations{{ if $httpRoute.enabled }} - except that in GATEWAY
|
||||
MODE there is no annotation to set and no counterpart at all, which is the
|
||||
section above{{ end }}.
|
||||
- No Gateway, no GatewayClass and no listener, in either mode. Those are
|
||||
platform objects and live in cluster-config; this chart renders an
|
||||
HTTPRoute at most, which is the part that looks the same on every Gateway
|
||||
implementation.
|
||||
@@ -447,17 +447,34 @@ 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.
|
||||
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" -}}
|
||||
{{- if .Values.ingress.domain -}}
|
||||
https://{{ .Values.ingress.domain }}
|
||||
{{- $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 -}}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
{{/*
|
||||
HTTPRoute - the Gateway-API alternative to bulwark.ingress.yaml (Gitea #114).
|
||||
|
||||
ONE ROUTE, ONE RULE, AT THE ROOT - and the reason is the same one that makes
|
||||
the Ingress rule permanently "/": NEXT_PUBLIC_BASE_PATH is compiled into
|
||||
Bulwark's bundle, so the application cannot be served under a sub-path at all.
|
||||
Which is also why THIS CHART RENDERS NO URLRewrite FILTER and needs none. That
|
||||
differs from what #114's measured table and the header of
|
||||
ci/run-httproute-match-test.sh both expected of this chart, so it is worth
|
||||
writing down where it will be read:
|
||||
|
||||
- This chart has ONE Ingress object, not two (keycloak has two).
|
||||
- This chart has NO rewrite-target annotation and CANNOT have one. Every
|
||||
occurrence of the string "rewrite-target" in this chart - values.yaml, the
|
||||
schema, the Ingress template - is an explanation of why it does not help:
|
||||
the bundle's own asset and API URLs are built for the root, so stripping a
|
||||
prefix at the proxy yields a page that loads and then fetches everything
|
||||
from the wrong place. A `git grep rewrite-target bulwark/` finds those
|
||||
refutations and counts them as occurrences, which is how the expectation
|
||||
arose.
|
||||
- Consequently the #118 prefix/redirect assurance in
|
||||
ci/run-httproute-match-test.sh does not apply here: with no URLRewrite on a
|
||||
prefix other than "/", its precondition is never met. The bare-prefix
|
||||
redirect that freesailarr needs has nothing to redirect here.
|
||||
|
||||
WHAT THIS RENDERS, AND WHAT IT MUST NOT
|
||||
|
||||
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
|
||||
Certificate, no implementation-specific policy object - the subset that looks
|
||||
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio (decision 1
|
||||
of #114). Class and certificate belong to the PLATFORM, on the Gateway and its
|
||||
listeners, in cluster-config.
|
||||
|
||||
WHAT THIS MODE DROPS FOR THIS CHART, MEASURED ON THE LIVE OBJECT
|
||||
|
||||
On 2026-10-12 the live Ingress (jk-cluster, namespace bulwark,
|
||||
webmail.bekemeyer-klein.de) carried
|
||||
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 25m
|
||||
|
||||
The CHART does not set it - values.yaml recommends exactly that value and
|
||||
explains why the chart cannot pick it (the right number is the mail server's,
|
||||
which this chart does not know), so the 25m is the operator's, supplied through
|
||||
ingress.annotations. It is therefore invisible to anyone reading this
|
||||
repository alone, and it is the single annotation this installation depends on.
|
||||
|
||||
In this mode it is gone. The Gateway API has no body-size counterpart: it is
|
||||
implementation policy, not routing, and decision 2 of #114 says such a limit is
|
||||
DOCUMENTED rather than rebuilt. So the Gateway branch of NOTES.txt names it in
|
||||
this chart's own terms - an attachment above the Gateway's default is rejected
|
||||
before Bulwark sees a byte, with a 413 from the proxy that reads like an
|
||||
application fault, on the one application whose whole purpose is sending files
|
||||
to other people.
|
||||
*/}}
|
||||
{{- /*
|
||||
Null-safe like every other block a template here dereferences (#99): an
|
||||
override written without children - "httpRoute:" with its keys deleted - is
|
||||
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
|
||||
*/ -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
||||
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Bulwark it also breaks the login: the OIDC redirect URI registered with the identity provider names ONE origin, and a session that starts on one data path and returns on the other fails on the state cookie. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain) with that second origin registered as a redirect URI too, never over parallel operation of both." -}}
|
||||
{{- end -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
||||
{{- if not $parentRefs -}}
|
||||
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
||||
{{- end -}}
|
||||
{{- /*
|
||||
One source of truth for the hostname: an empty httpRoute.hostnames means
|
||||
[ingress.domain]. Two values that have to agree drift apart.
|
||||
|
||||
AND THIS READ CHANGES THE CHART'S OWN ACCOUNT OF ITSELF. Before this mode
|
||||
existed, bulwark.ingress.yaml and the bulwark.publicUrl helper both stated that
|
||||
nothing outside the Ingress template FUNCTIONALLY reads ingress.domain - true
|
||||
then, false now, because this template reads it. Both have been corrected
|
||||
rather than left standing (the same correction garage and calibre-web-automated
|
||||
needed in groups 1 and 2). The required() below is what makes it true again:
|
||||
ingress.domain is mandatory in Gateway mode too, unless hostnames are given
|
||||
explicitly.
|
||||
*/ -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
|
||||
{{- end -}}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
{{- toYaml $hostnames | nindent 4 }}
|
||||
rules:
|
||||
{{- /*
|
||||
The Ingress counterpart is `path: / , pathType: Prefix`, and "/" here is
|
||||
as permanent as it is there - see the header. The port is the Service's
|
||||
port NUMBER and not its name "http": backendRefs takes a PortNumber, so
|
||||
unlike the Ingress this reference cannot be made by name. It comes from
|
||||
the same settings helper the Service renders its port from, so
|
||||
bulwark.port and this file still cannot drift apart;
|
||||
ci/run-httproute-match-test.sh holds the pair for the case where they do.
|
||||
*/}}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port: {{ $settings.port | int64 }}
|
||||
{{- end }}
|
||||
@@ -14,12 +14,22 @@ 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
|
||||
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 no routing object at all is complete without a domain. That is the
|
||||
calibre-web-automated case rather than the matrix-synapse one.
|
||||
|
||||
CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): this paragraph used to say
|
||||
that nothing outside this template FUNCTIONALLY reads ingress.domain.
|
||||
bulwark.httproute.yaml now does - as the route's hostname, with a required() of
|
||||
its own - so the value is mandatory in Gateway mode too unless
|
||||
httpRoute.hostnames is given explicitly. "The gate frees both values" means
|
||||
BOTH gates off, not this one alone.
|
||||
|
||||
THE TWO MODES ARE MUTUALLY EXCLUSIVE and the HTTPRoute template aborts the
|
||||
render when ingress.enabled and httpRoute.enabled are both true, naming both
|
||||
value paths.
|
||||
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user