homelab-codex-ws/scripts/ha/import.sh
oskar 4658089e21 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.

  15  markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
      odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
      rozwiazywala sie z katalogu, w ktorym lezala)
 200  odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
      -> nowa sciezka repo-root-relative, zgodnie z konwencja repo
   5  linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
      tylko w starym katalogu; przeliczone recznie

Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.

Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.

Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.

Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:46 +02:00

216 lines
7.5 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 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/<instance>/ or storage-export/<instance>/.
#
# 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 <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
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 <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
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:-<unset>}'." >&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