278 lines
9.9 KiB
Bash
278 lines
9.9 KiB
Bash
|
|
#!/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
|