Files
jklein f202fdc3fc
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 8s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 22s
Install-test / Install-test freesailarr (pull_request) Successful in 4m19s
Validate Helm Charts / Install-test (pull_request) Successful in 4m19s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 12s
SonarQube Analysis / SonarQube Analysis (push) Successful in 48s
fix(freesailarr): redirect the bare /qbittorrent prefix in Gateway mode
qBittorrent has no base-path setting, so the chart serves it under a prefix
and strips that prefix before the request reaches it. For the slashed form
that is correct. For the BARE form - /qbittorrent, no trailing slash - the
request is rewritten to "/" and qBittorrent answers 200 with its login page,
while the browser's document URL keeps the prefixless form. Every relative
link in that page therefore resolves against the host root, and on the shared
host the root is seerr: the login form comes up unstyled and does nothing
while a different application answers its assets and XHRs. Nothing reports an
error at any level.

The shared-domain HTTPRoute now carries a second rule for qBittorrent: an
Exact match on qbittorrent.ingress.path with a RequestRedirect filter that
replaces the full path with the slashed form, statusCode 301. The Gateway API
ranks an Exact path match above every prefix match, so it wins over both
qBittorrent's own PathPrefix rule and seerr's "/" wherever it is emitted - the
rules' length-descending order stays a rendering convention. 301 rather than
308 because only 301 and 302 are Core in that API; an Extended value an
implementation declines makes it set Accepted=False on the WHOLE route, which
would take the four neighbouring apps' routing down with it, and that would
also contradict decision 1 of Gitea #114. statusCode is written out because it
defaults to 302. The rule is guarded on the app being qbittorrent and its path
not being "/", and it reads the path from the value, never a literal.

The INGRESS BRANCH IS UNCHANGED, deliberately. The remedy there is an
nginx configuration-snippet annotation, and the chart cannot ship it: snippets
are ignored unless the controller runs with allow-snippet-annotations=true,
off by default since ingress-nginx 1.9 and only switched on by accepting
annotation risk level "Critical". A shipped annotation would be a silent no-op
on every cluster keeping the default. The working snippet, that price, and why
a third Ingress object with pathType: Exact is not a substitute - buildLocation
in ingress-nginx controller-v1.14.5 returns a regex location in its
enforceRegex branch before reaching the PathTypeExact branch, and enforceRegex
is computed once per nginx server over every location of the hostname, one of
which carries rewrite-target - are all written out in values.yaml instead.

ci/run-httproute-match-test.sh gains a third assertion, per HTTPRoute object:
a rule stripping a prefix with ReplacePrefixMatch requires a rule in the same
route matching that path Exactly and carrying a RequestRedirect filter. This
is what stops the gap reappearing; bulwark is the second rewrite-target chart
and meets the same check when group 3 of Gitea #114 lands. 37 assertions over
15 charts, up from 36. Both fail paths were exercised: the rule deleted, and
the redirect pointed at a path other than the one stripped.

Minor and not major: with the Ingress branch untouched and httpRoute.enabled
defaulting to false, no existing release's render changes by a byte and no
value becomes required. Shown by an empty helm template diff against
origin/main in both Ingress shapes, shared-domain and per-app hosts.

Fixes #118
2026-10-12 20:06:09 +00:00

287 lines
16 KiB
YAML

{{/*
The Gateway-API alternative to freesailarr.ingress.yaml (Gitea #114). Form and
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
Gateway API".
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners. ingress.clusterIssuer, ingress.className,
ingress.annotations and their per-app overrides are all inert in this mode.
THE SAME TWO SHAPES AS THE INGRESS, AND THEY STILL MIX
1. One host per app (the default): every published app gets its own
HTTPRoute with its own hostname, pointing at its own app's Service.
2. One shared domain with sub-paths (ingress.host): the apps that carry no
host of their own move into ONE route with one rule each.
THIS CHART HAS NO ingress.domain, SO httpRoute HAS NO hostnames
It is the documented exception on that point (README, guideline 4): the chart
bundles ten independently exposed apps, so there is no single hostname to put
in a chart-global list - a hostnames entry could not say which app it belonged
to. The hostnames therefore come from the values that already hold them,
ingress.host and <app>.ingress.host, which keeps the single source of truth the
convention is actually about. Those two are not Ingress switches: they name the
public addresses of this release, and both modes read them.
Consequence for a migration: the "second hostname" the convention offers
elsewhere is done here by changing <app>.ingress.host, which in Gateway mode
feeds only the route - there is no Ingress rendered to disagree with it.
WHERE THE GATEWAY API IS BETTER THAN WHAT IT REPLACES, AND IT IS NOT A
TRANSLATION
qBittorrent has no base-path setting: it serves its WebUI at the root and the
proxy has to strip the prefix. nginx applies rewrite-target per INGRESS OBJECT
and not per path, and the other apps must NOT be rewritten - their UrlBase
contains the prefix, so they expect the full path. That is why
freesailarr.ingress.yaml splits qBittorrent into a SECOND Ingress object on the
same host, which then has to
- repeat the same TLS secret name as the main object, so that neither object
depends on an assumption about whether nginx applies the other one's TLS
configuration to its paths,
- OMIT the cert-manager annotation so only one Certificate is requested for
that one secret - except when no main object renders at all, which is a
conditional inside the annotation block, and
- match on an ImplementationSpecific path with a regex
("<path>(/|$)(.*)") whose capture group 2 the rewrite refers to.
The Gateway API's URLRewrite filter is per RULE. All of that disappears: one
route, one rule per app, and qBittorrent's rule carries the filter while its
neighbours do not. The path match goes back to a plain PathPrefix, there is no
second object, no duplicated TLS block, no conditional annotation and no
regex. The convention's own summary says rewrite-target "has a real
equivalent"; in this chart the equivalent is strictly less machinery than the
original, and that is the reason this chart was worth converting rather than
merely translating.
qBittorrent is the one app that contributes a SECOND rule, and it is not part
of the rewrite: a 301 from the bare, slashless prefix to the slashed form
(Gitea #118). Without it qBittorrent answers 200 under a URL whose relative
links resolve to the host root, which on the shared host is a DIFFERENT
application. In Ingress mode the chart does not render that redirect - it
cannot, as the only mechanism nginx offers for it is a snippet annotation that
most clusters refuse (values.yaml, qbittorrent.ingress, has the full price).
This is therefore the one place where the Gateway mode is not merely less
machinery but closes a gap the Ingress mode leaves open.
WHAT IS NOT BETTER: ANYTHING THAT WAS AN ANNOTATION AND IS NOT A FILTER. The
per-app and global ingress.annotations have no counterpart at all here. This
chart sets no body-size annotation of its own, so nothing it NEEDS is lost -
but an operator who added one through ingress.annotations (qBittorrent takes
torrent file uploads) loses it silently in this mode. NOTES.txt says so.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and both defaults are
empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $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 Ingresses and the HTTPRoutes 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. ingress.enabled is this chart's MASTER switch and defaults to true, so turning the Gateway mode on means setting ingress.enabled: false in the same values file. A migration runs over different hostnames (change ingress.host or <app>.ingress.host), never over parallel operation of both modes." -}}
{{- 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 -}}
{{- $sharedHost := .Values.ingress.host | default "" }}
{{- /* The same validated list the Ingress uses, and the same list the UrlBase
env vars read: it is gated on freesailarr.published, so it is populated
in this mode as well. Its two guards - seerr/mediathekarr only at "/",
no duplicate paths on the shared host - therefore apply here unchanged,
which is the whole reason they live in the helper. */}}
{{- $shared := include "freesailarr.sharedIngressApps" $ | fromJsonArray }}
{{- range $app := include "freesailarr.ingressApps" $ | fromJsonArray }}
{{- $config := (index $.Values $app.name).ingress }}
{{- /* Skip the apps that live on the shared domain; they are one rule each in
the single route below. */}}
{{- if or (not $sharedHost) ($config.host | default "") }}
{{- $host := required (printf "%s.ingress.host must be provided while %s.ingress.enabled is true (or set the shared ingress.host to serve the app under a sub-path instead). The value is the app's public hostname and is read in Gateway mode as well - it is not an Ingress switch." $app.name $app.name) $config.host }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
- {{ $host | quote }}
rules:
{{- /* The Ingress counterpart is `path: / , pathType: Prefix` on this
app's own Service. An app on a host of its own is served at the
root, so no rewrite is involved in either mode - that question only
arises under the shared host below.
The port is the Service's port NUMBER and not its name (the Ingress
addresses it by name, which is the app name): backendRefs takes a
PortNumber, so this reference cannot be made by name and the number
has to track freesailarr.service.yaml. Both come from
freesailarr.portApps, which is also what the Service renders from,
so they cannot drift - and
ci/run-httproute-match-test.sh fails when a backendRef names a
Service, or a port, the same render does not create. */}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
port: {{ int $app.port }}
{{- end }}
{{- end }}
{{- if $shared }}
{{/*
The shared-domain route: ONE object for every app under ingress.host, one rule
each, rendered only when at least one app actually contributes a path - so
switching every app off while leaving ingress.host set produces no object
rather than an empty one without rules.
qBittorrent is in here with the others, which is the difference to the Ingress:
a filter in the Gateway API belongs to a RULE, so it contributes TWO rules of
its own and the apps around it stay untouched by construction - a PathPrefix
rule with the URLRewrite filter that strips the prefix, and an Exact rule on
the bare, slashless prefix that redirects 301 to the slashed form (Gitea #118;
the long reasoning is on that rule). No second object, no repeated TLS secret,
no conditional cert-manager annotation, no regex path - see the header.
The rules come sorted by path length descending, as the Ingress paths do, and
qBittorrent's redirect rule follows the rule it corrects. Neither placement
carries meaning: the Gateway API specifies matching precedence by criteria
before order - an Exact match outranks every prefix match, and among prefix
matches the longest wins - and list order is consulted only when all of those
tie. So the ordering here is about a deterministic, reviewable rendering, not
about matching behaviour. That it is ALSO the order nginx would need is a
coincidence of this chart's history and not a property to rely on.
*/}}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
- {{ $sharedHost | quote }}
rules:
{{- range $app := $shared }}
- matches:
- path:
type: PathPrefix
value: {{ $app.path | quote }}
{{- if eq $app.name "qbittorrent" }}
{{- /* qBittorrent has no base-path setting - it serves its WebUI at the
root, so the prefix has to be stripped
(https://github.com/qbittorrent/wiki/blob/master/NGINX-Reverse-Proxy-for-Web-UI.md).
ReplacePrefixMatch replaces exactly the PathPrefix matched above,
which is why the match must be a PathPrefix and not a regex.
The apps around it must NOT be rewritten: their UrlBase carries
the prefix, so they expect the full path. Under an Ingress that
made a second object unavoidable, because rewrite-target applies
per object; here the filter is scoped to this rule and the others
are untouched by construction. */}}
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
{{- end }}
backendRefs:
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
port: {{ int $app.port }}
{{- if and (eq $app.name "qbittorrent") (ne $app.path "/") }}
{{- /* THE BARE PREFIX, WITHOUT THE TRAILING SLASH (Gitea #118)
A request for the bare qbittorrent.ingress.path - "/qbittorrent"
by default, no trailing slash - is matched by the PathPrefix rule
above and the URLRewrite turns it into "/". That is a valid
request to qBittorrent, which answers 200 with its login
page, so nothing anywhere reports a problem. But the browser's
document URL stays prefixless, and a relative link in that page
resolves against the PARENT of that path - which is the host root.
Every asset, every XHR and every form target therefore goes to "/",
and "/" on this shared host is SEERR. The symptom is qBittorrent's
login page that is unstyled and does nothing, while the requests it
makes are answered by a different application entirely - and seerr
answering a qBittorrent request is not a clue anyone follows
quickly. One redirect to the slashed form fixes all of it, because
relative links then resolve against the slashed prefix.
PRECEDENCE IS NOT POSITION. This rule is matched by the Gateway API
on an Exact path, and the specification ranks "Exact path match"
ABOVE "Prefix path match with largest number of characters", with
list order consulted only when every earlier criterion ties
(gateway-api v1.6.3, apis/v1/httproute_types.go). So this rule wins
over the PathPrefix rule above AND over seerr's PathPrefix "/" no
matter where it is emitted. The length-descending sort of the rules
in this route therefore stays what the header says it is - a
deterministic, reviewable rendering - and emitting this rule
directly after the one it corrects is a readability choice, not a
correctness one.
301 AND NOT 308, deliberately, and the reason is not the HTTP
semantics but which values an implementation is obliged to support.
The Gateway API marks 301 and 302 as CORE and everything else -
308 included - as EXTENDED. An implementation that does not support
the value given here must set the Accepted condition of the ROUTE to
status False with reason UnsupportedValue: not of this rule, of the
whole object. On the shared host that is one route carrying every
app under it, so a 308 that one implementation declines takes
prowlarr, radarr, sonarr and seerr down with qBittorrent - and it
does so on a route that is schema-valid, in a status field no
controller writes in CI. It would also contradict decision 1 of
Gitea #114, which is that this chart renders only the subset
that looks identical on nginx-gateway-fabric, Cilium, Envoy
Gateway and Istio, so that the cluster's implementation can be
swapped without touching the chart.
What 308 would buy is method and body preservation, and this rule
does not need it: it fires on exactly one path, the prefixless entry
to the WebUI, which a browser reaches with GET. qBittorrent's API
lives under "<path>/api/..." - slashed, longer, and therefore
served by the PathPrefix rule above, which this rule never sees. A
POST to the API is not redirected at all, so there is no body to
lose.
statusCode MUST BE WRITTEN OUT: it defaults to 302 in the API, and
a temporary redirect here would be re-fetched by every client on
every visit forever, for a prefix that is a permanent property of
this release's layout.
THE ONE ASYMMETRY TO THE nginx SNIPPET (values.yaml, qbittorrent):
HTTPRequestRedirectFilter has exactly five fields - scheme,
hostname, path, port, statusCode - and says nothing about the query
string, while ReplaceFullPath is specified as replacing the full
PATH. The nginx remedy in Ingress mode appends "$is_args$args" and
so carries the query over explicitly; here the query's fate is left
to the implementation and is not something this chart can state.
It costs nothing on the path this rule actually serves - the UI
entry point takes no query parameters - but it is a real difference
and not a translation. */}}
- matches:
- path:
type: Exact
value: {{ $app.path | quote }}
filters:
- type: RequestRedirect
requestRedirect:
path:
type: ReplaceFullPath
replaceFullPath: {{ printf "%s/" $app.path | quote }}
statusCode: 301
{{- end }}
{{- end }}
{{- end }}
{{- end }}