Part of #43. This chart was cited throughout that issue as the one that already has a NOTES.txt and therefore serves as the style reference for the whole programme. The file contained the single line "Hello :)". It is replaced here by the real thing, built on the pattern the later charts (zipline, voucher-vault, urlaubsverwaltung) established in the meantime. It prints what an operator would otherwise have to read out of the templates: the access URL assembled from the chart's upstream-style ingress block (hosts x tls, so the scheme is derived per host rather than assumed) or the port-forward command; the three fields the 1Password item must carry (config, http-config, recorder-config) and how configuration.yaml has to include the other two itself; and the traps of this particular chart - onboarding hands the instance to the first visitor, use_x_forwarded_for/trusted_proxies decides between 400 Bad Request and one client IP for everyone, the three config files are read-only subPath mounts that the kubelet never refreshes, safe mode lets a broken configuration pass the probes, and helm uninstall deletes the PVC that holds all of the state. Conditional sections cover scaleDown/replicas 0, replicas > 1 on a single ReadWriteOnce claim, a NET_RAW override that silently kills device discovery, a non-empty env block, the chart placeholder host chart-example.local, and the case where ingress.tls names a Secret while no cert-manager annotation is set - this chart adds none by itself, unlike the rest of the repo. No domain appears in the file: hosts come from the values, examples use example.com. Minor bump, appVersion unchanged; a NOTES.txt changes no rendered object, verified by diffing helm template before and after.
292 lines
15 KiB
Plaintext
292 lines
15 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) -}}
|
|
{{- $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 }}
|