#!/usr/bin/env bash # Read-only import of one Home Assistant instance's /config tree (plus a # curated .storage/* export and an /api/states fixture snapshot) into this # repo, normalized and split per kb/decisions/ha-configs-as-code.md. # # This script NEVER writes to the HA instance. It is idempotent: re-running # against an unchanged instance produces no diff under # services/home-assistant/config// or storage-export//. # # Two adapters are implemented — see DESIGN.md, "Deploy path: adapter per # instance": # - "docker-exec": pulls the whole /config tree over `ssh ... docker exec # ... tar`. Used where the HA instance's filesystem is reachable. # - "api": for instances with no filesystem/SSH access (HAOS, e.g. "ken"). # Pulls automations/scripts/scenes via REST and dashboards/registries/ # helpers via the WebSocket API (scripts/ha/lib/import_api.py). Full # /config import is out of scope for this adapter. # # The /api/states fixture fetch works for either adapter, since it only # needs a reachable base_url + token, not full config access. # # Usage: scripts/ha/import.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SERVICE_DIR="$(cd "$SCRIPT_DIR/../../services/home-assistant" && pwd)" INSTANCES_FILE="$SERVICE_DIR/instances.yaml" GITIGNORE_FILE="$SERVICE_DIR/.gitignore" # shellcheck source=lib/apply_gitignore.sh source "$SCRIPT_DIR/lib/apply_gitignore.sh" is_storage_export_candidate() { local base="$1" case "$base" in core.area_registry|core.entity_registry) return 0 ;; input_*|lovelace*) return 0 ;; *) return 1 ;; esac } fetch_fixtures() { local token_file="${HA_TOKEN_PATH/#\~/$HOME}" if [[ -z "$HA_TOKEN_PATH" || ! -f "$token_file" ]]; then echo "-> fixtures: no token at '${token_file:-}' — skipping fixtures fetch (not a failure)" >&2 return 0 fi mkdir -p "$FIXTURES_DIR" local dest="$FIXTURES_DIR/${INSTANCE}-states-$(date +%F).yaml" local raw_file raw_file="$(mktemp)" local fetch_ok=1 if [[ "$HA_ADAPTER" == "docker-exec" ]]; then # docker-exec adapter: token is read into a shell variable and embedded # in the remote curl command over ssh (pre-existing behavior, left # untouched — see DESIGN.md, "Deploy path: adapter per instance"). local token token="$(<"$token_file")" ssh "${HA_SSH_USER}@${HA_SSH_HOST}" \ "curl -sf -H 'Authorization: Bearer ${token}' http://localhost:8123/api/states" \ > "$raw_file" || fetch_ok=0 else # api adapter: the token never touches a shell variable or a subprocess # argv here — ha_api.py reads token_file itself and sets the # Authorization header in-process via `requests`. python3 "$SCRIPT_DIR/lib/ha_api.py" get-raw "$HA_BASE_URL" "$token_file" /api/states \ > "$raw_file" || fetch_ok=0 fi if [[ "$fetch_ok" -ne 1 ]]; then echo "-> fixtures: fetch failed for instance '$INSTANCE' — skipping (not failing the import)" >&2 rm -f "$raw_file" return 0 fi python3 "$SCRIPT_DIR/lib/normalize.py" json "$raw_file" > "$dest" rm -f "$raw_file" echo "-> fixtures: wrote $dest" >&2 } list_known_instances() { python3 - "$INSTANCES_FILE" <<'PYEOF' import sys import yaml data = yaml.safe_load(open(sys.argv[1], encoding="utf-8")) print(" ".join(sorted((data or {}).get("instances", {})))) PYEOF } usage() { echo "usage: $0 " >&2 echo "known instances: $(list_known_instances)" >&2 } if [[ $# -ne 1 ]]; then usage exit 2 fi INSTANCE="$1" if [[ ! -f "$INSTANCES_FILE" ]]; then echo "error: instances file not found: $INSTANCES_FILE" >&2 exit 1 fi INSTANCE_CONFIG_OUTPUT="$(python3 "$SCRIPT_DIR/lib/instance_config.py" "$INSTANCES_FILE" "$INSTANCE")" || exit 1 eval "$INSTANCE_CONFIG_OUTPUT" CONFIG_OUT_DIR="$SERVICE_DIR/config/$INSTANCE" STORAGE_OUT_DIR="$SERVICE_DIR/storage-export/$INSTANCE" FIXTURES_DIR="$SERVICE_DIR/fixtures" echo "== ha import: instance=$INSTANCE adapter=$HA_ADAPTER host=$HA_HOST ==" >&2 if [[ "$HA_ADAPTER" == "docker-exec" ]]; then if [[ -z "$HA_SSH_USER" || -z "$HA_SSH_HOST" ]]; then echo "error: instance '$INSTANCE' is missing ssh.user/ssh.host in instances.yaml" >&2 exit 1 fi if [[ -z "$HA_CONTAINER" ]]; then echo "error: instance '$INSTANCE' is missing 'container' in instances.yaml" >&2 exit 1 fi TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/ha-import-${INSTANCE}.XXXXXX")" trap 'rm -rf "$TMPDIR"' EXIT EXTRACT_DIR="$TMPDIR/config" mkdir -p "$EXTRACT_DIR" echo "-> pulling /config from ${HA_CONTAINER} on ${HA_SSH_HOST} via docker exec ..." >&2 if ! ssh "${HA_SSH_USER}@${HA_SSH_HOST}" "docker exec ${HA_CONTAINER} tar cf - -C /config ." > "$TMPDIR/config.tar"; then echo "error: ssh/docker exec pull failed for instance '$INSTANCE' (host=${HA_SSH_HOST} container=${HA_CONTAINER})" >&2 exit 1 fi tar xf "$TMPDIR/config.tar" -C "$EXTRACT_DIR" echo "-> filtering excluded paths (per $GITIGNORE_FILE) ..." >&2 apply_gitignore_filter "$EXTRACT_DIR" "$GITIGNORE_FILE" echo "-> normalizing + splitting config into $CONFIG_OUT_DIR ..." >&2 mkdir -p "$CONFIG_OUT_DIR" while IFS= read -r -d '' src; do rel="${src#"$EXTRACT_DIR"/}" base="$(basename "$src")" case "$base" in automations.yaml|scripts.yaml|scenes.yaml) python3 "$SCRIPT_DIR/lib/split.py" "$src" "$CONFIG_OUT_DIR" > /dev/null ;; *) dest="$CONFIG_OUT_DIR/$rel" mkdir -p "$(dirname "$dest")" python3 "$SCRIPT_DIR/lib/normalize.py" yaml "$src" > "$dest" ;; esac done < <(find "$EXTRACT_DIR" -type f -name '*.yaml' -not -path '*/.storage/*' -print0) echo "-> exporting curated .storage/* into $STORAGE_OUT_DIR ..." >&2 mkdir -p "$STORAGE_OUT_DIR" if [[ -d "$EXTRACT_DIR/.storage" ]]; then while IFS= read -r -d '' src; do base="$(basename "$src")" if is_storage_export_candidate "$base"; then dest="$STORAGE_OUT_DIR/${base}.yaml" python3 "$SCRIPT_DIR/lib/normalize.py" json "$src" > "$dest" fi done < <(find "$EXTRACT_DIR/.storage" -maxdepth 1 -type f -print0) else echo " (no .storage directory in pulled config — skipping)" >&2 fi elif [[ "$HA_ADAPTER" == "api" ]]; then api_token_file="${HA_TOKEN_PATH/#\~/$HOME}" if [[ -z "$HA_TOKEN_PATH" || ! -f "$api_token_file" ]]; then echo "error: instance '$INSTANCE' has adapter=api but no token at '${api_token_file:-}'." >&2 echo " the api adapter has no filesystem fallback (HAOS has no SSH) — without a" >&2 echo " deploy_agent long-lived access token there is nothing to import at all." >&2 echo " See DESIGN.md, 'Tokens', for how to provision one." >&2 exit 1 fi mkdir -p "$CONFIG_OUT_DIR" "$STORAGE_OUT_DIR" echo "-> importing automations/scripts/scenes (REST) + dashboards/registries/helpers (websocket) ..." >&2 python3 "$SCRIPT_DIR/lib/import_api.py" "$HA_BASE_URL" "$api_token_file" "$CONFIG_OUT_DIR" "$STORAGE_OUT_DIR" else echo "error: adapter '$HA_ADAPTER' has no config-extraction implementation." >&2 echo " see DESIGN.md, 'Deploy path: adapter per instance' — only docker-exec and api are built." >&2 echo " config/${INSTANCE}/ and storage-export/${INSTANCE}/ were NOT touched." >&2 fetch_fixtures || true exit 1 fi fetch_fixtures echo "-> summary of changes:" >&2 REPO_ROOT="$(git -C "$SERVICE_DIR" rev-parse --show-toplevel 2>/dev/null || true)" if [[ -n "$REPO_ROOT" ]]; then git -C "$REPO_ROOT" status --porcelain -- \ "services/home-assistant/config/$INSTANCE" \ "services/home-assistant/storage-export/$INSTANCE" || true else echo " (not inside a git worktree — skipping git diff summary)" >&2 fi echo "== import complete: $INSTANCE ==" >&2