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
|
## Plan: Monitoring floty — Prometheus jako źródło prawdy
|
||||||
|
|
||||||
**Data**: 2026-06-22
|
**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