feat(homeassistant): Gateway-API as an alternative to the Ingress

Group 3 of the #114 sweep, and the one chart where the convention had to bend
rather than the chart. homeassistant is the DOCUMENTED INGRESS EXCEPTION
(README): upstream's flexible values, a list of hosts each with its own list
of paths, instead of the repo's required domain/clusterIssuer pair. The
exception stays - nothing here unifies it.

NO httpRoute.hostnames KEY, which is the freesailarr shape from group 2 and
for the same reason: there is no ingress.domain to fall back to, and a
chart-global hostname list could not say which host entry - and therefore
which set of paths - it belonged to. So httpRoute carries only enabled and
parentRefs, the hostnames come from ingress.hosts, and the chart renders ONE
HTTPRoute PER ENTRY with one rule per path of that entry. One object with
several hostnames would be wrong, not merely different: an HTTPRoute's rules
apply to every hostname of the object, and per-host paths are this chart's
whole point. The schema rejects httpRoute.hostnames outright, so the absence
is enforced rather than merely intended.

ingress.hosts is therefore read in BOTH modes and is not an Ingress-only
value. ingress.className, ingress.tls and ingress.annotations are not read in
Gateway mode: class and certificate belong to the Gateway's listener, and
there is no annotation channel.

pathType IS THE SHARP EDGE, AND THE CHART'S DEFAULT IS THE AWKWARD VALUE.
Ingress has Exact/Prefix/ImplementationSpecific; the Gateway API has
Exact/PathPrefix/RegularExpression. Exact and Prefix map cleanly.
ImplementationSpecific - the default in values.yaml - means "ask the
controller" and has NO counterpart, so:

  - on "/" it renders as PathPrefix. Every controller treats a root
    ImplementationSpecific path as a prefix match, so the chart's own default
    works in this mode unchanged (verified: the untouched default renders a
    valid route).
  - on ANY OTHER path the render ABORTS, naming the value and asking for
    Prefix or Exact. In ingress-nginx - this chart's default class - an
    ImplementationSpecific sub-path is a REGEX, and PathPrefix is not one.
    Mapping it silently would change how the path matches while the render
    stayed green, which is exactly the class of defect #118 was.

RegularExpression is deliberately not offered as an escape hatch: its support
level is Implementation-specific, which decision 1 of #114 forbids.

NOTHING WAS MEASURED ON THE LIVE CLUSTER FOR THIS CHART, because there is
nothing to measure: homeassistant is NOT DEPLOYED in jk-cluster (checked
2026-10-12 - 23 Ingress objects cluster-wide, none this chart's, and no
workload of it in any namespace). So it is the one chart of the six with no
operator-supplied annotation to lose. values.yaml and NOTES.txt say that as
an absence of evidence about this installation rather than as a property of
the chart.

Route NAMES carry the entry's INDEX, not its hostname - a hostname is not a
dependable object-name fragment. Consequence named in values.yaml and in the
template: reordering ingress.hosts renames routes and therefore recreates
rather than edits them. Harmless, since a route holds no state and no
certificate.

ci/httproute/homeassistant.yaml has to SUPPLY HOSTS, which no other overlay in
the sweep does, and it adds coverage the install-test never had: this chart's
ingress.enabled defaults to FALSE and the fixture does not switch it on, so
no routing object was exercised at all before. Two entries on purpose - a
plain one, and one with two paths including an Exact - because that second
entry is what proves the chart renders one object per host rather than one
object with several hostnames. 5 assertions over 2 routes and 3 rules.

NOTES.txt asks $published where it asked ingress.enabled, and the
trusted_proxies section now warns that moving from an Ingress to a Gateway
CHANGES the proxy's pod network: a list carried over unchanged turns every
request into a 400 if the CIDR differs, or silently keeps attributing every
client to one address if it does not.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main, both with the fixture as it stands and with ingress.enabled=true.

Part of #114
This commit is contained in:
jklein authored and jklein committed 2026-10-13 08:59:09 +00:00
1 parent 3e969c5659
commit b10800da2a
6 files changed
+453 -22

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
# 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/homeassistant/test-values.yaml, and here
# that is more than the usual reason. This chart's ingress.enabled DEFAULT IS
# false and the install-test fixture does not switch it on, so the install
# exercises no routing object at all - as with garage. There is nothing to
# trade away: this overlay ADDS coverage that the install-test never had,
# rather than swapping one object for another.
#
# ingress.enabled: false is restated anyway. It is already the default, but the
# two modes are mutually exclusive and the chart aborts its own render when
# both are true, so writing it down here keeps this overlay correct if that
# default ever changes.
#
# AND THIS OVERLAY HAS TO SUPPLY HOSTS, which no other overlay in the sweep
# does. This chart is the DOCUMENTED INGRESS EXCEPTION: it has no
# ingress.domain, so there is no httpRoute.hostnames either - the hostnames and
# the paths come from ingress.hosts, and the chart renders ONE HTTPRoute PER
# ENTRY there. The chart's default entry is the placeholder
# "chart-example.local" with pathType ImplementationSpecific, which WOULD
# render (the chart maps ImplementationSpecific to PathPrefix on "/"), but it
# would exercise exactly one route with one rule and would leave the
# interesting half of this chart's flexible style untested.
#
# So the list below is deliberate, and each entry earns its place:
#
# 1. ha.ci.local with "/" and pathType Prefix - the ordinary case, and the
# clean Prefix -> PathPrefix mapping.
# 2. ha-alt.ci.local with TWO paths, one Prefix and one Exact - this is the
# part that cannot be expressed in a single HTTPRoute together with entry
# 1, because an HTTPRoute's rules apply to EVERY hostname of the object.
# It is therefore the test that the chart really renders one object per
# host entry rather than one object with several hostnames, and it
# exercises the Exact mapping and the multi-rule path at the same time.
#
# Both entries' rules are checked against the Service of the same render. The
# reference is by PORT NUMBER (service.port) rather than by the name "http" the
# Ingress uses, because backendRefs takes a PortNumber - so this overlay is
# what holds that number against the Service's.
#
# NOT covered here, and it cannot be:
#
# - that either hostname answers. No Gateway controller runs in CI and no
# route reaches "Accepted: True".
# - the pathType ImplementationSpecific ABORT on a non-root path. That is a
# render-failure path, and this test only runs renders it expects to
# succeed; it was exercised by hand instead (see the PR).
# - that Home Assistant accepts the Host header or reads X-Forwarded-For.
# Both live in the application's own configuration, which arrives from
# 1Password, and changing the data path changes the proxy address the
# trusted_proxies list has to name. NOTES.txt says so; nothing can assert
# it here.
#
# No URLRewrite filter is rendered by this chart in either mode, so the #118
# prefix/redirect assurance has no precondition to meet here.
ingress:
enabled: false
hosts:
- host: ha.ci.local
paths:
- path: /
pathType: Prefix
- host: ha-alt.ci.local
paths:
- path: /
pathType: Prefix
- path: /api/websocket
pathType: Exact
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+1 -1
View File
@@ -4,7 +4,7 @@ description: A Helm chart to deploy Home Assistant on Kubernetes
icon: https://www.home-assistant.io/images/home-assistant-logo.svg icon: https://www.home-assistant.io/images/home-assistant-logo.svg
type: application type: application
version: 6.1.1+up2026.8.3 version: 6.2.0+up2026.8.3
appVersion: "2026.8.3" appVersion: "2026.8.3"
+102 -19
View File
@@ -18,7 +18,20 @@ print as "1e+06" here just as it did in the rendered manifest.
{{- $ctr := fromYaml (include "homeassistant.containerSecurityContext" .Values.securityContext) -}} {{- $ctr := fromYaml (include "homeassistant.containerSecurityContext" .Values.securityContext) -}}
{{- $caps := (index $ctr "capabilities") | default dict -}} {{- $caps := (index $ctr "capabilities") | default dict -}}
{{- $netRaw := has "NET_RAW" ((index $caps "add") | default list) -}} {{- $netRaw := has "NET_RAW" ((index $caps "add") | default list) -}}
{{- /*
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.
*/ -}}
{{- $ing := .Values.ingress -}} {{- $ing := .Values.ingress -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
both read ingress.hosts (Gitea #114).
*/ -}}
{{- $published := or $ing.enabled $httpRoute.enabled -}}
{{- $hosts := $ing.hosts | default list -}} {{- $hosts := $ing.hosts | default list -}}
{{- $tls := $ing.tls | default list -}} {{- $tls := $ing.tls | default list -}}
{{- $ann := $ing.annotations | default dict -}} {{- $ann := $ing.annotations | default dict -}}
@@ -33,7 +46,7 @@ Home Assistant {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namesp
{{- if $ing.enabled }} {{- if $ing.enabled }}
ACCESS ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
{{- range $h := $hosts }} {{- range $h := $hosts }}
{{- $scheme := "http" }} {{- $scheme := "http" }}
{{- range $t := $tls }} {{- range $t := $tls }}
@@ -75,11 +88,73 @@ ACCESS
kubectl -n {{ .Release.Namespace }} get certificate {{ $certName | default "<your ingress.tls secretName>" }} kubectl -n {{ .Release.Namespace }} get certificate {{ $certName | default "<your ingress.tls secretName>" }}
{{- end }} {{- end }}
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $h := $hosts }}
{{- range $p := ($h.paths | default list) }}
https://{{ $h.host }}{{ $p.path }}
{{- end }}
{{- end }}
ONE HTTPRoute PER ENTRY of ingress.hosts, and nothing else of the routing
path — no Gateway, no class, no Certificate, no policy object:
{{- range $i, $h := $hosts }}
{{ $.Release.Name }}-{{ $.Chart.Name }}-httproute{{ if $i }}-{{ $i }}{{ end }} {{ $h.host }}
{{- end }}
This chart has no ingress.domain — it is the documented ingress exception and
keeps upstream's flexible host list — so there is no httpRoute.hostnames
either: the hostnames and the paths above come from ingress.hosts, which is
read in BOTH modes. ingress.className, ingress.tls and ingress.annotations
are NOT read in this mode.
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do 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 certificates for those hostnames, on the Gateway's listeners.
THE URLS ABOVE SAY https BECAUSE THAT IS WHAT A GATEWAY LISTENER
NORMALLY TERMINATES, not because this release arranged it: ingress.tls
is not read in this mode and this chart requests no certificate in
either mode (see the Ingress-mode text — it never did).
WHAT IS GONE IN THIS MODE, generally rather than specifically: the Gateway
API has no annotation channel for controller behaviour, so everything in
ingress.annotations is inert. For THIS chart there is no measured loss to
report — the installation this repository serves does not deploy
homeassistant at all, so unlike its five siblings in this sweep it had no
operator-supplied annotation to lose. That is an absence of evidence about
that installation, not a property of the chart: whatever YOU put in
ingress.annotations disappears here.
AND ONE THING TO CHECK IN HOME ASSISTANT ITSELF, because no Kubernetes object
reports it: Home Assistant refuses requests whose Host header is not in its
own configuration and builds absolute URLs from it, and it needs the proxy's
address in trusted_proxies to read X-Forwarded-For at all. Changing the data
path changes that address — the Gateway's data-plane pods are not the ingress
controller's. See the trusted-proxies section below; it applies to the
Gateway exactly as it did to ingress-nginx, with a different CIDR.
{{- else }} {{- else }}
ACCESS — NO INGRESS ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled is false, so this release has no Ingress. Nothing else in ingress.enabled and httpRoute.enabled are both false, so this release
this chart reads ingress.hosts, so no other setting silently depends on it. publishes nothing. Nothing else in this chart reads ingress.hosts, so no
other setting silently depends on it.
Reach the instance inside the cluster with Reach the instance inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8123:{{ .Values.service.port }} kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8123:{{ .Values.service.port }}
@@ -89,21 +164,25 @@ ACCESS — NO INGRESS
because a browser that has already talked to another Home Assistant on because a browser that has already talked to another Home Assistant on
localhost:8123 will otherwise mix up the stored tokens. localhost:8123 will otherwise mix up the stored tokens.
{{- end }} {{- end }}
{{- if and $ing.enabled $placeholder }} {{- if and $published $placeholder }}
THE INGRESS IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST THIS RELEASE IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
ingress.enabled is true while ingress.hosts still contains the default entry {{ if $ing.enabled }}ingress.enabled{{ else }}httpRoute.enabled{{ end }} is true while ingress.hosts still contains the default entry
"chart-example.local". That name resolves nowhere, so the Ingress exists, "chart-example.local". That name resolves nowhere, so the {{ if $ing.enabled }}Ingress{{ else }}HTTPRoute{{ end }} exists,
ingress-nginx accepts it, and no request ever arrives. Set ingress.hosts to {{ if $ing.enabled }}ingress-nginx accepts it{{ else }}the Gateway accepts it{{ end }}, and no request ever arrives. Set ingress.hosts to
the name you actually serve — for example example.com — before assuming the the name you actually serve — for example example.com — before assuming the
release is unreachable for some deeper reason. release is unreachable for some deeper reason.
{{ if $httpRoute.enabled }}ingress.hosts is read in Gateway mode too; it is not an "Ingress-only"
value.{{ end }}
{{- end }} {{- end }}
{{ if eq $replicas "0" }} {{ if eq $replicas "0" }}
SCALED TO ZERO SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers {{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers
until you set it back and upgrade again. The PVC, the OnePasswordItem and until you set it back and upgrade again. The PVC, the OnePasswordItem and
the Service{{ if $ing.enabled }} and Ingress stay in place, so no state is lost and no the Service{{ if $ing.enabled }} and Ingress stay in place, so no state is lost and no
certificate is re-issued.{{ else }} stay in place, so no state is lost.{{ end }} certificate is re-issued.{{ else if $httpRoute.enabled }} and the HTTPRoutes stay in place, so no state is
lost — the routes keep pointing at a Service with no endpoints, and the
Gateway answers with an error of its own rather than with nothing.{{ else }} stay in place, so no state is lost.{{ end }}
Bringing this release back up is not a no-op: the pod re-reads the config Bringing this release back up is not a no-op: the pod re-reads the config
item from the vault as it starts, and it starts on whatever app version the item from the vault as it starts, and it starts on whatever app version the
@@ -155,23 +234,27 @@ THE CONFIGURATION IS A 1PASSWORD ITEM, AND IT IS READ-ONLY IN THE CONTAINER
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }} kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }}
BEHIND AN INGRESS, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs BEHIND A PROXY, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
Every request reaching Home Assistant through ingress-nginx arrives from the Every request reaching Home Assistant through {{ if $httpRoute.enabled }}the Gateway{{ else }}ingress-nginx{{ end }} arrives from
controller's pod IP, not from the browser. The http-config field is where {{ if $httpRoute.enabled }}its data-plane pod IP{{ else }}the controller's pod IP{{ end }}, not from the browser. The http-config field is
that is dealt with, and both ways of getting it wrong are quiet in their own where that is dealt with, and both ways of getting it wrong are quiet in
way: their own way:
use_x_forwarded_for: true without the proxy's address in trusted_proxies use_x_forwarded_for: true without the proxy's address in trusted_proxies
makes Home Assistant reject the request outright — the browser gets makes Home Assistant reject the request outright — the browser gets
400 Bad Request and the log names the untrusted address it saw. 400 Bad Request and the log names the untrusted address it saw.
Neither setting at all means every client looks like the ingress Neither setting at all means every client looks like {{ if $httpRoute.enabled }}the Gateway{{ else }}the ingress
controller. Nothing fails; you simply get one address in the logbook for controller{{ end }}. Nothing fails; you simply get one address in the logbook for
everyone, and Home Assistant's login-attempt ban then locks out all users everyone, and Home Assistant's login-attempt ban then locks out all users
at once because they share that address. at once because they share that address.
The value belongs to the ingress controller's POD network, not to the nodes The value belongs to the {{ if $httpRoute.enabled }}GATEWAY's{{ else }}ingress controller's{{ end }} POD network, not to the nodes
and not to the clients: and not to the clients{{ if $httpRoute.enabled }} — and switching from an Ingress to a Gateway CHANGES
that network, because a Gateway's data plane normally runs in a namespace of
its own. A trusted_proxies list carried over unchanged from Ingress mode
turns every request into a 400 if the pod CIDR differs, or silently keeps
attributing all clients to one address if it does not{{ end }}:
use_x_forwarded_for: true use_x_forwarded_for: true
trusted_proxies: trusted_proxies:
@@ -0,0 +1,164 @@
{{/*
HTTPRoute - the Gateway-API alternative to homeassistant.ingress.yaml
(Gitea #114), AND THE ONE CHART IN THE SWEEP WHERE THE CONVENTION HAD TO BEND
RATHER THAN THE CHART.
This chart is the DOCUMENTED INGRESS EXCEPTION (README): it keeps upstream's
flexible ingress values - a LIST of hosts, each with its own LIST of paths,
plus a separate tls list - instead of the repository's required
domain/clusterIssuer pair. The exception stays. Nothing here unifies it, and
the Gateway support is built on top of it rather than next to it.
THIS CHART HAS NO ingress.domain, SO httpRoute HAS NO hostnames KEY
That is the freesailarr precedent from group 2, and the reason is the same: a
chart-global hostname list could not say which host entry - and therefore which
set of paths - it belonged to. So the hostnames come from the values that
already hold them, ingress.hosts[].host, and httpRoute carries only `enabled`
and `parentRefs`. One HTTPRoute PER HOST ENTRY, with one rule per path of that
entry, because an HTTPRoute's rules apply to every hostname of the object and
this chart's whole point is that the paths may differ per host.
ingress.hosts IS READ IN BOTH MODES, then, and it is not an "Ingress" value any
more than ingress.domain is in the other charts - it is where this release's
public names live. ingress.tls and ingress.className are NOT read here: the
certificate and the class sit on the Gateway and its listeners and belong to
the platform (decision 1 of #114). ingress.annotations is inert - the Gateway
API has no annotation channel.
Nothing was measured on the live cluster for this chart, because there is
nothing to measure: homeassistant is NOT DEPLOYED in jk-cluster (checked
2026-10-12 - 23 Ingress objects cluster-wide, none of them this chart's, and no
workload of it in any namespace). So unlike its five siblings in this group it
carries no operator-supplied annotation that this mode would drop. That is an
absence of evidence about THIS installation, not a demonstration that the chart
needs none: its ingress.annotations default is {} and the chart sets nothing
itself, so whatever a future installation puts there would be dropped in this
mode exactly as elsewhere.
pathType HAS NO CLEAN COUNTERPART, AND THE CHART'S DEFAULT IS THE AWKWARD ONE
Ingress pathType has three values; the Gateway API's path match types are
Exact, PathPrefix and RegularExpression. The mapping:
Exact -> Exact
Prefix -> PathPrefix
ImplementationSpecific -> NOTHING. It means "ask the controller", so there
is no value that preserves it.
And ImplementationSpecific is this chart's DEFAULT (values.yaml,
ingress.hosts[0].paths[0].pathType). Two rules therefore, and the split is not
tidiness:
- On the path "/", ImplementationSpecific is rendered as PathPrefix. Every
controller treats a root ImplementationSpecific path as a prefix match, so
the two coincide and the chart's default works in this mode out of the box.
- On ANY OTHER path, ImplementationSpecific ABORTS THE RENDER. In
ingress-nginx - the class this chart defaults to - an
ImplementationSpecific non-root path is a REGEX, and PathPrefix is not a
regex. Mapping it silently would change matching behaviour while the render
stayed green, which is precisely the class of defect #118 was: a chart that
looks translated and routes differently. So the operator is asked to say
which of Exact or Prefix was meant.
RegularExpression is deliberately NOT offered as an escape hatch: its support
level is Implementation-specific, which would bind this chart to one Gateway
implementation and is what decision 1 of #114 forbids.
*/}}
{{- /*
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 HTTPRoutes would publish the same Service 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 Home Assistant there is a second edge: it refuses requests whose Host header is not in its own trusted/allowed list and builds absolute URLs from it, so a client arriving over the other path can be rejected by the application rather than by the proxy. A migration runs over a DIFFERENT hostname - add a second entry to ingress.hosts and switch the modes once it answers - 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 -}}
{{- $hosts := .Values.ingress.hosts | default list -}}
{{- if not $hosts -}}
{{- fail "httpRoute.enabled is true but ingress.hosts is empty - there is no hostname to publish and the release would render no route at all, silently. This chart has no ingress.domain (it is the documented ingress exception and keeps upstream's flexible host list), so ingress.hosts is where its public names live and it is read in BOTH modes. Set at least one entry, e.g. ingress.hosts: [{host: ha.example.com, paths: [{path: /, pathType: Prefix}]}]." -}}
{{- end -}}
{{- $first := true -}}
{{- range $i, $entry := $hosts }}
{{- $host := required "Every entry of ingress.hosts needs a host - an HTTPRoute with an empty hostname matches every request that reaches its listener, which on a shared Gateway means this release answers for hostnames belonging to other releases" $entry.host -}}
{{- $paths := $entry.paths | default list -}}
{{- if not $paths -}}
{{- fail (printf "ingress.hosts[%d] (%s) has no paths - the HTTPRoute would carry no rules, and a route with no rules matches nothing while being perfectly valid and Accepted. Give it at least one, e.g. paths: [{path: /, pathType: Prefix}]" $i $host) -}}
{{- end -}}
{{- if not $first }}
---
{{- end }}
{{- $first = false -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
{{- /*
ONE ROUTE PER HOST ENTRY, so the name has to carry the entry's identity. The
index and not the hostname: a hostname is not a legal object-name fragment
(dots are legal in a name but a 63-character label limit and the leading
"<release>-<chart>-" prefix are not something a hostname can be trusted to
fit), and a sanitised hostname would collide for two hosts differing only in
a character that sanitising removes. The index is stable for a given values
file, and REORDERING ingress.hosts THEREFORE RENAMES ROUTES - which deletes
and recreates them on the next upgrade rather than editing them. Harmless
here (a route carries no state and no certificate), and named so it is not a
surprise.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-httproute{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
- {{ $host | quote }}
rules:
{{- range $paths }}
{{- /*
pathType mapping, with the rule from the header: ImplementationSpecific -
this chart's default - becomes PathPrefix on "/" only, and aborts the
render on any other path rather than silently changing how it matches. An
absent pathType is treated the same way; note that pathType is a REQUIRED
field of networking.k8s.io/v1 Ingress, so an entry without it renders an
Ingress the API server rejects - a pre-existing gap of the Ingress
template, not something this one introduces.
*/}}
{{- $given := .pathType | default "ImplementationSpecific" -}}
{{- $path := required "Every entry of ingress.hosts[].paths needs a path" .path -}}
{{- $type := "" -}}
{{- if eq $given "Exact" -}}
{{- $type = "Exact" -}}
{{- else if eq $given "Prefix" -}}
{{- $type = "PathPrefix" -}}
{{- else if eq $given "ImplementationSpecific" -}}
{{- if eq $path "/" -}}
{{- $type = "PathPrefix" -}}
{{- else -}}
{{- fail (printf "ingress.hosts[%d] (%s) has a path %q with pathType ImplementationSpecific, which has NO counterpart in the Gateway API - it means \"ask the controller\", and the Gateway API offers only Exact, PathPrefix and RegularExpression. On \"/\" the chart maps it to PathPrefix because every controller agrees there, but on a sub-path ImplementationSpecific is a REGEX in ingress-nginx (this chart's default class) and PathPrefix is not one, so mapping it would change how the path matches while the render stayed green. Say which you mean: pathType: Prefix or pathType: Exact. RegularExpression is deliberately not offered - its support level is Implementation-specific and would bind this chart to one Gateway implementation (decision 1 of Gitea #114)" $i $host $path) -}}
{{- end -}}
{{- else -}}
{{- fail (printf "ingress.hosts[%d] (%s) has a path %q with an unknown pathType %q - Ingress knows Exact, Prefix and ImplementationSpecific" $i $host $path $given) -}}
{{- end }}
- matches:
- path:
type: {{ $type }}
value: {{ $path | quote }}
backendRefs:
{{- /*
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 service.port, the same value
the Service renders, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they
do.
*/}}
- name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-service"
port: {{ $.Values.service.port | int64 }}
{{- end }}
{{- end }}
{{- end }}
+23 -2
View File
@@ -31,7 +31,7 @@
} }
}, },
"ingress": { "ingress": {
"description": "Documented exception (README): this chart keeps the upstream-style flexible ingress values - host and tls lists instead of the repo's required domain/clusterIssuer pair. The list entries go verbatim into the Ingress spec, so they stay open.", "description": "Documented exception (README): this chart keeps the upstream-style flexible ingress values - host and tls lists instead of the repo's required domain/clusterIssuer pair. The list entries go verbatim into the Ingress spec, so they stay open. Mutually exclusive with httpRoute.enabled (Gitea #114) - the render aborts when both are true.",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -39,20 +39,36 @@
"type": "boolean" "type": "boolean"
}, },
"className": { "className": {
"description": "Set to \"\" to omit ingressClassName from the rendered Ingress.", "description": "Set to \"\" to omit ingressClassName from the rendered Ingress. Not read in Gateway mode: the class is a property of the Gateway there, and the Gateway belongs to the platform.",
"type": ["string", "null"] "type": ["string", "null"]
}, },
"annotations": { "annotations": {
"description": "Not read in Gateway mode: the Gateway API has no annotation channel for controller behaviour, so anything set here is gone in that mode. The chart sets nothing itself.",
"type": "object" "type": "object"
}, },
"hosts": { "hosts": {
"description": "The public names of this release, READ IN BOTH MODES - this chart has no ingress.domain, so there is no httpRoute.hostnames either and the hostnames live here. In Gateway mode the chart renders one HTTPRoute per entry, with one rule per path of that entry. pathType maps Exact to Exact and Prefix to PathPrefix; ImplementationSpecific (the default below) has no Gateway-API counterpart and is rendered as PathPrefix on \"/\" only - on any other path the render aborts rather than silently changing how the path matches, because in ingress-nginx an ImplementationSpecific sub-path is a regex. RegularExpression is not offered: its support level is Implementation-specific.",
"type": ["array", "null"] "type": ["array", "null"]
}, },
"tls": { "tls": {
"description": "Not read in Gateway mode: the certificate sits on the Gateway's listener and belongs to the platform.",
"type": ["array", "null"] "type": ["array", "null"]
} }
} }
}, },
"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 KEYS ONLY, and the missing hostnames key is deliberate: this chart is the documented ingress exception and has no ingress.domain, so the hostnames and paths come from ingress.hosts and the chart renders ONE route per entry there (the freesailarr shape from group 2 of the sweep). Reordering ingress.hosts renames routes, which recreates rather than edits them - harmless, since a route holds no state. 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) every route of this release 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. The same list is used for every route; the hosts differ, the Gateway does not.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
}
}
},
"securityContext": { "securityContext": {
"$ref": "#/definitions/securityContext" "$ref": "#/definitions/securityContext"
}, },
@@ -125,6 +141,11 @@
} }
}, },
"definitions": { "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": { "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.", "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"], "type": ["object", "null"],
+84
View File
@@ -17,22 +17,106 @@ service:
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress: ingress:
# MUTUALLY EXCLUSIVE WITH httpRoute.enabled - the render aborts when both are
# true, and the message names both value paths.
enabled: false enabled: false
# Ingress class; set to "" to omit ingressClassName from the rendered Ingress. # Ingress class; set to "" to omit ingressClassName from the rendered Ingress.
# NOT READ IN GATEWAY MODE: the class is a property of the Gateway there, and
# the Gateway belongs to the platform.
className: nginx className: nginx
# NOT READ IN GATEWAY MODE either, and this is the one to be aware of: the
# Gateway API has no annotation channel for controller behaviour, so
# everything put here is simply gone in that mode. This chart sets nothing
# itself, and the installation this repository serves does not deploy
# homeassistant at all (checked 2026-10-12), so there is no operator-supplied
# annotation here today that the switch would drop - unlike its siblings in
# the same sweep. That is an absence of evidence about this installation, not
# a guarantee about yours: whatever you put here disappears in Gateway mode.
annotations: {} annotations: {}
# kubernetes.io/ingress.class: nginx # kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true" # kubernetes.io/tls-acme: "true"
# THE PUBLIC NAMES OF THIS RELEASE, AND READ IN BOTH MODES.
#
# This chart is the documented ingress exception (README): it keeps upstream's
# flexible host list rather than the repository's required
# domain/clusterIssuer pair, and that exception stays. The consequence for
# Gitea #114 is that httpRoute has NO hostnames key - a chart-global hostname
# list could not say which host entry, and therefore which set of paths, it
# belonged to. So this list is where the hostnames live in BOTH modes, and in
# Gateway mode the chart renders ONE HTTPRoute PER ENTRY, with one rule per
# path of that entry.
#
# pathType IN GATEWAY MODE, because the default below is the awkward value.
# Ingress has Exact, Prefix and ImplementationSpecific; the Gateway API has
# Exact, PathPrefix and RegularExpression. Exact and Prefix map cleanly.
# ImplementationSpecific means "ask the controller" and has NO counterpart,
# so:
# - on the path "/" it is rendered as PathPrefix (every controller treats a
# root ImplementationSpecific path as a prefix match, so the default
# below works in Gateway mode unchanged);
# - on ANY OTHER path the render ABORTS and asks you to say which you mean.
# In ingress-nginx an ImplementationSpecific sub-path is a REGEX and
# PathPrefix is not one, so mapping it silently would change how the path
# matches while the render stayed green.
# RegularExpression is deliberately not offered: its support level is
# Implementation-specific and would bind this chart to one Gateway
# implementation.
hosts: hosts:
- host: chart-example.local - host: chart-example.local
paths: paths:
- path: / - path: /
pathType: ImplementationSpecific pathType: ImplementationSpecific
# NOT READ IN GATEWAY MODE: the certificate sits on the Gateway's listener.
tls: [] tls: []
# - secretName: chart-example-tls # - secretName: chart-example-tls
# hosts: # hosts:
# - chart-example.local # - chart-example.local
# 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 KEYS ONLY, and the missing third one is deliberate: there is no
# httpRoute.hostnames here, because this chart has no ingress.domain either.
# The hostnames and the paths come from ingress.hosts above - see the comment
# there, including the pathType mapping. This is the freesailarr shape from
# group 2 of the sweep, for the same reason.
#
# ONE HTTPRoute PER ENTRY of ingress.hosts, named
# <release>-homeassistant-httproute for the first and ...-httproute-<index> for
# the rest. REORDERING ingress.hosts RENAMES ROUTES and so deletes and
# recreates them on the next upgrade instead of editing them - harmless,
# because a route holds no state and no certificate, but worth knowing before
# it looks like a bug.
#
# 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) EVERY route of this release 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. The same list is used for every route
# this chart renders; the hosts differ, the Gateway does not.
parentRefs: []
# Documented deviation from the repo-wide hardening standard: the official # Documented deviation from the repo-wide hardening standard: the official
# Home Assistant container must start as root — it boots via the s6-overlay # Home Assistant container must start as root — it boots via the s6-overlay
# init system, which performs its setup as uid 0 (the image supports no # init system, which performs its setup as uid 0 (the image supports no