Files
helm-charts/homeassistant/templates/NOTES.txt
T
jklein d860704172 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
2026-10-12 21:16:03 +00:00

375 lines
20 KiB
Plaintext

{{- $replicas := include "homeassistant.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) -}}
{{- $res := fromYaml (include "homeassistant.effectiveResources" .Values.resources) -}}
{{/*
The ephemeral-storage limit goes through formatNumber for the reason issue #157
documents: it comes back from fromYaml as float64, and a bare byte count would
print as "1e+06" here just as it did in the rendered manifest.
*/}}
{{- $ephRaw := index ($res.limits | default dict) "ephemeral-storage" -}}
{{- $eph := "the computed ephemeral-storage limit" -}}
{{- if not (kindIs "invalid" $ephRaw) -}}
{{- $eph = include "homeassistant.formatNumber" $ephRaw -}}
{{- end -}}
{{- $startup := fromYaml (include "homeassistant.startupProbe" .Values.startupProbe) -}}
{{- $budget := "" -}}
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
{{- $budget = printf "%.0f seconds" (mulf $startup.periodSeconds $startup.failureThreshold) -}}
{{- end -}}
{{- $ctr := fromYaml (include "homeassistant.containerSecurityContext" .Values.securityContext) -}}
{{- $caps := (index $ctr "capabilities") | default dict -}}
{{- $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 -}}
{{- $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 -}}
{{- $tls := $ing.tls | default list -}}
{{- $ann := $ing.annotations | default dict -}}
{{- $issuer := or (hasKey $ann "cert-manager.io/cluster-issuer") (hasKey $ann "cert-manager.io/issuer") -}}
{{- $certName := "" -}}
{{- range $t := $tls }}{{ if not $certName }}{{ $certName = ($t.secretName | default "") }}{{ end }}{{ end }}
{{- $placeholder := false -}}
{{- range $h := $hosts }}{{ if eq ($h.host | default "") "chart-example.local" }}{{ $placeholder = true }}{{ end }}{{ end }}
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
Home Assistant {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if $ing.enabled }}
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
{{- range $h := $hosts }}
{{- $scheme := "http" }}
{{- range $t := $tls }}
{{- if has ($h.host | default "") ($t.hosts | default list) }}{{ $scheme = "https" }}{{ end }}
{{- end }}
{{- range $p := ($h.paths | default list) }}
{{ $scheme }}://{{ $h.host }}{{ $p.path }}
{{- end }}
{{- end }}
This chart keeps the upstream-style ingress block — hosts and tls lists
instead of the domain/clusterIssuer pair the other charts in this repo use.
That is a documented exception, and it has one consequence worth knowing
before you go looking for a certificate:
{{- if not $tls }}
ingress.tls is empty, so the Ingress carries no TLS section at all and the
URLs above are plain http. Nothing here requests a certificate.
{{- else if not $issuer }}
ingress.tls names a secretName, but NOTHING IN THIS RELEASE CREATES THAT
SECRET. The chart adds no cert-manager annotation of its own — unlike the
other charts here, which build one from ingress.clusterIssuer — and
ingress.annotations carries neither cert-manager.io/cluster-issuer nor
cert-manager.io/issuer. Until that Secret exists by some other means,
ingress-nginx answers with its own default certificate and every browser
shows a name mismatch. Add the annotation under ingress.annotations:
ingress:
annotations:
cert-manager.io/cluster-issuer: letsencrypt
{{- else }}
The certificate is requested by cert-manager through the annotation you set
under ingress.annotations; the chart adds none by itself, and it does not
name the TLS Secret either — you did, in ingress.tls. cert-manager's
ingress-shim names the Certificate after that Secret, so this is the object
to watch while the browser still shows a warning:
kubectl -n {{ .Release.Namespace }} get certificate {{ $certName | default "<your ingress.tls secretName>" }}
{{- 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 }}
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release
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
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8123:{{ .Values.service.port }}
and open http://localhost:8123. The Service listens on port {{ .Values.service.port }} and
forwards to the container's 8123; keeping 8123 as the LOCAL port matters,
because a browser that has already talked to another Home Assistant on
localhost:8123 will otherwise mix up the stored tokens.
{{- end }}
{{- if and $published $placeholder }}
THIS RELEASE IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
{{ 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 {{ if $ing.enabled }}Ingress{{ else }}HTTPRoute{{ end }} exists,
{{ 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
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 }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ 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
the Service{{ if $ing.enabled }} and Ingress stay in place, so no state is lost and no
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
item from the vault as it starts, and it starts on whatever app version the
chart carries now. A release parked at zero for a long time therefore comes
back changed in two ways at once.
{{ end }}
THE CONFIGURATION IS A 1PASSWORD ITEM, AND IT IS READ-ONLY IN THE CONTAINER
Home Assistant is configured from files, not from the environment, and this
chart delivers those files out of a single 1Password item. The operator
copies the item's FIELDS into the Kubernetes Secret, one key per field name,
and this chart mounts exactly three of them. The field names are decided
here and appear nowhere in 1Password, so they have to match to the letter:
secrets.configSecretRef → {{ .Values.secrets.configSecretRef | default "(not set — the render fails without it)" }}
→ Secret {{ .Release.Name }}-{{ .Chart.Name }}-config-secret
config → /config/configuration.yaml
http-config → /config/http-config.yaml
recorder-config → /config/recorder-config.yaml
The three are separate files on purpose, and configuration.yaml has to pull
the other two in itself — the chart only mounts them, it does not wire them
together. A working minimum for the "config" field is
default_config:
http: !include http-config.yaml
recorder: !include recorder-config.yaml
A field spelled differently yields a Secret without that key. The pod then
hangs in CreateContainerConfigError while the Secret itself exists, so it
looks as if the operator had done its job. What it actually delivered:
kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-{{ .Chart.Name }}-config-secret -o jsonpath='{.data}'
All three are mounted with subPath from a Secret volume, which makes them
READ-ONLY inside the container. Home Assistant's own file editor and every
UI page that offers to write configuration.yaml will fail on these three
paths — and only on these three. Everything else under /config is on the
PVC and writable as usual.
Pointing secrets.configSecretRef at a DIFFERENT item rolls the pod: the
StatefulSet carries a checksum over the rendered OnePasswordItem, so the
item path is part of the pod spec. Changing the CONTENT of the same item
does not, and this is the sharp edge — Helm never sees that content, and a
subPath mount is never refreshed by the kubelet even after the operator has
updated the Secret. The running pod keeps the old files until something
else restarts it, which may be months later, so a broken value surfaces far
away from the edit that caused it. After every content change:
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }}
BEHIND A PROXY, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
Every request reaching Home Assistant through {{ if $httpRoute.enabled }}the Gateway{{ else }}ingress-nginx{{ end }} arrives from
{{ if $httpRoute.enabled }}its data-plane pod IP{{ else }}the controller's pod IP{{ end }}, not from the browser. The http-config field is
where that is dealt with, and both ways of getting it wrong are quiet in
their own way:
use_x_forwarded_for: true without the proxy's address in trusted_proxies
makes Home Assistant reject the request outright — the browser gets
400 Bad Request and the log names the untrusted address it saw.
Neither setting at all means every client looks like {{ if $httpRoute.enabled }}the Gateway{{ else }}the ingress
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
at once because they share that address.
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{{ 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
trusted_proxies:
- 10.42.0.0/16 # the cluster's pod CIDR — check yours
Read the address Home Assistant actually sees straight out of the log
rather than guessing at the CIDR:
kubectl -n {{ .Release.Namespace }} logs {{ $pod }} | grep -i forwarded
THE FIRST VISITOR BECOMES THE OWNER
Against an empty /config Home Assistant has no users and sends every visitor
into onboarding, where the first account created is the owner. There is no
password to look up anywhere and no Secret holding one — whoever reaches the
URL first owns this instance. Do the onboarding right after the first
rollout, not the next morning.
Onboarding also decides the instance's location, unit system and time zone
and stores them under /config/.storage. That is state on the PVC, not
configuration in the 1Password item: it survives a pod restart, and it is
gone with the volume.
TZ AND HOME ASSISTANT'S OWN TIME ZONE ARE TWO SETTINGS, NOT ONE
core.timezone ({{ .Values.core.timezone | default "unset" }}) is passed to the container as TZ and is the only
environment variable this chart sets by itself. Home Assistant's own time
zone is the one from onboarding or from configuration.yaml, and it is NOT
derived from this value. When the two disagree, log timestamps and
time-based automations disagree with them, and nothing reports a problem.
WHAT A READY POD PROVES — AND WHAT IT DOES NOT
All three probes fetch "/" on the container port. That path is served by the
HTTP integration as soon as it has come up, which happens early in the
bootstrap and says nothing about the rest:
A configuration.yaml that cannot be loaded does not necessarily keep the
pod from becoming Ready. Home Assistant falls back to safe mode, serves
the frontend with none of your configuration in effect, and the rollout
goes green. An instance that comes back "empty" after a config change is
this, and the reason is only in the log.
An integration that fails to set up, a recorder that cannot reach its
database, a full volume — none of them are in the probe's answer.
So check the log after every config change, not the rollout status:
kubectl -n {{ .Release.Namespace }} logs {{ $pod }}
The startup probe gates liveness and readiness and gives the first boot
{{ if $budget }}{{ $budget }}{{ else }}its configured budget{{ end }}, which is deliberately generous: a cold start
installs and initializes integrations. If a start outruns that budget the
kubelet kills the container mid-setup and the next attempt begins again,
which looks like CrashLoopBackOff with no error in the log at all.
/config IS THE WHOLE OF THE STATE, AND helm uninstall DELETES IT
The owner account, every long-lived token, all UI-created automations and
dashboards, every integration's stored credentials and the recorder
database live under /config — on {{ .Release.Name }}-{{ .Chart.Name }}-pvc ({{ .Values.core.storage.size }}
from storage class {{ .Values.core.storage.storageClass | default "the cluster default" }}), inside its subPath "hass-config".
Only the three files above come from the vault; nothing else here is
reproducible from the chart's values.
That PVC is an ordinary claim in this release, not a StatefulSet
volumeClaimTemplate: "helm uninstall {{ .Release.Name }}" deletes it, and with a Delete
reclaim policy the instance is then gone for good. Back it up before
uninstalling — a resource-policy annotation added to the live PVC afterwards
does not help, because Helm reads that annotation from the release manifest.
The recorder is what makes the volume grow, and its retention is a field of
the 1Password item (purge_keep_days in recorder-config), not a chart value.
Growing core.storage.size only takes effect if the storage class allows
volume expansion; shrinking it is refused by Kubernetes and makes the next
helm upgrade fail on the PVC instead of touching the data.
/tmp AND /run ARE emptyDirs AND COUNT AGAINST ephemeral-storage
The root filesystem is read-only, so the two paths the image must write to —
/run for s6-overlay's state and /tmp for Home Assistant's scratch files —
are emptyDirs. An emptyDir counts against the container's ephemeral-storage
limit ({{ $eph }}). Going over it does not fail a request: the kubelet evicts the
whole pod, and the reason is on the pod, not in Home Assistant's log. Where
integrations pull large downloads through /tmp, raise
resources.requests.ephemeralStorage — the limit follows it through
resources.limitFactor.
{{- if gt (atoi $replicas) 1 }}
MORE THAN ONE REPLICA WILL NOT WORK HERE
replicas is {{ $replicas }}, but every pod of this StatefulSet mounts the SAME
ReadWriteOnce claim {{ .Release.Name }}-{{ .Chart.Name }}-pvc at /config — there is no
volumeClaimTemplate and no per-pod volume. Unless the storage class hands
that claim to several nodes, the additional pods stay stuck before start;
where they do start, two Home Assistant processes write the same state
directory and the same recorder database at once. This chart is built for
one replica, and Home Assistant does not run active-active.
{{- end }}
{{- if not $netRaw }}
NET_RAW HAS BEEN DROPPED FROM THE CONTAINER
The chart's default adds NET_RAW back after dropping ALL, and the effective
securityContext.container for this release no longer has it. That is a
values override, not a chart default. Home Assistant's device discovery —
the DHCP watcher, the ping and arp integrations — opens raw sockets, and
without the capability it does not fail loudly: those integrations log
"Operation not permitted" and simply find nothing. Devices that used to
appear on their own will stop appearing.
{{- end }}
{{- if .Values.env }}
env IS APPENDED, AND HOME ASSISTANT WILL NOT READ IT ON ITS OWN
The {{ len .Values.env }} entries land after TZ and are rendered through tpl, so a value may
contain template expressions. Note what they do NOT do: apart from TZ, Home
Assistant takes no configuration from the environment. An entry here reaches
the application only if the YAML in the 1Password item picks it up with the
!env_var tag — otherwise it sits in the pod spec unused.
Anything sensitive belongs in valueFrom.secretKeyRef rather than a plain
value: a value is stored in the pod spec and readable by anyone who can read
pods in {{ .Release.Namespace }}.
{{- end }}