From 07a441b060b0f82a125d4ed149e7c55c603de122 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Mon, 29 Jun 2026 21:22:55 +0200 Subject: [PATCH 1/2] feat(home-assistant): add git config-sync sidecar to keep /config live with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HA's /config is an RWO PVC that is a git checkout of the homeassistant repo, edited via the code-server sidecar. There was no automated path from a merge to the running instance, so merged config (e.g. the notification-queue work) sat undeployed until a manual pull+reload. Adds a `config-sync` sidecar (alpine/git) sharing the config PVC. Every 30s it fetches origin/main and, on change, force-syncs ONLY the HA YAML config paths (automations(.yaml+dir), packages/, scripts.yaml, templates.yaml, configuration.yaml, notify.yaml, groups.yaml, powercalc.yaml, blueprints) into the working tree, advances the branch ref without touching the working tree, and calls homeassistant.reload_all so the change goes live with no restart. Deliberately left untouched: www/ (HACS rewrites those tracked files at runtime) and untracked runtime state (.storage/, *.db, secrets.yaml). git auth reuses the PAT already embedded in the PVC's remote URL; reload auth uses a dedicated long-lived token in a sops secret. Consequence (git becomes source of truth): uncommitted edits to a synced path made directly in code-server OR via HA's UI automation editor are reverted on the next sync. Commit + push to persist. NOTE: the reload token currently reuses the existing HA long-lived token; rotate it to a dedicated `config-sync` token (revocable independently) — see PR. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GgbyKgrN1yYWrkAA32rJE5 --- .../config-sync-token.sops.yaml | 23 ++++++ kubernetes/apps/home-assistant/config-sync.sh | 71 +++++++++++++++++++ .../apps/home-assistant/deployment.yaml | 42 +++++++++++ .../apps/home-assistant/kustomization.yaml | 8 +++ 4 files changed, 144 insertions(+) create mode 100644 kubernetes/apps/home-assistant/config-sync-token.sops.yaml create mode 100644 kubernetes/apps/home-assistant/config-sync.sh diff --git a/kubernetes/apps/home-assistant/config-sync-token.sops.yaml b/kubernetes/apps/home-assistant/config-sync-token.sops.yaml new file mode 100644 index 00000000..1f9788c5 --- /dev/null +++ b/kubernetes/apps/home-assistant/config-sync-token.sops.yaml @@ -0,0 +1,23 @@ +apiVersion: ENC[AES256_GCM,data:Vww=,iv:XwauTH7o+2kV/rzEOpolM5dOY2K0eoafjLR/ad1L6v8=,tag:l+lBQ0hvCZ8Yd8xl9oGYAw==,type:str] +kind: ENC[AES256_GCM,data:K/2bDqfT,iv:255tg0Sb6Ag6jAdfXrwSecrdg2LqIxXeC1icAm5nDUg=,tag:aYzZOFFcNcjTFDR4zWevpw==,type:str] +metadata: + name: ENC[AES256_GCM,data:Odt/OY/8RKH2OnMAMz1K65KRjJK5MC7fSIbJ3o7fajs=,iv:UwV65TsXkpgEOyY4CJ7J6dSVTTCAD3+MB8MMWCZ91nU=,tag:35M1V8CqjHeSIdraET/xpQ==,type:str] + namespace: ENC[AES256_GCM,data:FbEGPjnxsjbuATia3qs=,iv:eXFQ/c05zjkKp0JaFXDhZyUlybUCYaarpSvuEoXzr4o=,tag:vk35CyJ7m9Fw3CyZtyZtIg==,type:str] +type: ENC[AES256_GCM,data:cYQiWwL+,iv:Unemp5U44QLA6yfNQFJzjL2cRrHnvmZmgtkRV8/3FMA=,tag:Wpcnacj66xPSdeZArYjPdg==,type:str] +stringData: + token: ENC[AES256_GCM,data:AXuCVQ+K5ZkpFqh/sILso6bbltVnGm82NFidoy6YWvrZpQ/7xE73DSCagBNDR1QVS7dgbocI8aj6Fdfea5eqG6kMbqNpvFZdoL1jdJ+W2g+0IpOYm5bjFhC6/NTEELsXHr80/qO0us5sXmn3ZPgFaZpB2kQpZ61zVM9PqMEsB1M7ic2pAvvvR5AXV74iOMAmQMAgpMxCwIMMbZHGQTn6spg3WGjnRhvWMmpJjpaJq3bTYhffhQ2A,iv:4SevydlDJFiIFzmcGCje6cqM8SNrfZNDozXjex72aDs=,tag:rdr18DG2O0qt4r+F7v6egw==,type:str] +sops: + age: + - recipient: age1yqjvqkwwwxdj7mr546zfs5ckzuwndp69ya2n2drd4vypwns8w4ys04qfy3 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnQzNHUTZTL0lYaG44VTFE + ME1ieW9zYzZjVGMzclRnbEJ2aEFoME1Ic3hBCjhWSk1rajJUeDdHbE1QejRHbmlz + TkQ3UGV5SDVHZUh0aEwwT1FyMHViR3cKLS0tIHE2Vi93STVsbTVMKzNjQ1hKWlA5 + a1ZSOFFjbjFoRGNSYUg3YjlVcUZPcDQKoNJDDO1ATI6h6rKUeizcrEq2N7zcGfTy + mnVoeKrrlC9WSC5fbLnKxAIF782UxT7sFFQGypIzEulmMPJ+vbuo4g== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-06-29T19:20:57Z" + mac: ENC[AES256_GCM,data:hl8Ps6bx94hRZ0UR9JvDiT/H15WJgcRKs26lyVcFZIarv2yW7mJkJZ2MfBQc5E3IdoQs/cW89RxiAv4sU6ELS+jlWkQP4gl2gJrgzOelYBVN0bHzvICBN0bV7iRMlr35BSEztQ7P+9GRSsPVt3Gqwtlu1Cby2TEsDhurcX4xclI=,iv:P1ROxx7+Tk7mLGhhEYpbG7cdE8p1se9wJVrgPFyDtZk=,tag:k6+lr579yNZVRY8Po3Md1g==,type:str] + unencrypted_suffix: _unencrypted + version: 3.10.2 diff --git a/kubernetes/apps/home-assistant/config-sync.sh b/kubernetes/apps/home-assistant/config-sync.sh new file mode 100644 index 00000000..64fc983a --- /dev/null +++ b/kubernetes/apps/home-assistant/config-sync.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Home Assistant config-sync sidecar. +# +# /config is a git checkout of gitea.renner.uno/k.renner/homeassistant living on +# an RWO PVC. This loop force-syncs ONLY the HA YAML config paths from +# origin/ into the working tree, then calls homeassistant.reload_all so +# the change goes live. It deliberately does NOT touch: +# - www/ (HACS rewrites those tracked files at runtime; leave them to HACS) +# - untracked runtime state (.storage/, *.db, secrets.yaml, tts/, deps/, ...) +# Git auth uses the PAT already embedded in the PVC's remote URL; reload auth +# uses the dedicated long-lived token in $HA_TOKEN. +# +# Source of truth is git: any uncommitted edit to a synced path made directly in +# code-server is overwritten on the next sync. Commit + push to persist. +set -eu + +CONFIG_DIR="${CONFIG_DIR:-/config}" +BRANCH="${SYNC_BRANCH:-main}" +INTERVAL="${SYNC_INTERVAL:-30}" +HA_URL="${HA_URL:-http://127.0.0.1:8123}" + +# HA config paths to force-sync from origin/. Anything not listed here +# (notably www/ and untracked runtime files) is left untouched. +PATHS="automations.yaml automations packages scripts.yaml templates.yaml configuration.yaml notify.yaml groups.yaml powercalc.yaml blueprints" + +log() { echo "$(date -Iseconds) config-sync: $*"; } + +reload_ha() { + if [ -z "${HA_TOKEN:-}" ]; then + log "WARN no HA_TOKEN set; files synced but HA not reloaded" + return + fi + if wget -q -O /dev/null \ + --header="Authorization: Bearer ${HA_TOKEN}" \ + --header="Content-Type: application/json" \ + --post-data='{}' \ + "${HA_URL}/api/services/homeassistant/reload_all"; then + log "homeassistant.reload_all OK" + else + log "ERROR homeassistant.reload_all failed (rc=$?)" + fi +} + +log "starting (branch=${BRANCH} interval=${INTERVAL}s dir=${CONFIG_DIR})" +cd "${CONFIG_DIR}" +git config --global --add safe.directory "${CONFIG_DIR}" 2>/dev/null || true + +while true; do + if git fetch --quiet origin "${BRANCH}" 2>/dev/null; then + LOCAL="$(git rev-parse HEAD 2>/dev/null || echo none)" + REMOTE="$(git rev-parse "origin/${BRANCH}" 2>/dev/null || echo none)" + if [ "${REMOTE}" != none ] && [ "${LOCAL}" != "${REMOTE}" ]; then + log "change detected ${LOCAL} -> ${REMOTE}; syncing config paths" + # Overwrite only the config paths (working tree + index) from the remote. + # shellcheck disable=SC2086 + if git checkout "origin/${BRANCH}" -- ${PATHS} 2>/tmp/sync.err; then + # Advance the branch ref to the remote WITHOUT touching the working + # tree, so www/ drift and untracked runtime state are preserved while + # git status stays clean for the synced paths. + git update-ref "refs/heads/${BRANCH}" "${REMOTE}" + log "synced to ${REMOTE}" + reload_ha + else + log "ERROR checkout failed: $(cat /tmp/sync.err 2>/dev/null)" + fi + fi + else + log "WARN git fetch failed; will retry" + fi + sleep "${INTERVAL}" +done diff --git a/kubernetes/apps/home-assistant/deployment.yaml b/kubernetes/apps/home-assistant/deployment.yaml index 7bb5c988..c5b30232 100644 --- a/kubernetes/apps/home-assistant/deployment.yaml +++ b/kubernetes/apps/home-assistant/deployment.yaml @@ -155,10 +155,52 @@ spec: 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 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 64Mi + 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 diff --git a/kubernetes/apps/home-assistant/kustomization.yaml b/kubernetes/apps/home-assistant/kustomization.yaml index 7f06041e..b52b75c4 100644 --- a/kubernetes/apps/home-assistant/kustomization.yaml +++ b/kubernetes/apps/home-assistant/kustomization.yaml @@ -10,6 +10,7 @@ resources: - ingress.yaml - vscode-ingress.yaml - prometheus-token.sops.yaml + - config-sync-token.sops.yaml - mosquitto-bootstrap.yaml - mosquitto-config.yaml - mosquitto-pvc.yaml @@ -36,5 +37,12 @@ configMapGenerator: namespace: home-assistant files: - default.conf.template=proxy.conf + # config-sync sidecar script. Hash suffix disabled (name stays stable), so + # editing config-sync.sh does not auto-roll the pod — restart manually with + # `kubectl rollout restart deploy/home-assistant -n home-assistant`. + - name: ha-config-sync-script + namespace: home-assistant + files: + - sync.sh=config-sync.sh generatorOptions: disableNameSuffixHash: true -- 2.54.0 From 12169bf75322c9af309fabda607f54f793da2503 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Mon, 29 Jun 2026 21:35:45 +0200 Subject: [PATCH 2/2] fix(home-assistant): config-sync reloads HA only when synced paths actually change The previous loop reloaded on any advance of origin/main, including commits that touch only unsynced paths (www/, shelly_scripts/, docs). Now the reload trigger is `git diff --quiet -- `: HA is reloaded only when the synced config paths differ. Non-config commits just advance the branch ref with no reload. As a side effect the loop now also reverts local drift on synced paths continuously (reloading only when something actually changed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GgbyKgrN1yYWrkAA32rJE5 --- kubernetes/apps/home-assistant/config-sync.sh | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/kubernetes/apps/home-assistant/config-sync.sh b/kubernetes/apps/home-assistant/config-sync.sh index 64fc983a..3c8c6464 100644 --- a/kubernetes/apps/home-assistant/config-sync.sh +++ b/kubernetes/apps/home-assistant/config-sync.sh @@ -47,21 +47,31 @@ git config --global --add safe.directory "${CONFIG_DIR}" 2>/dev/null || true while true; do if git fetch --quiet origin "${BRANCH}" 2>/dev/null; then - LOCAL="$(git rev-parse HEAD 2>/dev/null || echo none)" REMOTE="$(git rev-parse "origin/${BRANCH}" 2>/dev/null || echo none)" - if [ "${REMOTE}" != none ] && [ "${LOCAL}" != "${REMOTE}" ]; then - log "change detected ${LOCAL} -> ${REMOTE}; syncing config paths" - # Overwrite only the config paths (working tree + index) from the remote. + if [ "${REMOTE}" != none ]; then + # The ONLY reload trigger: do the synced config paths in the working tree + # differ from the remote? Commits that touch only unsynced paths (www/, + # shelly_scripts/, docs, ...) advance the ref but never reload. # shellcheck disable=SC2086 - if git checkout "origin/${BRANCH}" -- ${PATHS} 2>/tmp/sync.err; then - # Advance the branch ref to the remote WITHOUT touching the working - # tree, so www/ drift and untracked runtime state are preserved while - # git status stays clean for the synced paths. + if ! git diff --quiet "${REMOTE}" -- ${PATHS} 2>/dev/null; then + log "config change detected; syncing paths from ${REMOTE}" + # Overwrite only the config paths (working tree + index) from the remote. + # shellcheck disable=SC2086 + if git checkout "${REMOTE}" -- ${PATHS} 2>/tmp/sync.err; then + # Advance the branch ref to the remote WITHOUT touching the working + # tree, so www/ drift and untracked runtime state are preserved while + # git status stays clean for the synced paths. + git update-ref "refs/heads/${BRANCH}" "${REMOTE}" + log "synced config to ${REMOTE}; reloading HA" + reload_ha + else + log "ERROR checkout failed: $(cat /tmp/sync.err 2>/dev/null)" + fi + elif [ "$(git rev-parse HEAD 2>/dev/null)" != "${REMOTE}" ]; then + # Config already matches the remote; a commit touched only unsynced + # paths. Advance the ref to keep git status clean — but do NOT reload. git update-ref "refs/heads/${BRANCH}" "${REMOTE}" - log "synced to ${REMOTE}" - reload_ha - else - log "ERROR checkout failed: $(cat /tmp/sync.err 2>/dev/null)" + log "non-config commit; advanced ref to ${REMOTE} (no reload)" fi fi else -- 2.54.0