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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user