Structure only, no deploy path: DESIGN.md decision registry (phasing, per-instance adapter, bidirectional sync with drift-abort on deploy, reload-vs-restart gating, token handling), instances.yaml (ken/piha via docker-exec, chelsty-ha via api), config/storage-export/fixtures dirs, and a read-only scripts/ha/import.sh (docker-exec adapter only) with a canonical YAML normalize+split library and an offline determinism test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
6.1 KiB
Bash
Executable file
188 lines
6.1 KiB
Bash
Executable file
#!/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 services/home-assistant/DESIGN.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/<instance>/ or storage-export/<instance>/.
|
|
#
|
|
# Only the "docker-exec" adapter (instance "ken") is implemented here. Other
|
|
# adapters (e.g. "api", used by "chelsty-ha") are out of scope for this
|
|
# skeleton — see DESIGN.md, "Deploy path: adapter per instance". The
|
|
# /api/states fixture fetch is the one piece that works for any adapter,
|
|
# since it only needs a reachable base_url + token, not full config access.
|
|
#
|
|
# Usage: scripts/ha/import.sh <instance>
|
|
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:-<unset>}' — skipping fixtures fetch (not a failure)" >&2
|
|
return 0
|
|
fi
|
|
|
|
local token
|
|
token="$(<"$token_file")"
|
|
|
|
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
|
|
ssh "${HA_SSH_USER}@${HA_SSH_HOST}" \
|
|
"curl -sf -H 'Authorization: Bearer ${token}' http://localhost:8123/api/states" \
|
|
> "$raw_file" || fetch_ok=0
|
|
else
|
|
curl -sf -H "Authorization: Bearer ${token}" "${HA_BASE_URL}/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 <instance>" >&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
|
|
echo "error: adapter '$HA_ADAPTER' has no config-extraction implementation in this skeleton." >&2
|
|
echo " see DESIGN.md, 'Deploy path: adapter per instance' — only docker-exec is built." >&2
|
|
echo " config/${INSTANCE}/ and storage-export/${INSTANCE}/ were NOT touched." >&2
|
|
fetch_fixtures || true
|
|
exit 1
|
|
fi
|
|
|
|
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
|
|
|
|
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
|