HA: git config-sync sidecar (auto-deploy /config from main) #87

Merged
k.renner merged 2 commits from worktree-ha-config-sync-sidecar into master 2026-06-29 19:37:47 +00:00
4 changed files with 154 additions and 0 deletions
@@ -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
@@ -0,0 +1,81 @@
#!/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/<branch> 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/<branch>. 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
REMOTE="$(git rev-parse "origin/${BRANCH}" 2>/dev/null || echo none)"
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 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 "non-config commit; advanced ref to ${REMOTE} (no reload)"
fi
fi
else
log "WARN git fetch failed; will retry"
fi
sleep "${INTERVAL}"
done
@@ -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
@@ -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