Merge pull request #202 from jk-cluster/feat/homeassistant-notes-and-157
homeassistant: echte NOTES.txt und Plain-Decimal-Byte-Werte (6.1.0+up2026.8.3)
This commit is contained in:
3 files changed
+311
-4
No files matched your search
@@ -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
|
||||
type: application
|
||||
|
||||
version: 6.0.0+up2026.8.3
|
||||
version: 6.1.0+up2026.8.3
|
||||
|
||||
appVersion: "2026.8.3"
|
||||
|
||||
|
||||
@@ -1 +1,291 @@
|
||||
Hello :)
|
||||
{{- $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) -}}
|
||||
{{- $ing := .Values.ingress -}}
|
||||
{{- $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
|
||||
{{- 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 }}
|
||||
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress. 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 $ing.enabled $placeholder }}
|
||||
|
||||
THE INGRESS IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
|
||||
ingress.enabled is true while ingress.hosts still contains the default entry
|
||||
"chart-example.local". That name resolves nowhere, so the Ingress exists,
|
||||
ingress-nginx accepts it, 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.
|
||||
{{- 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 }} 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 AN INGRESS, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
|
||||
Every request reaching Home Assistant through ingress-nginx arrives from the
|
||||
controller's pod IP, 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 the ingress
|
||||
controller. 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 ingress controller's POD network, not to the nodes
|
||||
and not to the clients:
|
||||
|
||||
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 }}
|
||||
@@ -67,7 +67,7 @@ Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
|
||||
results are printed without a decimal point.
|
||||
*/}}
|
||||
{{- define "homeassistant.multiplyQuantity" -}}
|
||||
{{- $quantity := printf "%v" .quantity -}}
|
||||
{{- $quantity := include "homeassistant.formatNumber" .quantity -}}
|
||||
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||
{{- if not $number -}}
|
||||
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||
@@ -80,7 +80,24 @@ results are printed without a decimal point.
|
||||
{{- if eq $product (floor $product) -}}
|
||||
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v%s" $product $suffix -}}
|
||||
{{- printf "%s%s" (include "homeassistant.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation.
|
||||
|
||||
Why this exists (issue #157): the null-safe fallback helpers pass values
|
||||
through fromYaml, which parses every number as float64. Go's "%v" switches
|
||||
float64 to exponential notation from about 1e6 upwards, so a bare byte count
|
||||
like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the
|
||||
render aborted. Strings ("512Mi") are passed through untouched.
|
||||
*/}}
|
||||
{{- define "homeassistant.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
|
||||
Reference in new issue
Block a user