diff --git a/ci/httproute/matrix-synapse.yaml b/ci/httproute/matrix-synapse.yaml new file mode 100644 index 0000000..e1019c6 --- /dev/null +++ b/ci/httproute/matrix-synapse.yaml @@ -0,0 +1,59 @@ +# 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/matrix-synapse/test-values.yaml: the +# install-test runs exactly ONE configuration per chart, and this chart's +# fixture installs the StatefulSet with its PVC and the delegation Deployment. +# 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 routes are held here, at render level, which is where +# their 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. +# +# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS - and this chart is +# the one in group 3 where "both" means two different hostnames, two different +# paths and two different backends: +# +# - ingress.domain comes from the fixture (matrix.ci.local), so the +# HOMESERVER route renders with its hostname from that single source of +# truth. httpRoute.hostnames stays empty on purpose: the fallback is the +# path every installation will actually use. +# - matrix.delegation.enabled is TRUE in the fixture with +# matrix.delegation.serverUrl: ci.local, so the DELEGATION route renders +# too - on its own hostname, matching PathPrefix /.well-known/matrix, and +# pointing at the delegation Service rather than at Synapse. Its +# parentRefs fall back to httpRoute.parentRefs below, which is the branch +# an installation hits unless the server name sits behind a different +# Gateway. +# - both routes' backendRefs are checked against the Services of the same +# render, and they are DIFFERENT Services. Both references are by PORT +# NUMBER rather than by the name "http" the Ingress uses, because +# backendRefs takes a PortNumber - so this overlay is what holds those +# numbers against the Services'. +# +# No URLRewrite filter is rendered by this chart in either mode: the delegation +# nginx answers on the full /.well-known/matrix/* path, so nothing is stripped +# and the #118 prefix/redirect assurance has no precondition to meet here. +# +# NOT covered here, and it cannot be: that either hostname answers, which for +# Matrix is also whether FEDERATION works. No Gateway controller runs in CI, no +# route reaches "Accepted: True", and Matrix federation is HTTPS-only while +# nothing in this mode renders a certificate. Nor is the body-size limit +# covered - proxy-body-size: 50m is on the live Ingress and 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/matrix-synapse/Chart.yaml b/matrix-synapse/Chart.yaml index bfee40f..21099d2 100644 --- a/matrix-synapse/Chart.yaml +++ b/matrix-synapse/Chart.yaml @@ -3,7 +3,7 @@ name: matrix-synapse description: A Helm chart to deploy a Matrix Synapse server on Kubernetes icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png type: application -version: 7.1.1+upv1.159.0 +version: 7.2.0+upv1.159.0 appVersion: "v1.159.0" maintainers: - name: jklein \ No newline at end of file diff --git a/matrix-synapse/templates/NOTES.txt b/matrix-synapse/templates/NOTES.txt index 1d4d2d6..8ea56da 100644 --- a/matrix-synapse/templates/NOTES.txt +++ b/matrix-synapse/templates/NOTES.txt @@ -1,5 +1,29 @@ +{{- /* +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. +*/ -}} {{- $replicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.matrix.replicas) -}} {{- $delegation := .Values.matrix.delegation | default dict -}} +{{- $httpRoute := .Values.httpRoute | default dict -}} +{{- $httpRouteDelegation := $httpRoute.delegation | default dict -}} +{{- /* +"Published" rather than "has an Ingress": the two modes are alternatives, and +every paragraph below that used to ask ingress.enabled was really asking +whether anything publishes this release. Gitea #114, and the sure.publishedOverTls +/ freesailarr.published shape. +*/ -}} +{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}} +{{- $hostnames := $httpRoute.hostnames | default list -}} +{{- if not $hostnames -}} +{{- $hostnames = list .Values.ingress.domain -}} +{{- end -}} +{{- $delegationHostnames := $httpRouteDelegation.hostnames | default list -}} +{{- if not $delegationHostnames -}} +{{- $delegationHostnames = list $delegation.serverUrl -}} +{{- end -}} {{- $delegationReplicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $delegation.replicas) -}} {{- $res := fromYaml (include "matrix-synapse.effectiveResources" .Values.matrix.resources) -}} {{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}} @@ -14,7 +38,7 @@ Matrix Synapse {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namesp {{- if .Values.ingress.enabled }} -ACCESS +ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false) https://{{ .Values.ingress.domain }} That host carries the whole client and federation API: the Ingress sends "/" @@ -24,11 +48,96 @@ ACCESS shows a warning and federation fails. Follow it with kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress +{{- else if $httpRoute.enabled }} + +ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false) +{{- range $hostnames }} + https://{{ . }} +{{- end }} + + That host carries the whole client and federation API: the HTTPRoute sends + "/" on it to the Synapse Service, which forwards port 80 to the container's + 8008. + + THIS RELEASE RENDERS {{ if $delegation.enabled }}TWO HTTPRoutes{{ else }}ONE HTTPRoute{{ end }} AND NOTHING ELSE of the routing + path — no Gateway, no class, no Certificate, no policy object. + + {{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }} +{{- if $delegation.enabled }} + {{ .Release.Name }}-{{ .Chart.Name }}-delegation-httproute {{ join ", " $delegationHostnames }} +{{- end }} + + Three things therefore belong to the cluster, and if any is missing the + hostname does not answer — which for Matrix means federation is down, not + just the web client: + + 1. The Gateway-API CRDs. Without them this install would have failed, so + they are present — nothing to check. + 2. The Gateway(s) named in parentRefs +{{- range $httpRoute.parentRefs }} + {{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (homeserver) +{{- end }} +{{- if $delegation.enabled }} +{{- range ($httpRouteDelegation.parentRefs | default $httpRoute.parentRefs) }} + {{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (delegation) +{{- end }} +{{- end }} + must exist AND their listeners 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. {{ if $delegation.enabled }}The TLS certificates — one per hostname, on the Gateway's listeners, + or one wildcard listener covering both{{ else }}The TLS certificate for the hostname, on the Gateway's listener{{ end }}. + 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{{ if $delegation.enabled }} and no + tls-{{ .Release.Name }}-{{ .Chart.Name }}-delegation secret{{ end }} and nothing for + cert-manager to do for this release. MATRIX FEDERATION IS HTTPS-ONLY, + so a listener without a valid certificate for these names is a + federation outage that no probe in this release can see. + + 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 50m — because Synapse's media repository takes + uploads and nginx's default of 1m rejects nearly all of them. 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). + + The ceiling in force is the smaller of max_upload_size in your + homeserver.yaml — which this chart cannot see, it comes from 1Password — + and whatever the GATEWAY enforces. Neither number anyone can point at is + necessarily the one that decides. Set the Gateway's half, in your + implementation's own object, to match max_upload_size, BEFORE you point DNS + at it. + + AND ONE THING NOT TO RE-CREATE. The live Ingress also carries a + configuration-snippet of three more_set_headers directives + (X-Forwarded-For $remote_addr, X-Forwarded-Proto $scheme, Host). It LOOKS + like what Synapse's x_forwarded setting depends on. It is not: + more_set_headers is the headers-more module's OUTPUT-header directive, so + those three headers go on the RESPONSE TO THE CLIENT and Synapse never sees + them. What Synapse reads with x_forwarded: true is the REQUEST's + X-Forwarded-For, which the proxy supplies by itself. Do not spend effort + rebuilding this at the Gateway, and do not read its absence as the cause of + a rate-limiting or federation problem. (This is a reading of the directive's + documented semantics; nothing here A/B-tested a Synapse instance.) + + ingress.annotations is inert in this mode in general: the Gateway API has no + annotation channel for controller behaviour. {{- else }} -ACCESS — NO INGRESS - ingress.enabled is false, so this release has no Ingress and no certificate; - ingress.clusterIssuer is not needed. +ACCESS — NEITHER INGRESS NOR HTTPROUTE + ingress.enabled and httpRoute.enabled are both false, so this release has no + Ingress, no HTTPRoute and no certificate; ingress.clusterIssuer is not + needed. {{- if $delegation.enabled }} ingress.domain still is: the delegation nginx renders it into the /.well-known/matrix/* documents, so it stays the host every other Matrix @@ -56,10 +165,10 @@ DELEGATION — TWO HOSTNAMES THAT ARE NOT INTERCHANGEABLE → {"m.server": "{{ .Values.ingress.domain }}:443"} /.well-known/matrix/client → {"m.homeserver": {"base_url": "https://{{ .Values.ingress.domain }}"}} -{{- if .Values.ingress.enabled }} +{{- if $published }} - The Ingress publishes them on a SECOND host. Read that split carefully, - because the chart cannot check it for you: + {{ if $httpRoute.enabled }}A SECOND HTTPRoute publishes{{ else }}The Ingress publishes{{ end }} them on a SECOND host. Read that split + carefully, because the chart cannot check it for you: matrix.delegation.serverUrl ({{ $delegation.serverUrl }}) is the DELEGATING host — the domain in every user ID (@user:{{ $delegation.serverUrl }}). @@ -69,18 +178,36 @@ DELEGATION — TWO HOSTNAMES THAT ARE NOT INTERCHANGEABLE is where Synapse actually answers. It appears inside the documents above and needs no relation to the server name. - Only the path /.well-known/matrix is routed to nginx on {{ $delegation.serverUrl }}; - everything else on that host is not served by this release. Both hosts need + Only the path /.well-known/matrix is routed to nginx on {{ join ", " $delegationHostnames }}; + everything else on that host is not served by this release. +{{- if $httpRoute.enabled }} + + AND THAT CO-EXISTENCE IS EASIER HERE THAN IT WAS. The server name is usually + an apex domain that already serves a website, and under ingress-nginx two + Ingress objects cannot both claim one hostname — the admission webhook + rejects the second — which is why the delegation rule had to sit inside this + chart's single Ingress object. HTTPRoutes attached to the same listener for + the same hostname are MERGED by the implementation, longest match winning, + so this route may sit beside whatever owns that apex without either side + knowing about the other. + + Both hosts need a certificate ON THE GATEWAY'S LISTENERS — this chart renders + none in this mode — and a listener without a valid certificate for the server + name is a federation outage, not a cosmetic problem. +{{- else }} + Both hosts need a certificate — the second one is tls-{{ .Release.Name }}-{{ .Chart.Name }}-delegation — and cert-manager only issues it once {{ $delegation.serverUrl }} resolves to this cluster. A pending certificate there is a federation outage, not a cosmetic problem. +{{- end }} Check what is actually being served, from outside the cluster: - curl -s https://{{ $delegation.serverUrl }}/.well-known/matrix/server + curl -s https://{{ first $delegationHostnames }}/.well-known/matrix/server {{- else }} - Nothing publishes them, though: ingress.enabled is false, so the documents + Nothing publishes them, though: ingress.enabled and httpRoute.enabled are + both false, so the documents exist only on the in-cluster Service {{ .Release.Name }}-{{ .Chart.Name }}-delegation-service, and matrix.delegation.serverUrl ({{ $delegation.serverUrl }}) is rendered into no object of this release. Whatever terminates TLS in front of the cluster has to route @@ -101,7 +228,7 @@ NO DELEGATION IN THIS RELEASE — FEDERATION LIKELY DOES NOT WORK 8448 anywhere: the container publishes 8008 only, and the Service maps port 80 onto it. Unless the delegation is served by something else (a static .well-known on the server-name host, or an SRV record), federation stays - broken{{ if .Values.ingress.enabled }} while clients on {{ .Values.ingress.domain }} work fine{{ end }}. + broken{{ if $published }} while clients on {{ first $hostnames }} work fine{{ end }}. Setting matrix.delegation.enabled to true and matrix.delegation.serverUrl to the server name lets this chart serve it. {{- end }} @@ -120,7 +247,11 @@ SCALED TO ZERO {{- end }} {{- end }} The PVC, the Secret and the Services stay in place, so no data is lost{{ if .Values.ingress.enabled }} and - the certificates are not re-issued{{ end }}. Set it back and upgrade again to + the certificates are not re-issued{{ else if $httpRoute.enabled }} and the + HTTPRoutes stay in place, so the hostnames keep pointing at Services with no + endpoints — the Gateway answers with an error of its own rather than with + nothing, and remote homeservers see a failing .well-known instead of an + unresolvable name{{ end }}. Set it back and upgrade again to return. {{- end }} @@ -190,7 +321,7 @@ THE SERVER NAME IS DECIDED ONCE, AND NOT BY matrix.serverName a different name is a new server with a new database, not an upgrade. Decide it before the first start — together with matrix.delegation.serverUrl, which has to match it. -{{- if and .Values.ingress.enabled $delegation.enabled .Values.matrix.serverName $delegation.serverUrl }} +{{- if and $published $delegation.enabled .Values.matrix.serverName $delegation.serverUrl }} {{- if ne (printf "%v" .Values.matrix.serverName) (printf "%v" $delegation.serverUrl) }} MISMATCH IN THIS RELEASE: matrix.serverName is "{{ .Values.matrix.serverName }}" but the diff --git a/matrix-synapse/templates/matrix-synapse.httproute.yaml b/matrix-synapse/templates/matrix-synapse.httproute.yaml new file mode 100644 index 0000000..0d6d5ca --- /dev/null +++ b/matrix-synapse/templates/matrix-synapse.httproute.yaml @@ -0,0 +1,198 @@ +{{/* +HTTPRoute - the Gateway-API alternative to matrix-synapse.ingress.yaml +(Gitea #114). + +TWO ROUTES, AND THE SHAPE IS NOT WHAT #114 EXPECTED. The issue describes this +chart as rendering "its Ingresses in a loop over several apps". It does not - +that is freesailarr. This chart renders ONE Ingress object with TWO host rules: + + ingress.domain / synapse service + matrix.delegation.serverUrl /.well-known/matrix delegation service + +In the Gateway API that has to become TWO HTTPRoute objects, and not because of +style: spec.hostnames applies to the WHOLE object, so one route can carry +several hostnames only when they want the SAME rules. These two want different +paths and different backends, so one object per hostname is the only faithful +translation. + +WHAT THIS MODE IMPROVES, WHICH IS WORTH KNOWING BEFORE THE MIGRATION + +The delegation hostname is the Matrix SERVER NAME - the apex domain, not the +homeserver's own host - and it is almost always already serving something else +(a website at /). Under ingress-nginx two Ingress objects on one hostname are a +fight: the admission webhook rejects the second claim on the same host and +path, which is why the delegation rule has to live INSIDE this chart's single +Ingress object rather than beside it. HTTPRoutes do not work that way: several +routes may attach to the same listener for the same hostname and the +implementation merges them by match specificity (a longer path wins). So in +this mode the /.well-known/matrix route can sit next to whatever else owns that +apex, without either side knowing about the other. + +WHAT THIS RENDERS, AND WHAT IT MUST NOT + +Only HTTPRoutes 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. Note that the two hostnames need certificates on +TWO listeners (or one wildcard listener covering both), which in Ingress mode +were two entries in the one object's tls block. + +WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND ONE OF THE TWO IS NOT +WHAT IT LOOKS LIKE + +On 2026-10-12 the live Ingress (jk-cluster, namespace matrix-synapse) carried, +all of it OPERATOR-supplied through ingress.annotations - this chart sets none +of it: + + nginx.ingress.kubernetes.io/proxy-body-size: 50m + nginx.org/client-max-body-size: 50m + nginx.ingress.kubernetes.io/configuration-snippet: | + more_set_headers "X-Forwarded-For $remote_addr"; + more_set_headers "X-Forwarded-Proto $scheme"; + more_set_headers "Host $host:$server_port"; + +1. THE BODY SIZE IS THE REAL LOSS. Synapse's media repository takes uploads, + its own ceiling is max_upload_size in homeserver.yaml - which this chart + cannot see, because the whole config arrives from 1Password through + secrets.configSecretRef - and the effective limit is the smaller of the two. + In this mode the proxy half becomes THE GATEWAY'S default, so neither number + anyone can point at is necessarily the one that decides. There is no + counterpart in the Gateway API: body size is implementation policy, not + routing, and decision 2 of #114 says it is documented rather than rebuilt. + (nginx.org/client-max-body-size is the same limit spelled for the OTHER + nginx ingress controller, NGINX Inc's; it is a hedge across both + implementations and it disappears for the same reason.) + +2. THE SNIPPET IS NOT LOAD-BEARING, AND THAT TOOK CHECKING RATHER THAN + ASSUMING. It looks like the thing Synapse's x_forwarded setting depends on - + client IP for rate limiting, and federation. It is not, because + more_set_headers is the module's OUTPUT-header directive: + headers-more-nginx-module, verbatim - more_set_headers "Replaces (if any) or + adds (if not any) the specified OUTPUT headers", while the request-side + directive is a different one, more_set_input_headers ("it operates on input + headers (or request headers)"). So these three headers are added to the + RESPONSE TO THE CLIENT. Synapse never sees them; what Synapse reads with + x_forwarded: true is the REQUEST's X-Forwarded-For, which ingress-nginx + populates itself. + + The honest statement for the migration is therefore: not translatable, and + not needed. The Gateway API's ResponseHeaderModifier could set static + response headers but knows no nginx variables ($remote_addr, $scheme, + $host), so a rebuild is impossible anyway - and pointless, since dropping + them changes nothing Synapse depends on. What does change is that a client + stops receiving three headers echoing the proxy's view of its own request, + which is at best noise. + + This is a claim about a directive's documented semantics, not an experiment: + nothing here observed a Synapse instance with and without the snippet. + +Both are named in the Gateway branch of NOTES.txt, the first as a limit to +re-establish at the Gateway and the second as a thing NOT to re-establish. +*/}} +{{- /* +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 -}} +{{- 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 Services under the same hostnames 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 Matrix it is worse than for a stateless app: the /.well-known/matrix delegation tells every OTHER homeserver in the federation where to find this one, and a server name that resolves to two data paths makes federation succeed or fail per remote server and per retry. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and the delegation host moves last, after the homeserver host answers." -}} +{{- 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. ingress.domain is +read outside the routing templates in this chart - the delegation nginx serves +it inside /.well-known/matrix/client - so it already carries a required() of +its own there and stays mandatory with the Ingress off (README, guideline 12, +point 1). This required() is the route's own, so the message names the route. +*/ -}} +{{- $hostnames := $httpRoute.hostnames | default list -}} +{{- if not $hostnames -}} +{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the homeserver 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 +spec: + parentRefs: + {{- toYaml $parentRefs | nindent 4 }} + hostnames: + {{- toYaml $hostnames | nindent 4 }} + rules: + {{- /* + The Ingress counterpart is `path: / , pathType: Prefix` on the Synapse + Service. 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. ci/run-httproute-match-test.sh is what holds the + pair - it fails when a backendRef names a Service, or a port, that the + same render does not create. + */}} + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ .Release.Name }}-{{ .Chart.Name }}-service + port: 80 +{{- if .Values.matrix.delegation.enabled }} +--- +{{- /* +The delegation route, on the SERVER NAME rather than on the homeserver's host. +Gated on matrix.delegation.enabled exactly as the second host rule of the +Ingress is, and for the same reason: without the delegation workload there is +nothing to route to. + +Its hostname deliberately does NOT fall back to httpRoute.hostnames. The +delegation host is matrix.delegation.serverUrl - the Matrix server name, which +is a different thing from the homeserver's hostname and is what remote servers +resolve. Letting a chart-global hostname list cover it would make the two +indistinguishable, and the migration order (homeserver host first, server name +last) depends on telling them apart. httpRoute.delegation.hostnames exists for +the one case where the server name itself has to move. +*/ -}} +{{- $delegation := $httpRoute.delegation | default dict -}} +{{- $delegationHostnames := $delegation.hostnames | default list -}} +{{- if not $delegationHostnames -}} +{{- $delegationHostnames = list (required "If delegation is enabled you must provide a serverUrl: set matrix.delegation.serverUrl (used as the delegation HTTPRoute's single hostname when httpRoute.delegation.hostnames is empty) or set httpRoute.delegation.hostnames explicitly" .Values.matrix.delegation.serverUrl) -}} +{{- end -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-httproute +spec: + {{- /* + Its own parent, falling back to the homeserver route's. The server name may + well be published by a different Gateway than the homeserver host - it is a + domain somebody else's website usually owns - and in that case this is where + it is said. + */}} + parentRefs: + {{- toYaml ($delegation.parentRefs | default $parentRefs) | nindent 4 }} + hostnames: + {{- toYaml $delegationHostnames | nindent 4 }} + rules: + {{- /* + PathPrefix and not Exact, and the same path the Ingress uses: the + delegation nginx serves two documents below it + (/.well-known/matrix/server and /.well-known/matrix/client), so the + prefix has to carry both. NO URLRewrite filter - the delegation nginx's + own config answers on the full path, so nothing is stripped, which is + also why the #118 prefix/redirect assurance has no precondition to meet + here. + */}} + - matches: + - path: + type: PathPrefix + value: /.well-known/matrix + backendRefs: + - name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-service + port: 80 +{{- end }} +{{- end }} diff --git a/matrix-synapse/values.schema.json b/matrix-synapse/values.schema.json index fa03e64..a26e4a0 100644 --- a/matrix-synapse/values.schema.json +++ b/matrix-synapse/values.schema.json @@ -119,10 +119,11 @@ "additionalProperties": false, "properties": { "enabled": { - "description": "Gates the whole Ingress template (issue #93). false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which the delegation nginx also serves in its /.well-known/matrix/* endpoints.", + "description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which the delegation nginx also serves in its /.well-known/matrix/* endpoints and which the HTTPRoute reads as its hostname.", "type": "boolean" }, "domain": { + "description": "The homeserver hostname. Read by the Ingress, by the HTTPRoute (when httpRoute.hostnames is empty) and by the delegation nginx, which serves it inside /.well-known/matrix/client - so it is required in every mode while delegation is on.", "type": ["string", "null"] }, "clusterIssuer": { @@ -132,13 +133,53 @@ "type": ["string", "null"] }, "annotations": { - "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.", + "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. INERT IN GATEWAY MODE: the Gateway API has no annotation channel. This installation's live Ingress carries proxy-body-size 50m (which has no counterpart and must be re-established at the Gateway) and a configuration-snippet of more_set_headers directives (which must NOT be re-established - see values.yaml, httpRoute block).", "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 HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO routes, not one: the Ingress is a single object with two host rules (homeserver at /, delegation at /.well-known/matrix), and an HTTPRoute's hostnames apply to the whole object, so two hostnames wanting different paths and backends must be two objects. NOT COVERED IN THIS MODE: proxy-body-size, which this installation carries at 50m and which Synapse's own max_upload_size has to agree with - see values.yaml, httpRoute block, including why the configuration-snippet on the live object is NOT a loss. 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 homeserver 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 homeserver hostname has a single source of truth. Does NOT cover the delegation host - see httpRoute.delegation.hostnames.", + "type": "array", + "items": { "type": "string" } + }, + "delegation": { + "description": "The delegation route, rendered while matrix.delegation.enabled is true - the same switch the Ingress's second host rule uses.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "parentRefs": { + "description": "Empty falls back to httpRoute.parentRefs. Worth setting when the server name is published by a different Gateway than the homeserver host, which is the normal case - the server name is an apex domain somebody else's website usually owns.", + "type": "array", + "items": { "$ref": "#/definitions/parentRef" } + }, + "hostnames": { + "description": "Empty means [matrix.delegation.serverUrl]. Deliberately does NOT fall back to httpRoute.hostnames: the server name is what remote homeservers resolve and is a different thing from the homeserver's hostname, and the migration order depends on telling them apart.", + "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"] + }, "resources": { "description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.", "type": ["object", "null"], diff --git a/matrix-synapse/values.yaml b/matrix-synapse/values.yaml index 96f0db7..4345a94 100644 --- a/matrix-synapse/values.yaml +++ b/matrix-synapse/values.yaml @@ -155,10 +155,133 @@ ingress: # read by the delegation nginx, which serves it in the /.well-known/matrix/* # endpoints, so it stays required whenever matrix.delegation.enabled is true. # Only clusterIssuer becomes unnecessary. + # + # MUTUALLY EXCLUSIVE WITH httpRoute.enabled - the render aborts when both are + # true, and the message names both value paths. enabled: true domain: null clusterIssuer: null className: nginx # Extra annotations for the Ingress; merged with the cert-manager # cluster-issuer annotation the chart always sets. + # + # INERT IN GATEWAY MODE - the Gateway API has no annotation channel for + # controller behaviour. For THIS chart that is not a formality: read the + # httpRoute block below for what this installation actually has in here and + # which half of it matters. annotations: {} + +# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114). +# +# Mutually exclusive with ingress.enabled - the render aborts when both are +# true. The chart renders ONLY HTTPRoutes 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. +# +# TWO ROUTES, not one. The Ingress is a single object with two HOST rules - the +# homeserver on ingress.domain at "/", and the delegation nginx on +# matrix.delegation.serverUrl at "/.well-known/matrix". An HTTPRoute's +# hostnames apply to the whole object, so two hostnames wanting different paths +# and different backends must be two objects. (#114 describes this chart as +# rendering its Ingresses in a loop over several apps; it does not - that is +# freesailarr.) +# +# ONE THING THIS MODE MAKES EASIER, and it is the awkward part of Matrix +# delegation: the server name is usually an apex domain that already serves a +# website. Under ingress-nginx two Ingress objects cannot both claim one +# hostname - the admission webhook rejects the second - which is why the +# delegation rule has to live inside this chart's single Ingress object. +# HTTPRoutes attached to the same listener for the same hostname are MERGED by +# the implementation, longest match winning, so here the /.well-known/matrix +# route can sit beside whatever owns that apex without either side knowing. +# +# WHAT THIS MODE COSTS, MEASURED ON THE LIVE OBJECT ON 2026-10-12, where the +# Ingress carried - all of it OPERATOR-supplied through ingress.annotations, +# since this chart sets none of it: +# +# nginx.ingress.kubernetes.io/proxy-body-size: 50m +# nginx.org/client-max-body-size: 50m +# nginx.ingress.kubernetes.io/configuration-snippet: | +# more_set_headers "X-Forwarded-For $remote_addr"; +# more_set_headers "X-Forwarded-Proto $scheme"; +# more_set_headers "Host $host:$server_port"; +# +# 1. THE BODY SIZE IS THE REAL LOSS. Synapse's media repository takes uploads +# and its own ceiling is max_upload_size in homeserver.yaml - which this +# chart cannot see, because the whole config comes from 1Password through +# secrets.configSecretRef. The effective limit is the smaller of the two, +# and in this mode the proxy half becomes THE GATEWAY'S default: neither +# number anyone can point at is necessarily the one that decides. The +# Gateway API has no body-size field, so per decision 2 of #114 this is +# documented rather than rebuilt. Set it next to the Gateway, in that +# implementation's own object, and set it to match max_upload_size. +# (nginx.org/client-max-body-size is the same limit spelled for NGINX Inc's +# ingress controller rather than ingress-nginx - a hedge across both - and +# it goes for the same reason.) +# +# 2. THE SNIPPET IS NOT LOAD-BEARING, WHICH TOOK CHECKING. It looks like what +# Synapse's x_forwarded setting depends on - client IP for rate limiting, +# and federation. It is not: more_set_headers is the OUTPUT-header directive +# of headers-more-nginx-module ("Replaces (if any) or adds (if not any) the +# specified output headers"), while the request-side directive is +# more_set_input_headers. These three headers are therefore added to the +# RESPONSE TO THE CLIENT; Synapse never sees them, and what it reads with +# x_forwarded: true is the REQUEST's X-Forwarded-For, which ingress-nginx +# sets itself. +# +# So for the migration: not translatable, and not needed. The Gateway API's +# ResponseHeaderModifier can set static response headers but knows no nginx +# variables ($remote_addr, $scheme, $host), so rebuilding it is impossible +# anyway - and pointless. Do NOT re-create it at the Gateway in the belief +# that Synapse needs it. +# +# Stated as what it is: a reading of the directive's documented semantics. +# Nothing here observed a Synapse instance with and without the snippet. +# +# 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 HOMESERVER 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 homeserver 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. + # + # THE DELEGATION HOST IS NOT COVERED BY THIS LIST, deliberately - see below. + hostnames: [] + + delegation: + # The delegation route's own parent. Empty falls back to + # httpRoute.parentRefs. Worth setting when the SERVER NAME is published by + # a different Gateway than the homeserver host, which is the normal case: + # the server name is an apex domain somebody else's website usually owns. + parentRefs: [] + + # Empty means [matrix.delegation.serverUrl], and it deliberately does NOT + # fall back to httpRoute.hostnames. The server name is a different thing + # from the homeserver's hostname - it is what REMOTE homeservers resolve - + # and the migration order depends on telling them apart: move the + # homeserver host first, confirm it answers, move the server name last. + # A chart-global hostname list covering both would erase that distinction. + # Set this only when the server name itself has to move. + hostnames: []