Merge pull request 'HA config-sync: two-way (push live /config changes back as a PR)' (#91) from feat-config-sync-two-way into master

Reviewed-on: gitea_admin/homelab#91
This commit was merged in pull request #91.
This commit is contained in:
2026-06-30 00:02:53 +02:00
+109 -65
View File
@@ -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/<branch> 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/<branch>. 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