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