diff --git a/bulwark/Chart.yaml b/bulwark/Chart.yaml index 1d7cac3..c3afad3 100644 --- a/bulwark/Chart.yaml +++ b/bulwark/Chart.yaml @@ -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: diff --git a/bulwark/templates/NOTES.txt b/bulwark/templates/NOTES.txt index b567ee1..49728d6 100644 --- a/bulwark/templates/NOTES.txt +++ b/bulwark/templates/NOTES.txt @@ -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. diff --git a/bulwark/templates/_helpers.tpl b/bulwark/templates/_helpers.tpl index 8275f7a..16a5b53 100644 --- a/bulwark/templates/_helpers.tpl +++ b/bulwark/templates/_helpers.tpl @@ -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:// {{- else -}} https:// {{- end -}} diff --git a/bulwark/templates/bulwark.httproute.yaml b/bulwark/templates/bulwark.httproute.yaml new file mode 100644 index 0000000..9182c44 --- /dev/null +++ b/bulwark/templates/bulwark.httproute.yaml @@ -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 }} diff --git a/bulwark/templates/bulwark.ingress.yaml b/bulwark/templates/bulwark.ingress.yaml index 0a247d8..4247881 100644 --- a/bulwark/templates/bulwark.ingress.yaml +++ b/bulwark/templates/bulwark.ingress.yaml @@ -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 diff --git a/bulwark/values.schema.json b/bulwark/values.schema.json index 0793987..0d8eb6e 100644 --- a/bulwark/values.schema.json +++ b/bulwark/values.schema.json @@ -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, diff --git a/bulwark/values.yaml b/bulwark/values.yaml index 527d53b..de050e0 100644 --- a/bulwark/values.yaml +++ b/bulwark/values.yaml @@ -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: [] diff --git a/ci/httproute/bulwark.yaml b/ci/httproute/bulwark.yaml new file mode 100644 index 0000000..067d13e --- /dev/null +++ b/ci/httproute/bulwark.yaml @@ -0,0 +1,54 @@ +# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST. +# +# Its one job is to put this chart into Gateway mode, because the convention +# ships with httpRoute.enabled: false and the test would otherwise be green +# over nothing. A chart that has an overlay here and still renders no +# HTTPRoute FAILS the test - that is the whole point of the file existing. +# +# An overlay and not a change to ci/bulwark/test-values.yaml: the install-test +# runs exactly ONE configuration per chart, and this chart's fixture installs +# the StatefulSet with its PVC and both 1Password items. Trading its Ingress +# for a CRD schema check that cyberchef's fixture already performs against the +# real Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the +# route is held here, at render level, which is where its silent failure modes +# live. +# +# ingress.enabled: false is not optional here - ingress.enabled is this chart's +# routing switch and defaults to TRUE, so without this line the chart aborts +# its own render with both modes on. +# +# ONE ROUTING OBJECT, WHICH IS ALL THIS CHART HAS. #114's measured table and +# the header of ci/run-httproute-match-test.sh both expected two Ingresses and +# a rewrite-target here; neither is the case, and the reason is in +# bulwark.httproute.yaml. In short: Bulwark cannot be served under a sub-path +# (NEXT_PUBLIC_BASE_PATH is compiled into the bundle), so there is one object, +# one rule, at "/", and no URLRewrite filter for the #118 prefix/redirect +# assurance to pair up with. Every "rewrite-target" string in this chart is an +# explanation of why it does not work, and a grep counted those as +# occurrences. +# +# WHAT THIS OVERLAY COVERS: +# +# - ingress.domain comes from the fixture, so the route renders with its +# hostname from that single source of truth - httpRoute.hostnames stays +# empty on purpose, because the fallback is the path every installation +# will actually use. +# - the route's backendRef is checked against the serving Service of the same +# render, by PORT NUMBER (bulwark.port) rather than by the name "http" the +# Ingress uses, because backendRefs takes a PortNumber. This overlay is +# what holds that number against the Service's. +# +# NOT covered here, and it cannot be: that the hostname answers. No Gateway +# controller runs in CI and no route reaches "Accepted: True". Nor the thing +# this chart actually loses in this mode - the operator's +# proxy-body-size: 25m, which has no counterpart in the specification. That +# loss is documented in values.yaml and printed by NOTES.txt, and documentation +# is not something a test can assert. +ingress: + enabled: false +httpRoute: + enabled: true + parentRefs: + - name: public + namespace: gateway + sectionName: https diff --git a/ci/run-httproute-match-test.sh b/ci/run-httproute-match-test.sh index 431a86f..e06aea3 100755 --- a/ci/run-httproute-match-test.sh +++ b/ci/run-httproute-match-test.sh @@ -43,9 +43,18 @@ # property of one route - a different route is a different hostname and # says nothing about this one. Exact outranks every prefix match in that # precedence, so the pairing is about existence and not about order. -# freesailarr is the first chart this applies to; bulwark is the second -# rewrite-target chart and meets the same check when group 3 of #114 -# lands, which is the point of checking it here rather than in one chart. +# freesailarr is the FIRST AND, AS OF GROUP 3 OF #114, THE ONLY chart this +# applies to. The line that used to stand here said bulwark was the second +# rewrite-target chart and would meet the same check when group 3 landed. +# Group 3 landed and it does not: bulwark has ONE Ingress object, no +# rewrite-target annotation, and cannot have one - NEXT_PUBLIC_BASE_PATH is +# compiled into its bundle, so it cannot be served under a sub-path at all +# and its route is one rule at "/" with no URLRewrite filter. Every +# occurrence of the string "rewrite-target" in that chart is an explanation +# of why it does not help, and #114's measured table arrived at "bulwark, +# freesailarr" by grepping for the string rather than for the annotation. +# The check stays over all charts for the reason below - the mistake +# arrives by copy - not because a second chart is known to need it. # # And the install-test cannot close either gap, even with the real CRDs in the # cluster: schema validation accepts an empty parentRefs list and accepts a