From ee48319a86ac854d70386a417b44bb50006bc905 Mon Sep 17 00:00:00 2001 From: oskar Date: Tue, 21 Jul 2026 12:40:21 +0200 Subject: [PATCH] feat(ha): skeleton for Home Assistant configs-as-code subproject 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 --- docs/backlog.md | 13 ++ scripts/ha/import.sh | 187 ++++++++++++++++++ scripts/ha/lib/apply_gitignore.sh | 33 ++++ scripts/ha/lib/instance_config.py | 47 +++++ scripts/ha/lib/normalize.py | 63 ++++++ scripts/ha/lib/split.py | 99 ++++++++++ scripts/ha/tests/fixtures/automations.yaml | 37 ++++ scripts/ha/tests/test_split_normalize.sh | 60 ++++++ services/home-assistant/.gitignore | 11 ++ services/home-assistant/DESIGN.md | 143 ++++++++++++++ services/home-assistant/README.md | 45 +++++ .../home-assistant/config/chelsty-ha/.gitkeep | 0 services/home-assistant/config/ken/.gitkeep | 0 services/home-assistant/fixtures/.gitkeep | 0 services/home-assistant/instances.yaml | 35 ++++ .../storage-export/chelsty-ha/.gitkeep | 0 .../storage-export/ken/.gitkeep | 0 17 files changed, 773 insertions(+) create mode 100755 scripts/ha/import.sh create mode 100755 scripts/ha/lib/apply_gitignore.sh create mode 100755 scripts/ha/lib/instance_config.py create mode 100755 scripts/ha/lib/normalize.py create mode 100755 scripts/ha/lib/split.py create mode 100644 scripts/ha/tests/fixtures/automations.yaml create mode 100755 scripts/ha/tests/test_split_normalize.sh create mode 100644 services/home-assistant/.gitignore create mode 100644 services/home-assistant/DESIGN.md create mode 100644 services/home-assistant/README.md create mode 100644 services/home-assistant/config/chelsty-ha/.gitkeep create mode 100644 services/home-assistant/config/ken/.gitkeep create mode 100644 services/home-assistant/fixtures/.gitkeep create mode 100644 services/home-assistant/instances.yaml create mode 100644 services/home-assistant/storage-export/chelsty-ha/.gitkeep create mode 100644 services/home-assistant/storage-export/ken/.gitkeep diff --git a/docs/backlog.md b/docs/backlog.md index 21bab92..9eb47e9 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -4,6 +4,19 @@ Centralny tracker tech-długu i znanych usterek. Wpisy ze sesji — dodawaj z da --- +## Nowy podprojekt: Home Assistant configs-as-code (szkielet) + +**Data**: 2026-07-21 +**Branch**: `task/ha-skeleton` + +Szkielet struktury dla `services/home-assistant/` — configs-as-code dla +instancji HA (`ken` na PIHA, `chelsty-ha`). Na razie tylko struktura + +read-only import (`scripts/ha/import.sh`), bez deployu. Fazowanie, wybór +adaptera per instancja, model sync i otwarte pytania — +`services/home-assistant/DESIGN.md`. + +--- + ## Plan: Monitoring floty — Prometheus jako źródło prawdy **Data**: 2026-06-22 diff --git a/scripts/ha/import.sh b/scripts/ha/import.sh new file mode 100755 index 0000000..c276f40 --- /dev/null +++ b/scripts/ha/import.sh @@ -0,0 +1,187 @@ +#!/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// or storage-export//. +# +# 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 +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 + + 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 " >&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 diff --git a/scripts/ha/lib/apply_gitignore.sh b/scripts/ha/lib/apply_gitignore.sh new file mode 100755 index 0000000..c48ccf6 --- /dev/null +++ b/scripts/ha/lib/apply_gitignore.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Delete files from an extracted /config tree that match +# services/home-assistant/.gitignore patterns. +# +# Intentionally NOT a full gitignore engine — the exclusion list is small +# and fixed (see DESIGN.md, "Scope"), so each line is applied as either a +# directory-name prune (trailing "/") or a path/basename glob. +set -euo pipefail + +apply_gitignore_filter() { + local root_dir="$1" + local gitignore_file="$2" + + if [[ ! -f "$gitignore_file" ]]; then + echo "apply_gitignore_filter: gitignore file not found: $gitignore_file" >&2 + return 1 + fi + + local pattern + while IFS= read -r pattern; do + [[ -z "$pattern" || "$pattern" == \#* ]] && continue + + if [[ "$pattern" == */ ]]; then + local dirname="${pattern%/}" + find "$root_dir" -type d -name "$dirname" -prune -exec rm -rf {} + + elif [[ "$pattern" == */* ]]; then + # path-relative pattern, e.g. ".storage/auth*" + find "$root_dir" -type f -path "*/${pattern}" -delete + else + find "$root_dir" -type f -name "$pattern" -delete + fi + done < "$gitignore_file" +} diff --git a/scripts/ha/lib/instance_config.py b/scripts/ha/lib/instance_config.py new file mode 100755 index 0000000..dd50350 --- /dev/null +++ b/scripts/ha/lib/instance_config.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Print shell-safe `KEY=value` lines for one instance from instances.yaml. + +Used by scripts/ha/import.sh via: + eval "$(python3 lib/instance_config.py instances.yaml )" +""" +import shlex +import sys + +import yaml + +FIELDS = [ + "host", + "container", + "config_mount", + "adapter", + "base_url", + "token_path", + "status", + "site", +] + + +def main(argv): + if len(argv) != 3: + print("usage: instance_config.py ", file=sys.stderr) + return 2 + instances_path, name = argv[1], argv[2] + with open(instances_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + instances = (data or {}).get("instances", {}) + if name not in instances: + known = ", ".join(sorted(instances)) or "(none)" + print(f"unknown instance '{name}' (known: {known})", file=sys.stderr) + return 1 + inst = instances[name] or {} + ssh = inst.get("ssh") or {} + print(f"HA_INSTANCE={shlex.quote(name)}") + for field in FIELDS: + print(f"HA_{field.upper()}={shlex.quote(str(inst.get(field, '')))}") + print(f"HA_SSH_USER={shlex.quote(str(ssh.get('user', '')))}") + print(f"HA_SSH_HOST={shlex.quote(str(ssh.get('host', '')))}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/ha/lib/normalize.py b/scripts/ha/lib/normalize.py new file mode 100755 index 0000000..a8750e0 --- /dev/null +++ b/scripts/ha/lib/normalize.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Canonical YAML normalization used by every HA import path. + +One function, one style, used everywhere data lands in the repo (see +services/home-assistant/DESIGN.md, "Canonical format"): block collections, +indent width 2, unlimited line width, sorted keys, UTF-8. +""" +import json +import sys + +import yaml + + +class _CanonicalDumper(yaml.SafeDumper): + pass + + +def _represent_none(dumper, _value): + return dumper.represent_scalar("tag:yaml.org,2002:null", "") + + +_CanonicalDumper.add_representer(type(None), _represent_none) + + +def dump_canonical_yaml(data): + """Render `data` as canonical YAML text.""" + return yaml.dump( + data, + Dumper=_CanonicalDumper, + default_flow_style=False, + sort_keys=True, + allow_unicode=True, + width=1 << 31, + indent=2, + ) + + +def normalize_yaml_text(text): + """Parse YAML text then re-dump it through the canonical formatter.""" + data = yaml.safe_load(text) + return dump_canonical_yaml(data) + + +def normalize_json_text(text): + """Parse JSON text (e.g. a `.storage/*` file) and dump as canonical YAML.""" + data = json.loads(text) + return dump_canonical_yaml(data) + + +def main(argv): + if len(argv) != 3 or argv[1] not in ("yaml", "json"): + print("usage: normalize.py ", file=sys.stderr) + return 2 + mode, path = argv[1], argv[2] + with open(path, "r", encoding="utf-8") as f: + text = f.read() + out = normalize_yaml_text(text) if mode == "yaml" else normalize_json_text(text) + sys.stdout.write(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/ha/lib/split.py b/scripts/ha/lib/split.py new file mode 100755 index 0000000..405cae2 --- /dev/null +++ b/scripts/ha/lib/split.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Split HA list/dict config files into one-object-per-file layouts. + +See services/home-assistant/DESIGN.md ("Split + normalization"): + automations.yaml -> automations/.yaml + scenes.yaml -> scenes/.yaml + scripts.yaml -> scripts/.yaml +""" +import os +import sys + +import yaml + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from normalize import dump_canonical_yaml # noqa: E402 + + +def split_list_by_id(items, id_field="id"): + """items: list of dicts, each expected to carry an `id_field` key. + + Returns {id: obj}. Raises ValueError on a missing/duplicate id so a bad + split can never silently drop or overwrite an object. + """ + result = {} + for obj in items: + if not isinstance(obj, dict) or id_field not in obj: + raise ValueError(f"object missing '{id_field}' field: {obj!r}") + key = str(obj[id_field]) + if key in result: + raise ValueError(f"duplicate {id_field} '{key}'") + result[key] = obj + return result + + +def split_dict(data): + """scripts.yaml is already a dict keyed by script id — pass through.""" + return dict(data or {}) + + +SPLIT_SPECS = { + "automations.yaml": ("automations", lambda data: split_list_by_id(data or [], "id")), + "scenes.yaml": ("scenes", lambda data: split_list_by_id(data or [], "id")), + "scripts.yaml": ("scripts", split_dict), +} + + +def write_split(objects_by_key, out_dir): + """Write out_dir/.yaml (canonical form) for each key/obj pair. + + Removes stale *.yaml files left over from a previous run for keys that + no longer exist, so the split output always mirrors the source exactly. + """ + os.makedirs(out_dir, exist_ok=True) + expected = {f"{key}.yaml" for key in objects_by_key} + for existing in os.listdir(out_dir): + if existing.endswith(".yaml") and existing not in expected: + os.remove(os.path.join(out_dir, existing)) + for key, obj in objects_by_key.items(): + path = os.path.join(out_dir, f"{key}.yaml") + with open(path, "w", encoding="utf-8") as f: + f.write(dump_canonical_yaml(obj)) + + +def split_file(source_path, out_root): + """source_path: path to e.g. .../automations.yaml. out_root: config//. + + Returns the output subdirectory written, or None if source_path's + basename isn't one of the known split targets. + """ + basename = os.path.basename(source_path) + if basename not in SPLIT_SPECS: + return None + subdir_name, splitter = SPLIT_SPECS[basename] + with open(source_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + objects_by_key = splitter(data) + out_dir = os.path.join(out_root, subdir_name) + write_split(objects_by_key, out_dir) + return out_dir + + +def main(argv): + if len(argv) != 3: + print("usage: split.py ", file=sys.stderr) + return 2 + source_path, out_root = argv[1], argv[2] + result_dir = split_file(source_path, out_root) + if result_dir is None: + print( + f"split.py: '{os.path.basename(source_path)}' is not a known split target", + file=sys.stderr, + ) + return 1 + print(result_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/ha/tests/fixtures/automations.yaml b/scripts/ha/tests/fixtures/automations.yaml new file mode 100644 index 0000000..7e3644f --- /dev/null +++ b/scripts/ha/tests/fixtures/automations.yaml @@ -0,0 +1,37 @@ +- id: "morning_lights" + alias: "Morning lights on" + trigger: + - platform: time + at: "06:30:00" + condition: [] + action: + - service: light.turn_on + target: + entity_id: light.living_room + mode: single + +- id: "goodnight" + alias: "Goodnight routine" + trigger: + - platform: state + entity_id: input_boolean.goodnight + to: "on" + condition: [] + action: + - service: light.turn_off + target: + entity_id: all + mode: single + +- id: "low_battery_alert" + alias: "Low battery alert" + trigger: + - platform: numeric_state + entity_id: sensor.front_door_battery + below: 20 + condition: [] + action: + - service: notify.mobile_app + data: + message: "Front door sensor battery low" + mode: single diff --git a/scripts/ha/tests/test_split_normalize.sh b/scripts/ha/tests/test_split_normalize.sh new file mode 100755 index 0000000..3f0301f --- /dev/null +++ b/scripts/ha/tests/test_split_normalize.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Offline determinism test for split + normalize (scripts/ha/lib/split.py, +# normalize.py): running the pipeline twice against the same fixture must +# produce byte-identical output. No network access, no HA instance needed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HA_LIB_DIR="$SCRIPT_DIR/../lib" +FIXTURE="$SCRIPT_DIR/fixtures/automations.yaml" + +run_once() { + local out_root="$1" + mkdir -p "$out_root" + python3 "$HA_LIB_DIR/split.py" "$FIXTURE" "$out_root" > /dev/null +} + +TMP1="$(mktemp -d)" +TMP2="$(mktemp -d)" +trap 'rm -rf "$TMP1" "$TMP2"' EXIT + +run_once "$TMP1" +run_once "$TMP2" + +fail=0 + +if ! diff -r "$TMP1/automations" "$TMP2/automations" > /dev/null; then + echo "FAIL: split+normalize output differs between two runs of the same fixture" >&2 + diff -r "$TMP1/automations" "$TMP2/automations" >&2 || true + fail=1 +else + echo "PASS: split+normalize is deterministic across two runs" +fi + +expected_files=(morning_lights.yaml goodnight.yaml low_battery_alert.yaml) +for f in "${expected_files[@]}"; do + if [[ ! -f "$TMP1/automations/$f" ]]; then + echo "FAIL: expected split output '$f' not found" >&2 + fail=1 + fi +done +if [[ "$fail" -eq 0 ]]; then + echo "PASS: expected per-id automation files present" +fi + +# re-running split against a directory that already holds a stale file (from +# a since-removed automation id) must remove it — split output should always +# mirror the source exactly, never accumulate stale files. +STALE_DIR="$(mktemp -d)" +trap 'rm -rf "$STALE_DIR"' EXIT +mkdir -p "$STALE_DIR/automations" +touch "$STALE_DIR/automations/removed_automation.yaml" +python3 "$HA_LIB_DIR/split.py" "$FIXTURE" "$STALE_DIR" > /dev/null +if [[ -f "$STALE_DIR/automations/removed_automation.yaml" ]]; then + echo "FAIL: stale split output was not cleaned up" >&2 + fail=1 +else + echo "PASS: stale split output is cleaned up on re-run" +fi + +exit "$fail" diff --git a/services/home-assistant/.gitignore b/services/home-assistant/.gitignore new file mode 100644 index 0000000..b10fea1 --- /dev/null +++ b/services/home-assistant/.gitignore @@ -0,0 +1,11 @@ +secrets.yaml +*.db +*.db-* +*.log* +.storage/auth* +.storage/core.restore_state +.storage/*token* +tts/ +deps/ +backups/ +.cloud/ diff --git a/services/home-assistant/DESIGN.md b/services/home-assistant/DESIGN.md new file mode 100644 index 0000000..46c3e59 --- /dev/null +++ b/services/home-assistant/DESIGN.md @@ -0,0 +1,143 @@ +# Home Assistant configs-as-code — design decisions + +Status: **skeleton only**. No deploy path is implemented yet — this document +and the accompanying structure/import tooling are phase 0/1 scaffolding. +See `docs/backlog.md` for the tracking entry. + +## Phasing + +| Phase | Scope | +|---|---| +| **0 — Snapshot** | Import-only tooling (this skeleton). Pull `/config` + `.storage` from each instance into the repo, read-only. No deploy, no write path back to HA. | +| **1 — Repo + CC** | Repo is the reviewable source of truth. Changes are authored in the repo (by a human or Claude Code) and pushed manually via the docker-exec / api adapters described below. Deploy has a hard drift-abort (see Sync model). | +| **2 — MCP read-only** | Expose HA state (entities, areas, config) to agents via an MCP server in read-only mode — either a self-hosted MCP or `hass-mcp` — to let agents reason about the live instance without touching import/deploy paths. Undecided which (see Open questions). | +| **3 — Agents** | Agents propose changes (automations, scripts, scenes) through the same reviewable repo path used by humans; the human-in-the-loop approval flow from `services/control-plane/` (pending → approved → executed) governs anything destructive. Telegram becomes a first-class interface alongside CC. | + +Each phase is a hard gate: no phase-N tooling depends on phase-(N+1) existing. + +## Scope + +Full `/config` tree per instance, plus a curated export of `.storage/*` JSON +files that are meaningfully version-controllable (registries, dashboards, +helpers) — not secrets, tokens, or runtime databases. See `.gitignore` below +for the exact exclusion list. + +## Canonical format + +All YAML committed to the repo passes through one normalization function +(`scripts/ha/lib/normalize.py`), used by every import path with no +per-caller variation: + +- block style (no flow/inline collections) +- fixed indent width 2 +- keys sorted (stable diffs) +- unlimited line width (no wrapping) +- UTF-8, no BOM + +`.storage/*` files are JSON on disk in HA; on import they are parsed as JSON +and re-emitted through the same YAML normalizer so the whole repo — config +and storage-export alike — has one diff format. + +## Deploy path: adapter per instance + +There is no single deploy mechanism — each HA instance gets an adapter +behind a common interface (`import.sh`/eventual `deploy.sh `): + +| Instance | Adapter | Why | +|---|---|---| +| `ken` (piha, container `homeassistant5`) | **docker-exec over SSH** | No HA API port reachable from where imports run today; container filesystem is reachable via `ssh oskar@piha "docker exec homeassistant5 ..."`. See `hosts/piha/README.md`. | +| `chelsty-ha` | **api** | Reachable over Tailscale at `100.70.180.90:8123` (confirmed working path — `services/ha-diag-agent/DEPLOY.md` already curls this for health checks). Config-as-code deploy will reuse the same reachability, calling the HA REST/websocket API rather than shelling into the container. | + +**Open**: a `file` adapter (direct bind-mount / SSH `rsync` to the config +directory, bypassing `docker exec`) is worth revisiting once SSH access to +the `chelsty-ha` VM itself is verified — see Open questions. + +## Sync model: bidirectional, asymmetric safety + +- **Deploy** (repo → instance): hard **drift-abort**. Before writing, the + adapter re-imports the instance's current state and diffs it against the + last-known-imported snapshot committed in the repo. Any unexpected + difference aborts the deploy with a non-zero exit and a diff printed — + never silently overwrites live drift. This applies unconditionally on + PIHA (`ken`); untested/undecided whether it should be relaxed for + `chelsty-ha` given its intermittent LTE uplink (see Open questions). +- **Import** (instance → repo): the reverse direction is intentionally + permissive. Running `import.sh ` on a dev workstation pulls + current state and, if it differs from the last commit, the operator + commits it under a conventional message: `drift(): `. + This is how out-of-band UI changes (made directly in the HA web UI) get + captured back into git history instead of being silently overwritten by + the next deploy. + +## Validation gate + +`check_config` (HA's built-in config validator, invoked via the running +container/instance — `docker exec homeassistant5 python -m homeassistant +--script check_config -c /config` or the equivalent over the api adapter) +is a mandatory gate before any deploy. A failing `check_config` blocks the +deploy entirely; it is not a warning. + +## Change classification: reload vs. restart + +Deploys default to the least disruptive mechanism: + +- **reload** — default for anything HA exposes a reload service for + (automations, scripts, scenes, input_* helpers, template entities, etc.) + via `homeassistant.reload_config_entry` / domain-specific `*.reload` + services. +- **restart** — only when the changed file requires it (e.g. + `configuration.yaml` core changes, new integrations, `.storage` + registry edits) **and** only when the deploy is invoked with an explicit + `--restart` flag. No implicit restarts, ever — an unattended restart on + `chelsty-ha` during a period of LTE unavailability would leave the site + without automation until someone is physically present. + +## Split + normalization + +On import, list-of-object YAML files are split one-object-per-file so diffs +stay scoped to what actually changed: + +- `automations.yaml` → `config//automations/.yaml` +- `scripts.yaml` → `config//scripts/.yaml` +- `scenes.yaml` → `config//scenes/.yaml` + +Every other `*.yaml` file under `/config` is copied through the normalizer +1:1 (same relative path, same filename). Non-YAML files under `/config` +(binaries, databases, `secrets.yaml`, logs — see `.gitignore`) are never +copied into the repo. + +## Tokens + +- A dedicated `deploy_agent` HA user account (admin rights, **local-only** + — never exposed through the public API/ingress) is created per instance, + mirroring the existing `diag_agent` account pattern documented in + `services/ha-diag-agent/DEPLOY.md`. Reusing `diag_agent` is explicitly + rejected — deploy tooling and the diagnostic agent must be revocable + independently. +- Long-lived access tokens for `deploy_agent` live at + `~/.config/ha-deploy/.token` **on PIHA** (the control node + where import/deploy tooling runs), `chmod 600`. +- Tokens are never committed to the repo, never templated into + `env.example`-style files, and never logged. Import scripts that need a + token to hit `/api/states` fail soft (skip the fixtures step with a + message) if the token file is absent, rather than aborting the whole + import — see `scripts/ha/import.sh`. + +## Interface + +- Phase 0/1: Claude Code is the interface, with the operator's mobile CC + client acting as the bridge for approvals made away from a desk. +- Phase 3 (agents): Telegram joins CC as a first-class interface, reusing + the existing Telegram bot / approval-queue pattern from + `services/control-plane/`. + +## Open questions + +- What actually drives the phase-3 operational agent (a new agent process + vs. extending an existing one in `services/`)? +- Own minimal MCP server vs. adopting `hass-mcp` for phase 2 read-only + access — tradeoffs not yet evaluated. +- Whether SSH access to `chelsty-ha` itself (not just its HA API) is + available/reliable enough to justify a `file` adapter there, which would + let phase-1 tooling treat `chelsty-ha` more like `ken` for drift-checking + purposes. diff --git a/services/home-assistant/README.md b/services/home-assistant/README.md new file mode 100644 index 0000000..db2b4a6 --- /dev/null +++ b/services/home-assistant/README.md @@ -0,0 +1,45 @@ +# home-assistant (configs-as-code) + +**Status: skeleton.** Structure and read-only import tooling only — no +deploy path exists yet. See `DESIGN.md` for the full phasing, adapter, sync, +and validation model, and for the open questions still blocking phase 2/3. + +## Layout + +``` +services/home-assistant/ +├── DESIGN.md # decision registry — read this first +├── instances.yaml # per-instance adapter/host/token config +├── .gitignore # excludes secrets/db/log/token paths from every import +├── config// # canonical, normalized /config mirror per instance +├── storage-export// # curated .storage/* export (registries, dashboards) +└── fixtures/ # dated /api/states snapshots +``` + +Instances: `ken` (PIHA, container `homeassistant5`, docker-exec adapter), +`chelsty-ha` (Tailscale, api adapter — see `instances.yaml`). + +## Import + +```bash +scripts/ha/import.sh ken +``` + +Read-only: pulls `/config` from the instance, filters it through +`.gitignore`, normalizes and splits it into `config/ken/`, exports curated +`.storage/*` into `storage-export/ken/`, and (if a deploy token exists at +`~/.config/ha-deploy/ken.token`) writes a dated `/api/states` fixture. +Idempotent — re-running against an unchanged instance produces no diff. + +Only the `docker-exec` adapter is implemented; running `import.sh +chelsty-ha` today exits with a clear "not implemented" error (its `api` +adapter is out of scope for this skeleton). + +## Tests + +```bash +scripts/ha/tests/test_split_normalize.sh +``` + +Offline determinism check for the split+normalize pipeline — no network, +no HA instance required. diff --git a/services/home-assistant/config/chelsty-ha/.gitkeep b/services/home-assistant/config/chelsty-ha/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/home-assistant/config/ken/.gitkeep b/services/home-assistant/config/ken/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/home-assistant/fixtures/.gitkeep b/services/home-assistant/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/home-assistant/instances.yaml b/services/home-assistant/instances.yaml new file mode 100644 index 0000000..5450202 --- /dev/null +++ b/services/home-assistant/instances.yaml @@ -0,0 +1,35 @@ +# Home Assistant instances managed as config-as-code. +# Schema and adapter rationale: see DESIGN.md ("Deploy path: adapter per instance"). +# This file is read by scripts/ha/import.sh — do not add secrets/tokens here, +# only the path to where each instance's token is expected on disk. + +instances: + ken: + host: piha + container: homeassistant5 + config_mount: /home/pi/homeassistant/config + adapter: docker-exec + ssh: + user: oskar + host: piha + token_path: ~/.config/ha-deploy/ken.token + # TODO: verify actual container port mapping via `docker inspect homeassistant5` + # on piha (not runnable from this worktree — no SSH access here). Known so far + # from docs/infra/inventory-2026-06-30.md + inventory/topology.yaml: + # - ha-diag-agent on piha targets http://localhost:8123 (container reachable + # on piha's own loopback, so likely host networking or 8123:8123 published) + # - NPM ingress ha.kapala.org -> 192.168.31.7:8123 (piha's LAN IP), per + # inventory/topology.yaml services.home_assistant + # Until confirmed with `docker inspect`, treat this as unverified. + base_url: "http://localhost:8123" # TODO: confirm via docker inspect on piha + status: active + site: ken + + chelsty-ha: + host: chelsty-ha + tailscale_ip: 100.70.180.90 + adapter: api + base_url: "http://100.70.180.90:8123" + token_path: ~/.config/ha-deploy/chelsty-ha.token + status: offline + site: chelsty diff --git a/services/home-assistant/storage-export/chelsty-ha/.gitkeep b/services/home-assistant/storage-export/chelsty-ha/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/home-assistant/storage-export/ken/.gitkeep b/services/home-assistant/storage-export/ken/.gitkeep new file mode 100644 index 0000000..e69de29