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:
jklein authored and jklein committed 2026-10-13 08:59:09 +00:00
1 parent d0f0d49a93
commit 9625d8b182
9 files changed
+416 -30

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ type: application
# 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
version: 1.1.0+up1.12.0
appVersion: "1.12.0"
maintainers:
+87 -10
View File
@@ -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.
+26 -9
View File
@@ -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 -}}
+115
View File
@@ -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 -4
View File
@@ -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
+26 -3
View File
@@ -166,27 +166,50 @@
"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.",
"description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Checked in both directions for this chart, and CORRECTED with Gitea #114: it used to say that nothing outside that template functionally reads ingress.domain, which the HTTPRoute now does. Switching the INGRESS off frees clusterIssuer; it frees domain only if httpRoute is off as well. What still holds is the reason - Bulwark derives its own public URL from the request origin and takes no base-URL variable - so a release with no routing object at all is complete without a domain. NOTES.txt 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.",
"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 - which is also why the HTTPRoute renders no URLRewrite filter. Read by the Ingress, by the HTTPRoute (when httpRoute.hostnames is empty) and by NOTES.txt for the OIDC redirect URI.",
"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.",
"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 - and that is what this installation's live Ingress actually carries (25m, measured 2026-10-12). INERT IN GATEWAY MODE: the Gateway API has no annotation channel and no body-size counterpart at all.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. ONE rule, at the root, with no URLRewrite filter - Bulwark cannot be served under a sub-path, so there is nothing to strip and the #118 prefix/redirect assurance has no precondition to meet here. NOT COVERED IN THIS MODE: proxy-body-size, which this installation's live Ingress carries at 25m and which has no counterpart in the specification - an oversized attachment is then rejected by the Gateway with a 413 that reads like a webmail fault. See values.yaml, httpRoute block. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. Setting it means a second origin, which has to be registered as an OIDC redirect URI with the identity provider before a login can succeed on it.",
"type": "array",
"items": { "type": "string" }
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"port": {
"type": ["integer", "null"],
"minimum": 1,
+81
View File
@@ -711,6 +711,11 @@ ingress:
# away would produce a release that renders cleanly and whose operator is told
# to register `https:///auth/callback`.
#
# AND A THIRD TIME SINCE Gitea #114: the HTTPRoute reads it as its hostname
# when httpRoute.hostnames is empty, with a required() of its own. So this
# value is mandatory in GATEWAY mode as well, and only a release with both
# modes off can do without it.
#
# 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
@@ -734,4 +739,80 @@ ingress:
# (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.
#
# AND THAT RECOMMENDATION IS NOT HYPOTHETICAL - IT IS WHAT THIS INSTALLATION
# RUNS ON. Measured on 2026-10-12: the live Ingress on
# webmail.bekemeyer-klein.de carries exactly
# nginx.ingress.kubernetes.io/proxy-body-size: 25m, supplied here by the
# operator. In GATEWAY MODE this whole block is INERT - the Gateway API has
# no annotation channel for controller behaviour, and body size has no
# counterpart in the specification at all. Read the httpRoute block below
# before switching modes.
annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY an
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate, and no
# implementation-specific policy object. Class and certificate sit on the
# Gateway and its listeners and belong to the platform (cluster-config), which
# is what keeps the Gateway implementation replaceable without touching this
# chart again.
#
# ONE ROUTE, ONE RULE, AT THE ROOT. "/" is as permanent here as it is on the
# Ingress, for the same reason: NEXT_PUBLIC_BASE_PATH is compiled into the
# bundle, so Bulwark cannot be served under a sub-path and there is nothing for
# a URLRewrite filter to strip. This chart renders no such filter and needs
# none - which also means the #118 prefix/redirect assurance in
# ci/run-httproute-match-test.sh has no precondition to meet here.
#
# WHAT THIS MODE COSTS, AND IT IS THE ONE ANNOTATION THIS INSTALLATION USES.
# proxy-body-size: 25m is set on the live Ingress by the operator (see the
# annotations block above). It is GONE in this mode and cannot be rebuilt here:
# the Gateway API knows no body size, that is implementation policy, and
# decision 2 of #114 says the limit is documented rather than reconstructed
# with a policy object that would bind this chart to one implementation.
#
# What that looks like in practice, for THIS application: an attachment above
# the GATEWAY's default is rejected before Bulwark sees a byte, and the 413
# comes from the proxy. The user is composing mail with a file attached, so the
# failure arrives at the least recoverable moment and reads like a webmail
# fault. Set the equivalent next to the Gateway - in that implementation's own
# object - BEFORE pointing DNS at it, and size it to what the MAIL SERVER
# accepts (stalwart), not to what feels generous. NOTES.txt prints this in
# Gateway mode.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
#
# AND IF YOU SET IT, REGISTER THAT ORIGIN WITH THE IDENTITY PROVIDER FIRST.
# NOTES.txt derives the OIDC redirect URI from the ACTIVE mode's hostname, so
# a second hostname means a second redirect URI - a login that returns to an
# unregistered origin is rejected at the provider, which says nothing about
# this chart.
hostnames: []