Both documents described work as outstanding that the cluster shows finished. A record that says "REMAINING" long after the fact is worse than no record: it is read as current state, and the next person plans around it. ## Home Assistant recorder Phase 2c is done, and done correctly. Against the live database: statistics 11,092,586 2024-03-13 20:00 -> current statistics_short_term 1,883,709 rolling ~7 days statistics_meta 1,480 The 2024-03-13 origin is the part that matters. It is unchanged from the 10,793,712-row count recorded on 2026-08-19, so the history was copied across engines rather than started from zero — the exact failure the document was written to prevent. `states` carries current rows, so db_url points at Postgres. The three "INERT"/"PHASE 1" comments went with it. All three described a cluster that nothing consumed; it is now the recorder's live database, and the render-db-url init container is load-bearing rather than inert — HA will not start without the secret it renders. ## Bare-metal conversion `talosctl get systeminformation` reports Minisforum MS-01 hardware with distinct chassis serials for all three nodes and no hypervisor, so saturn and neptune are not outstanding. Node creationTimestamp is not evidence either way: k8s-3 still carries its 2026-05-09 object and is on metal regardless. The runbook stays as the procedure for a rebuild. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
313 lines
12 KiB
YAML
313 lines
12 KiB
YAML
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: home-assistant
|
|
namespace: home-assistant
|
|
labels:
|
|
app.kubernetes.io/name: home-assistant
|
|
annotations:
|
|
# Reloader restarts the whole HA pod when config-sync-token rotates. The
|
|
# config-sync sidecar reads HA_TOKEN as a startup env var, so without this
|
|
# a quarterly rotation would leave it using the old (revoked) token until
|
|
# the pod next restarts for some other reason. The cost is a brief HA
|
|
# restart each quarter (04:05 Europe/Berlin, off-hours) — accepted so the
|
|
# full-admin config-sync LLAT is lifetime-bounded like the others. See
|
|
# kubernetes/infrastructure/openbao/rotation/home-assistant.yaml.
|
|
secret.reloader.stakater.com/reload: home-assistant-config-sync-token
|
|
spec:
|
|
replicas: 1
|
|
strategy:
|
|
type: Recreate
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: home-assistant
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: home-assistant
|
|
spec:
|
|
enableServiceLinks: false
|
|
# Explicit `default` overwrites the previously-claimed
|
|
# `home-assistant-trusted-proxies` SA ref left behind by SSA
|
|
# when that field was dropped from the manifest. Safe to remove
|
|
# in a follow-up once SSA owns it with this value.
|
|
serviceAccountName: default
|
|
# No nodeSelector/toleration: HA reads the power meter over MQTT now
|
|
# (sml2mqtt bridge owns the USB device on edge), so it no longer needs
|
|
# the edge USB device and schedules on the general cluster pool. This
|
|
# gets the heavy HA pod (8Gi) off the soon-to-be-tiny edge box.
|
|
initContainers:
|
|
# Puts CNPG's connection URI into /config/secrets.yaml as
|
|
# `recorder_db_url`, for the recorder's move off SQLite
|
|
# (MIGRATION-recorder-postgres.md).
|
|
#
|
|
# HA has no way to read a db_url from the environment — `!env_var` was
|
|
# removed from its YAML loader — so `!secret` is the only mechanism, and
|
|
# secrets.yaml is PVC state that config-sync deliberately does not
|
|
# manage. Hence a small init container rather than a env: entry.
|
|
#
|
|
# The URI comes STRAIGHT from the ha-pg-app Secret. CNPG builds and
|
|
# percent-encodes it, so there is nothing to escape here: the value is
|
|
# restricted to [A-Za-z0-9:/@._%-], verified against the live secret.
|
|
# An earlier version of this assembled the URI from user/password/dbname
|
|
# and needed percent-encoding plus YAML round-trip validation to be
|
|
# safe. That was solving a problem CNPG had already solved.
|
|
#
|
|
# Re-rendered on every pod start, so a CNPG password rotation takes
|
|
# effect rather than silently leaving a stale URI behind.
|
|
#
|
|
# Load-bearing: `db_url: !secret recorder_db_url` in the homeassistant
|
|
# repo's configuration.yaml reads this key, so HA does not start
|
|
# without it. Removing this init container takes the recorder down.
|
|
- name: render-db-url
|
|
image: busybox:1.36
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
set -eu
|
|
S=/config/secrets.yaml
|
|
T=/config/.secrets.render.tmp
|
|
[ -f "$S" ] || : > "$S"
|
|
# Filter-then-append, never rewrite in place: this file holds
|
|
# every other HA secret (influxdb_token and friends).
|
|
grep -v '^recorder_db_url:' "$S" > "$T" || true
|
|
printf 'recorder_db_url: "%s"\n' "$DB_URI" >> "$T"
|
|
# Refuse to shrink the file. Cheap guard against the one failure
|
|
# that would actually hurt: losing the other keys.
|
|
[ "$(wc -l < "$T")" -ge "$(wc -l < "$S")" ] || {
|
|
echo "FATAL: rewrite would shrink secrets.yaml; aborting" >&2; exit 1; }
|
|
chmod 600 "$T"
|
|
mv "$T" "$S"
|
|
echo "recorder_db_url rendered"
|
|
env:
|
|
- name: DB_URI
|
|
valueFrom:
|
|
secretKeyRef: {name: ha-pg-app, key: fqdn-uri}
|
|
volumeMounts:
|
|
- name: config
|
|
mountPath: /config
|
|
resources:
|
|
requests:
|
|
cpu: 10m
|
|
memory: 16Mi
|
|
limits:
|
|
memory: 32Mi
|
|
containers:
|
|
- name: home-assistant
|
|
image: ghcr.io/home-assistant/home-assistant:2026.8.3 # {"$imagepolicy": "flux-system:home-assistant"}
|
|
env:
|
|
- name: TZ
|
|
value: Europe/Berlin
|
|
ports:
|
|
- name: http
|
|
containerPort: 8123
|
|
protocol: TCP
|
|
volumeMounts:
|
|
- name: config
|
|
mountPath: /config
|
|
# Initial restore of a 1 GB encrypted HAOS backup runs single-
|
|
# threaded inside HA's Python and exceeded the old liveness
|
|
# window, getting SIGTERM'd mid-restore. Startup probe gives a
|
|
# 10 min ceiling; liveness/readiness only kick in afterwards.
|
|
startupProbe:
|
|
httpGet:
|
|
path: /
|
|
port: http
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 60
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /
|
|
port: http
|
|
periodSeconds: 30
|
|
timeoutSeconds: 10
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /
|
|
port: http
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
# ~10k entities. The legacy HAOS VM ran this workload with 16Gi;
|
|
# the 4Gi limit OOM-looped (working set ~3.9Gi, tipped over on
|
|
# morning load spikes). 8Gi held on the edge VM but was OOMKilled
|
|
# again on 2026-07-09 now that the pod schedules on the general
|
|
# pool (~47Gi nodes), so it is back to the HAOS-era 16Gi.
|
|
resources:
|
|
requests:
|
|
cpu: 500m
|
|
memory: 2Gi
|
|
limits:
|
|
memory: 16Gi
|
|
# No securityContext/privileged: the USB meter is gone (read via
|
|
# MQTT now), which was the only reason HA needed privileged.
|
|
# Reverse-proxy sidecar. ingress-nginx → :8124 → 127.0.0.1:8123.
|
|
# HA's TCP peer is always loopback, so http.trusted_proxies =
|
|
# [127.0.0.1, ::1] is static and never has to change when
|
|
# ingress-nginx pods churn.
|
|
#
|
|
# The XFF trust hinges on nothing else being able to present
|
|
# X-Forwarded-For to nginx. The CNP gates :8124 to traefik only;
|
|
# nginx additionally binds to ${POD_IP} (not 0.0.0.0) as defense
|
|
# in depth against any future pod-interface attachments.
|
|
#
|
|
# Rendering: the nginx-unprivileged image's entrypoint runs
|
|
# envsubst over /etc/nginx/templates/*.template at start, writing
|
|
# to /etc/nginx/conf.d/. NGINX_ENVSUBST_FILTER restricts the
|
|
# substitution to POD_IP so nginx-runtime variables like $host
|
|
# / $http_x_forwarded_for in the template stay intact.
|
|
- name: proxy
|
|
image: nginxinc/nginx-unprivileged:1.27-alpine
|
|
env:
|
|
- name: POD_IP
|
|
valueFrom:
|
|
fieldRef:
|
|
fieldPath: status.podIP
|
|
- name: NGINX_ENVSUBST_FILTER
|
|
value: "^POD_IP$"
|
|
ports:
|
|
- name: proxy-http
|
|
containerPort: 8124
|
|
protocol: TCP
|
|
volumeMounts:
|
|
- name: proxy-config
|
|
mountPath: /etc/nginx/templates
|
|
readOnly: true
|
|
- name: proxy-conf-d
|
|
mountPath: /etc/nginx/conf.d
|
|
readinessProbe:
|
|
tcpSocket:
|
|
port: 8124
|
|
periodSeconds: 10
|
|
resources:
|
|
requests:
|
|
cpu: 10m
|
|
memory: 32Mi
|
|
limits:
|
|
memory: 64Mi
|
|
# Browser-based VS Code editing /config in-place. Sidecar (not
|
|
# separate Deployment) because the config PVC is RWO ceph-block.
|
|
# Exposed LAN-only on vscode.home.renner.uno; --auth=none means
|
|
# any LAN client can edit, matching the user's trust-the-network
|
|
# model. Runs as root so its writes match HA's root-owned files.
|
|
- name: code-server
|
|
image: codercom/code-server:4.118.0
|
|
args:
|
|
- "--auth=none"
|
|
- "--bind-addr=0.0.0.0:8080"
|
|
- "--disable-telemetry"
|
|
- "--disable-update-check"
|
|
- "/config"
|
|
env:
|
|
- name: HOME
|
|
value: /var/lib/code-server
|
|
- name: TZ
|
|
value: Europe/Berlin
|
|
ports:
|
|
- name: vscode
|
|
containerPort: 8080
|
|
protocol: TCP
|
|
# /healthz answers 200 whenever the HTTP server is up; its body
|
|
# reports session heartbeat state, which is not health — an idle
|
|
# editor reports "expired" and is perfectly fine. So this asserts
|
|
# that code-server is serving, not that anyone is using it.
|
|
#
|
|
# Liveness matters here more than elsewhere in this pod: code-server
|
|
# shares the Home Assistant config volume, and a wedged editor is
|
|
# otherwise invisible until someone tries to open it.
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /healthz
|
|
port: vscode
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 30
|
|
timeoutSeconds: 5
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /healthz
|
|
port: vscode
|
|
initialDelaySeconds: 10
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
volumeMounts:
|
|
- name: config
|
|
mountPath: /config
|
|
- name: vscode-state
|
|
mountPath: /var/lib/code-server
|
|
resources:
|
|
requests:
|
|
cpu: 50m
|
|
memory: 256Mi
|
|
limits:
|
|
memory: 1Gi
|
|
securityContext:
|
|
runAsUser: 0
|
|
runAsGroup: 0
|
|
# Git config-sync sidecar. /config is a git checkout on the PVC; this
|
|
# force-syncs only the HA YAML config paths from origin/main of the
|
|
# gitea repo into the working tree, then calls homeassistant.reload_all.
|
|
# Leaves www/ (HACS-managed) and untracked runtime state (.storage,
|
|
# *.db, secrets.yaml) untouched. git auth uses the PAT already embedded
|
|
# in the PVC's remote URL; reload auth uses a dedicated long-lived token.
|
|
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
|
- name: config-sync
|
|
image: alpine/git:2.54.0
|
|
command: ["/bin/sh", "/scripts/sync.sh"]
|
|
env:
|
|
- name: SYNC_BRANCH
|
|
value: main
|
|
- name: SYNC_INTERVAL
|
|
value: "30"
|
|
- name: HA_URL
|
|
value: http://127.0.0.1:8123
|
|
- name: HA_TOKEN
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: home-assistant-config-sync-token
|
|
key: token
|
|
volumeMounts:
|
|
- name: config
|
|
mountPath: /config
|
|
- name: config-sync-script
|
|
mountPath: /scripts
|
|
readOnly: true
|
|
# ~1.1 GB .git (a 1 GB backup tar in history); auto-gc is disabled in
|
|
# the script so it never repacks. fetch/checkout of small config blobs
|
|
# peak well under this; the headroom covers index-pack on first fetch.
|
|
resources:
|
|
requests:
|
|
cpu: 10m
|
|
memory: 32Mi
|
|
limits:
|
|
memory: 256Mi
|
|
securityContext:
|
|
runAsUser: 0
|
|
runAsGroup: 0
|
|
volumes:
|
|
- name: config
|
|
persistentVolumeClaim:
|
|
claimName: home-assistant-config
|
|
# Mounts config-sync.sh (via configMapGenerator) read-only at /scripts.
|
|
- name: config-sync-script
|
|
configMap:
|
|
name: ha-config-sync-script
|
|
defaultMode: 0755
|
|
- name: proxy-config
|
|
configMap:
|
|
name: ha-proxy-config
|
|
# Writable emptyDir for the nginx entrypoint's envsubst output
|
|
# (/etc/nginx/conf.d/default.conf rendered from the .template).
|
|
- name: proxy-conf-d
|
|
emptyDir:
|
|
medium: Memory
|
|
sizeLimit: 16Mi
|
|
# code-server's settings/extensions cache. Not on the config PVC
|
|
# so installed extensions don't pollute HA's /config listing.
|
|
# Trade-off: extensions reset across pod restarts.
|
|
- name: vscode-state
|
|
emptyDir:
|
|
medium: Memory
|
|
sizeLimit: 16Mi
|