homelab-codex-ws/scripts/ha/tests/test_deploy_api_offline.sh

278 lines
9.9 KiB
Bash
Raw Normal View History

#!/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