From b6cf090aca06b2bea7ddd9641bdce629b2f9b526 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Mon, 29 Jun 2026 23:59:02 +0200 Subject: [PATCH] feat(home-assistant): make config-sync two-way (push live changes back via PR) The sidecar was one-way and git-authoritative: it force-overwrote local edits to the synced config paths, so changes made inside HA (code-server, the UI automation/script editors) were reverted on the next tick. Now it is two-way: - PUSH: changes to the tracked config paths are committed (bot identity) onto a dedicated `live` branch, pushed, and a PR `live -> main` is kept open (via the gitea token already in the remote URL). Live edits stay live AND are captured for review. - PULL: when `main` advances (merged PR / external push) the commits are merged back into `live`/the working tree and HA reloads. Conflicts are left to git: a conflicting merge is ABORTED (never written into the live files, which would break HA) and logged to resolve in the PR. Reloads fire only on changes pulled from main (local edits are already applied by HA). Also fixes the reload race: reload_all now retries until HA is serving, instead of failing once right after a pod start. Scope unchanged (config paths only; rest of www/ and untracked runtime state untouched); auto-gc still disabled. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GgbyKgrN1yYWrkAA32rJE5 --- kubernetes/apps/home-assistant/config-sync.sh | 174 +++++++++++------- 1 file changed, 109 insertions(+), 65 deletions(-) diff --git a/kubernetes/apps/home-assistant/config-sync.sh b/kubernetes/apps/home-assistant/config-sync.sh index 3d42224d..0cfdf07a 100644 --- a/kubernetes/apps/home-assistant/config-sync.sh +++ b/kubernetes/apps/home-assistant/config-sync.sh @@ -1,102 +1,146 @@ #!/bin/sh -# Home Assistant config-sync sidecar. +# Home Assistant config-sync sidecar — TWO-WAY. # -# /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/ in general (HACS rewrites those tracked files at runtime; leave them -# to HACS) — EXCEPT the few bespoke, non-HACS card files listed in PATHS, -# which are synced individually by exact path. -# - 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. +# /config is a git checkout of gitea.renner.uno/k.renner/homeassistant on an RWO +# PVC. This loop keeps the tracked HA config paths in sync with the repo in BOTH +# directions: # -# 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. +# push — changes made inside HA (code-server edits, HA UI automation/script +# editors) are committed onto a dedicated `live` branch, pushed, and a +# PR `live -> main` is kept open for review. Your live edits stay live. +# pull — when `main` advances (a merged PR, or a push from elsewhere) the new +# commits are merged back into `live`/the working tree and HA reloads. +# +# Scope: only the paths in PATHS. It never touches the rest of www/ (HACS rewrites +# those at runtime) or untracked runtime state (.storage/, *.db, secrets.yaml, +# tts/, deps/, ...). git auth uses the PAT embedded in the PVC's remote URL (also +# reused for the gitea API to open the PR); reload auth uses $HA_TOKEN. +# +# Conflicts are left to git: a merge that conflicts is ABORTED (never written +# into the live files, which would break HA) and logged for you to resolve in the +# PR. Reloads fire only on changes pulled FROM main — your own local edits are +# already applied by HA, so they are not reloaded (a plain code-server file edit +# needs a manual reload). set -eu CONFIG_DIR="${CONFIG_DIR:-/config}" -BRANCH="${SYNC_BRANCH:-main}" +MAIN="${SYNC_BRANCH:-main}" +LIVE="${SYNC_LIVE_BRANCH:-live}" 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 the rest of www/ and untracked runtime files) is left untouched. -# www/heizungsanlage-card.js is a bespoke, non-HACS custom card, so it is synced -# by exact path (the rest of www/ stays HACS-managed). +# Tracked HA config paths synced in both directions. www/heizungsanlage-card.js is +# a bespoke, non-HACS custom card synced by exact path; the rest of www/ stays +# HACS-managed and untouched. PATHS="automations.yaml automations packages scripts.yaml templates.yaml configuration.yaml notify.yaml groups.yaml powercalc.yaml blueprints www/heizungsanlage-card.js" # Log to stderr: busybox sh block-buffers stdout when it is a pipe (as under -# kubelet), so stdout log lines can sit unflushed for the lifetime of the loop. -# stderr is unbuffered, so lines reach `kubectl logs` immediately. Both streams -# are captured into the container log. +# kubelet), so stdout lines can sit unflushed for the life of the loop. stderr is +# unbuffered; both streams land in the container log. log() { echo "$(date -Iseconds) config-sync: $*" >&2; } +# Reload HA, retrying until it answers — covers the window right after a pod start +# where the sidecar is up before HA is serving (was: one-shot reload that lost the +# race and never retried). 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 + i=0 + while [ "${i}" -lt 18 ]; do + 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" + return + fi + i=$((i + 1)) + sleep 5 + done + log "ERROR homeassistant.reload_all failed after retries" } -log "starting (branch=${BRANCH} interval=${INTERVAL}s dir=${CONFIG_DIR})" +# Open a PR live -> main if one isn't already open. gitea returns 422 when a PR +# for this head already exists (or there's no diff); ignore it. +ensure_pr() { + [ -z "${GITEA_TOKEN}" ] && return 0 + wget -q -O /dev/null \ + --header="Authorization: token ${GITEA_TOKEN}" \ + --header="Content-Type: application/json" \ + --post-data="{\"head\":\"${LIVE}\",\"base\":\"${MAIN}\",\"title\":\"chore(live): HA config changes from /config\",\"body\":\"Auto-maintained by the config-sync sidecar. Live changes made in Home Assistant (/config) land here; review and merge to ${MAIN}.\"}" \ + "${API}/pulls" 2>/dev/null || true +} + +log "starting (main=${MAIN} live=${LIVE} interval=${INTERVAL}s dir=${CONFIG_DIR})" cd "${CONFIG_DIR}" + git config --global --add safe.directory "${CONFIG_DIR}" 2>/dev/null || true -# This repo's .git is ~1.1 GB (a 1 GB backup tar sits in history). Auto-gc would -# try to repack that whole pack inside this tiny sidecar and OOM. Disable it and -# cap delta/window memory so fetch/checkout stay cheap (we only touch small -# config blobs, never the big objects). +# Identity for auto-commits. +git config user.name "HA Config Sync" 2>/dev/null || true +git config user.email "config-sync@home.renner.uno" 2>/dev/null || true +# Never let auto-gc repack this (historically large) repo inside the tiny sidecar. git config --global gc.auto 0 2>/dev/null || true git config --global pack.windowMemory 32m 2>/dev/null || true git config --global pack.deltaCacheSize 32m 2>/dev/null || true git config --global core.deltaBaseCacheLimit 16m 2>/dev/null || true +# Derive gitea API base + token from the remote URL (https://user:TOKEN@host/owner/repo.git). +REMOTE_URL="$(git config --get remote.origin.url 2>/dev/null || echo '')" +GITEA_TOKEN="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^:]*:\([^@]*\)@.*#\1#p')" +GITEA_HOST="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^@]*@\([^/]*\)/.*#\1#p')" +REPO_PATH="$(printf '%s' "${REMOTE_URL}" | sed -n 's#^https://[^@]*@[^/]*/\(.*\)\.git$#\1#p')" +API="https://${GITEA_HOST}/api/v1/repos/${REPO_PATH}" +[ -z "${GITEA_TOKEN}" ] && log "WARN could not derive gitea token from remote URL; PRs won't be opened" + +# Put HEAD on the live branch without disturbing the working tree (live == current +# HEAD on first run; persisted across restarts on the PVC). +if ! git rev-parse --verify --quiet "refs/heads/${LIVE}" >/dev/null 2>&1; then + git branch "${LIVE}" HEAD 2>/dev/null || true +fi +git symbolic-ref HEAD "refs/heads/${LIVE}" 2>/dev/null || true + FIRST=1 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)" - elif [ "${FIRST}" = 1 ]; then - # Nothing to do on startup — log once so an idle loop is observably alive. - log "initial check: config in sync with origin/${BRANCH} at $(git rev-parse --short "${REMOTE}")" + # 1. PUSH: capture live edits to the tracked config paths onto `live`. + # shellcheck disable=SC2086 + if [ -n "$(git status --porcelain -- ${PATHS} 2>/dev/null)" ]; then + # shellcheck disable=SC2086 + git add -A -- ${PATHS} 2>/dev/null || true + if git commit -q -m "chore(live): HA config change $(date -Iseconds)" 2>/dev/null; then + log "committed live config change(s)" + if git push -q origin "${LIVE}" 2>/tmp/push.err; then + log "pushed ${LIVE}" + ensure_pr + else + log "ERROR push ${LIVE} failed: $(cat /tmp/push.err 2>/dev/null)" fi - FIRST=0 + fi + fi + + # 2. PULL: integrate commits that arrived on main (merged PR / external push). + if git fetch --quiet origin "${MAIN}" 2>/dev/null; then + if ! git merge-base --is-ancestor "origin/${MAIN}" HEAD 2>/dev/null; then + BEFORE="$(git rev-parse HEAD 2>/dev/null || echo none)" + if git merge -q --no-edit "origin/${MAIN}" 2>/tmp/merge.err; then + if [ "$(git rev-parse HEAD 2>/dev/null)" != "${BEFORE}" ]; then + log "merged origin/${MAIN} into ${LIVE}; reloading HA" + reload_ha + git push -q origin "${LIVE}" 2>/dev/null || true + fi + else + git merge --abort 2>/dev/null || true + log "ERROR merge conflict with origin/${MAIN}; left /config as-is — resolve in the live PR" + fi + elif [ "${FIRST}" = 1 ]; then + log "initial check: ${LIVE} up to date with origin/${MAIN} at $(git rev-parse --short HEAD 2>/dev/null)" fi else log "WARN git fetch failed; will retry" fi + + FIRST=0 sleep "${INTERVAL}" done -- 2.54.0