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 <noreply@anthropic.com>
This commit is contained in:
parent
1dffe330a6
commit
ee48319a86
|
|
@ -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
|
||||
|
|
|
|||
187
scripts/ha/import.sh
Executable file
187
scripts/ha/import.sh
Executable file
|
|
@ -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/<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
|
||||
33
scripts/ha/lib/apply_gitignore.sh
Executable file
33
scripts/ha/lib/apply_gitignore.sh
Executable file
|
|
@ -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"
|
||||
}
|
||||
47
scripts/ha/lib/instance_config.py
Executable file
47
scripts/ha/lib/instance_config.py
Executable file
|
|
@ -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 <name>)"
|
||||
"""
|
||||
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 <instances.yaml> <instance-name>", 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))
|
||||
63
scripts/ha/lib/normalize.py
Executable file
63
scripts/ha/lib/normalize.py
Executable file
|
|
@ -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 <yaml|json> <input-path>", 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))
|
||||
99
scripts/ha/lib/split.py
Executable file
99
scripts/ha/lib/split.py
Executable file
|
|
@ -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/<id>.yaml
|
||||
scenes.yaml -> scenes/<id>.yaml
|
||||
scripts.yaml -> scripts/<key>.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/<key>.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/<instance>/.
|
||||
|
||||
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 <source-yaml-path> <config-out-root>", 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))
|
||||
37
scripts/ha/tests/fixtures/automations.yaml
vendored
Normal file
37
scripts/ha/tests/fixtures/automations.yaml
vendored
Normal file
|
|
@ -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
|
||||
60
scripts/ha/tests/test_split_normalize.sh
Executable file
60
scripts/ha/tests/test_split_normalize.sh
Executable file
|
|
@ -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"
|
||||
11
services/home-assistant/.gitignore
vendored
Normal file
11
services/home-assistant/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
secrets.yaml
|
||||
*.db
|
||||
*.db-*
|
||||
*.log*
|
||||
.storage/auth*
|
||||
.storage/core.restore_state
|
||||
.storage/*token*
|
||||
tts/
|
||||
deps/
|
||||
backups/
|
||||
.cloud/
|
||||
143
services/home-assistant/DESIGN.md
Normal file
143
services/home-assistant/DESIGN.md
Normal file
|
|
@ -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>`):
|
||||
|
||||
| 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 <instance>` on a dev workstation pulls
|
||||
current state and, if it differs from the last commit, the operator
|
||||
commits it under a conventional message: `drift(<instance>): <summary>`.
|
||||
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/<instance>/automations/<id>.yaml`
|
||||
- `scripts.yaml` → `config/<instance>/scripts/<key>.yaml`
|
||||
- `scenes.yaml` → `config/<instance>/scenes/<id>.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/<instance>.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.
|
||||
45
services/home-assistant/README.md
Normal file
45
services/home-assistant/README.md
Normal file
|
|
@ -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/<instance>/ # canonical, normalized /config mirror per instance
|
||||
├── storage-export/<instance>/ # 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.
|
||||
0
services/home-assistant/config/chelsty-ha/.gitkeep
Normal file
0
services/home-assistant/config/chelsty-ha/.gitkeep
Normal file
0
services/home-assistant/config/ken/.gitkeep
Normal file
0
services/home-assistant/config/ken/.gitkeep
Normal file
0
services/home-assistant/fixtures/.gitkeep
Normal file
0
services/home-assistant/fixtures/.gitkeep
Normal file
35
services/home-assistant/instances.yaml
Normal file
35
services/home-assistant/instances.yaml
Normal file
|
|
@ -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
|
||||
0
services/home-assistant/storage-export/ken/.gitkeep
Normal file
0
services/home-assistant/storage-export/ken/.gitkeep
Normal file
Loading…
Reference in a new issue