#!/usr/bin/env bash # Offline tests for the deploy write path (scripts/ha/lib/deploy_api.py, # ha_write_api.py): clean deploy, drift-abort, validation rejecting broken # YAML, and verify detecting a post-write mismatch. No network access, no # HA instance needed -- REST calls are replaced with a fake in-memory # client (get/post_config/check_config); "repo HEAD" is a real temporary # git repo so git_show/git_ls_tracked exercise the actual git plumbing. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HA_LIB_DIR="$SCRIPT_DIR/../lib" FIXTURES_DIR="$SCRIPT_DIR/fixtures" python3 - "$HA_LIB_DIR" "$FIXTURES_DIR" <<'PYEOF' import json import os import subprocess import sys import tempfile lib_dir, fixtures_dir = sys.argv[1:3] sys.path.insert(0, lib_dir) import deploy_api # noqa: E402 from normalize import dump_canonical_yaml # noqa: E402 fail = False def check(condition, message): global fail if condition: print(f"PASS: {message}") else: print(f"FAIL: {message}", file=sys.stderr) fail = True def load_fixture(name): with open(os.path.join(fixtures_dir, name), encoding="utf-8") as f: return json.load(f) STATES = load_fixture("api_states.json") AUTOMATION_111 = load_fixture("api_automation_config_111.json") SCRIPT_DEMO = load_fixture("api_script_config_demo_script.json") SCENE_222 = load_fixture("api_scene_config_222.json") CONFIG_RELPATH = "services/home-assistant/config/testinst" class FakeClient: """Duck-types ha_write_api.WriteClient: .get, .post_config, .check_config.""" def __init__(self, get_responses, check_config_result=(True, {"result": "valid"})): self._get_responses = dict(get_responses) self._check_config_result = check_config_result self.posted = [] self.check_config_calls = 0 def get(self, path): if path == "/api/states": return STATES return self._get_responses.get(path) def post_config(self, domain, object_id, body): self.posted.append((domain, object_id, body)) return {"result": "ok"} def check_config(self): self.check_config_calls += 1 return self._check_config_result def init_repo_with_committed_objects(repo_root): """Commit automations/111, scripts/demo_script, scenes/222 at HEAD, exactly as import.sh would have written them (canonical YAML, split one-object- per-file).""" config_dir = os.path.join(repo_root, CONFIG_RELPATH) for subdir, key, obj in ( ("automations", "111", AUTOMATION_111), ("scripts", "demo_script", SCRIPT_DEMO), ("scenes", "222", SCENE_222), ): d = os.path.join(config_dir, subdir) os.makedirs(d, exist_ok=True) with open(os.path.join(d, f"{key}.yaml"), "w", encoding="utf-8") as f: f.write(dump_canonical_yaml(obj)) subprocess.run(["git", "init", "-q", repo_root], check=True) subprocess.run(["git", "-C", repo_root, "add", "-A"], check=True) subprocess.run( [ "git", "-C", repo_root, "-c", "user.email=test@example.com", "-c", "user.name=test", "commit", "-q", "-m", "initial import", ], check=True, ) return config_dir # --- Case A: clean deploy -- all three committed objects redeployed unchanged --- with tempfile.TemporaryDirectory() as repo_root: config_dir = init_repo_with_committed_objects(repo_root) get_responses = { "/api/config/automation/config/111": AUTOMATION_111, "/api/config/script/config/demo_script": SCRIPT_DEMO, "/api/config/scene/config/222": SCENE_222, } client = FakeClient(get_responses) result = deploy_api.run(repo_root, config_dir, CONFIG_RELPATH, "testinst", [], False, client) check(not result["aborted"], "clean deploy: not aborted") check(result["drift_aborts"] == [], "clean deploy: no drift aborts (all objects are targets)") check(result["validation_errors"] == [], "clean deploy: no validation errors") check(result["check_config_valid"] is True, "clean deploy: check_config reported valid") check(len(result["written"]) == 3, "clean deploy: all three objects written") check(result["write_failed"] == [], "clean deploy: no write failures") check(result["verify_failed"] == [], "clean deploy: no verify failures") check( set(client.posted[i][0:2] for i in range(3)) == {("automation", "111"), ("script", "demo_script"), ("scene", "222")}, "clean deploy: posted exactly the three expected (domain, id) pairs", ) # --- Case B: drift-abort -- a non-target object diverged on the live instance --- with tempfile.TemporaryDirectory() as repo_root: config_dir = init_repo_with_committed_objects(repo_root) drifted_automation = dict(AUTOMATION_111) drifted_automation["alias"] = "Evil override made directly in the HA UI" get_responses = { "/api/config/automation/config/111": drifted_automation, # drift: differs from repo HEAD "/api/config/script/config/demo_script": SCRIPT_DEMO, "/api/config/scene/config/222": SCENE_222, } client = FakeClient(get_responses) # Only redeploying the script -- automation/111 and scene/222 are NOT # targets, so any difference there must abort the whole run. result = deploy_api.run( repo_root, config_dir, CONFIG_RELPATH, "testinst", ["scripts/demo_script.yaml"], False, client, ) check(result["aborted"], "drift-abort: run is aborted") check( any(a[0] == "automations" and a[1] == "111" for a in result["drift_aborts"]), "drift-abort: automations/111 reported as drifted", ) check(client.posted == [], "drift-abort: no write was ever attempted") check(client.check_config_calls == 0, "drift-abort: check_config was never reached") # --- Case C: local validation rejects an object missing a required key --- with tempfile.TemporaryDirectory() as repo_root: config_dir = init_repo_with_committed_objects(repo_root) broken_dir = os.path.join(config_dir, "scripts") with open(os.path.join(broken_dir, "broken_script.yaml"), "w", encoding="utf-8") as f: f.write(dump_canonical_yaml({"alias": "broken, no sequence key"})) # Deliberately left uncommitted -- it's a new object being deployed for # the first time, which is exactly the case target-exclusion is for. # The other three committed objects must match live state exactly so # drift-check passes cleanly and the run reaches local validation. get_responses = { "/api/config/automation/config/111": AUTOMATION_111, "/api/config/script/config/demo_script": SCRIPT_DEMO, "/api/config/scene/config/222": SCENE_222, } client = FakeClient(get_responses) result = deploy_api.run( repo_root, config_dir, CONFIG_RELPATH, "testinst", ["scripts/broken_script.yaml"], False, client, ) check(result["aborted"], "broken YAML: run is aborted") check( any(e[0] == "scripts" and e[1] == "broken_script" for e in result["validation_errors"]), "broken YAML: scripts/broken_script reported as a validation error", ) check(client.posted == [], "broken YAML: no write was ever attempted") check(client.check_config_calls == 0, "broken YAML: check_config was never reached") with tempfile.TemporaryDirectory() as repo_root: config_dir = init_repo_with_committed_objects(repo_root) unparseable_dir = os.path.join(config_dir, "scripts") with open(os.path.join(unparseable_dir, "unparseable.yaml"), "w", encoding="utf-8") as f: f.write("alias: [unterminated flow sequence\n") get_responses = { "/api/config/automation/config/111": AUTOMATION_111, "/api/config/script/config/demo_script": SCRIPT_DEMO, "/api/config/scene/config/222": SCENE_222, } client = FakeClient(get_responses) result = deploy_api.run( repo_root, config_dir, CONFIG_RELPATH, "testinst", ["scripts/unparseable.yaml"], False, client, ) check(result["aborted"], "unparseable YAML: run is aborted, not a Python traceback") check( any(e[0] == "scripts" and e[1] == "unparseable" for e in result["validation_errors"]), "unparseable YAML: reported as a validation error naming the file", ) # --- Case D: verify detects a post-write mismatch (no auto-rollback) --- with tempfile.TemporaryDirectory() as repo_root: config_dir = init_repo_with_committed_objects(repo_root) get_responses = { "/api/config/automation/config/111": AUTOMATION_111, # verify GET for the script comes back different from what was written -- # e.g. the instance silently coerced/rejected a field. "/api/config/script/config/demo_script": {**SCRIPT_DEMO, "alias": "mutated_by_instance"}, "/api/config/scene/config/222": SCENE_222, } client = FakeClient(get_responses) result = deploy_api.run(repo_root, config_dir, CONFIG_RELPATH, "testinst", [], False, client) check(not result["aborted"], "verify-mismatch: run completes (write/verify errors are reported, not an abort)") check( any(v[0] == "scripts" and v[1] == "demo_script" for v in result["verify_failed"]), "verify-mismatch: scripts/demo_script reported as a verify failure", ) check( any(w[0] == "automations" and w[1] == "111" for w in result["written"]) and any(w[0] == "scenes" and w[1] == "222" for w in result["written"]), "verify-mismatch: the other two objects still wrote and verified successfully (no auto-rollback)", ) check( ("script", "demo_script") in {(p[0], p[1]) for p in client.posted}, "verify-mismatch: the mismatched object was still posted (verify runs after write, not before)", ) sys.exit(1 if fail else 0) PYEOF