homelab-codex-ws/scripts/ha/lib/deploy_api.py

501 lines
19 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Deploy path (repo -> instance) for the HA "api" adapter — see
services/home-assistant/DESIGN.md, "Sync model" and "Validation gate".
Scope: automations/scripts/scenes only (the domains reachable via
`/api/config/<domain>/config/<id>`). Dashboards and helpers are WS-only
ha_ws.py has no mutating command, deliberately and are a separate task.
Hard sequence, no shortcuts, matching DESIGN.md:
1. DRIFT-CHECK re-import the instance's current state (reusing
import_api.py's tested fetch/normalize logic) and diff it against the
last-known-imported snapshot committed at repo HEAD. Any difference
outside the objects this run is about to write aborts immediately
drift is never silently overwritten.
2. VALIDATE local sanity per object (parses, has the domain's required
keys) plus a live `check_config` gate on the instance. Either failing
aborts before a single write.
3. WRITE one POST per object, body = the repo file parsed back to JSON
(the inverse of import_api's GET-json -> normalize -> yaml transform).
4. VERIFY GET the same object back, normalize, compare to what was
just written. A mismatch is reported as an error; there is no
auto-rollback.
Every function here is written to be called directly with an injected
duck-typed client (see tests/test_deploy_api_offline.sh) no network, no
real HA instance, no live git repo required for the core logic.
"""
import difflib
import os
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ha_api # noqa: E402
import ha_write_api # noqa: E402
import import_api # noqa: E402
from normalize import dump_canonical_yaml, parse_yaml_text # noqa: E402
DOMAIN_SUBDIRS = ("automations", "scripts", "scenes")
DOMAIN_API_NAME = {"automations": "automation", "scripts": "script", "scenes": "scene"}
REQUIRED_KEYS = {
"automations": ("id",),
"scenes": ("id", "entities"),
"scripts": ("sequence",),
}
class DeployScopeError(RuntimeError):
"""A file argument can't be resolved to an in-scope object to deploy."""
# --- git-HEAD access (the "last-known-imported snapshot") ------------------
def _run_git(repo_root, args):
return subprocess.run(
["git", "-C", repo_root] + args, capture_output=True, text=True, check=False
)
def git_show(repo_root, relpath):
"""Return HEAD's content of relpath, or None if not tracked at HEAD."""
proc = _run_git(repo_root, ["show", f"HEAD:{relpath}"])
if proc.returncode != 0:
return None
return proc.stdout
def git_ls_tracked(repo_root, dir_relpath):
"""List tracked file paths (relative to repo_root) under dir_relpath at HEAD."""
proc = _run_git(repo_root, ["ls-tree", "-r", "--name-only", "HEAD", "--", dir_relpath])
if proc.returncode != 0:
return []
return [line for line in proc.stdout.splitlines() if line]
def load_repo_head_dir(repo_root, dir_relpath):
"""{key: canonical_yaml_text} for every *.yaml tracked at HEAD under dir_relpath."""
result = {}
for relpath in git_ls_tracked(repo_root, dir_relpath):
if not relpath.endswith(".yaml"):
continue
key = os.path.basename(relpath)[: -len(".yaml")]
text = git_show(repo_root, relpath)
if text is None:
continue
result[key] = dump_canonical_yaml(parse_yaml_text(text))
return result
def load_yaml_dir(dir_path):
"""{key: canonical_yaml_text} for every *.yaml file in dir_path (working tree)."""
result = {}
if not os.path.isdir(dir_path):
return result
for name in sorted(os.listdir(dir_path)):
if not name.endswith(".yaml"):
continue
key = name[: -len(".yaml")]
with open(os.path.join(dir_path, name), encoding="utf-8") as f:
text = f.read()
result[key] = dump_canonical_yaml(parse_yaml_text(text))
return result
# --- target resolution ------------------------------------------------------
def build_targets(config_dir, repo_root, file_args):
"""Resolve CLI file arguments to a deploy target list.
No args -> every automations/scripts/scenes *.yaml file currently in
config_dir (working tree) "wszystkie w scope".
Explicit args -> each is tried as-is, then repo-root-relative, then
config-dir-relative; whichever resolves to an existing file wins. Any
file outside config_dir/{automations,scripts,scenes}/ is a hard error
(dashboards/helpers are out of scope for this adapter).
Returns a list of (subdir, key, abs_path), in sorted order for the
no-args case and CLI-argument order otherwise.
"""
if not file_args:
targets = []
for subdir in DOMAIN_SUBDIRS:
dir_path = os.path.join(config_dir, subdir)
if not os.path.isdir(dir_path):
continue
for name in sorted(os.listdir(dir_path)):
if name.endswith(".yaml"):
targets.append((subdir, name[: -len(".yaml")], os.path.join(dir_path, name)))
return targets
config_dir_abs = os.path.abspath(config_dir)
targets = []
for arg in file_args:
candidates = [arg, os.path.join(repo_root, arg), os.path.join(config_dir, arg)]
resolved = next((c for c in candidates if os.path.isfile(c)), None)
if resolved is None:
raise DeployScopeError(
f"file not found: {arg!r} (tried as given, repo-root-relative, "
"and config-dir-relative)"
)
resolved = os.path.abspath(resolved)
if not resolved.startswith(config_dir_abs + os.sep):
raise DeployScopeError(
f"{arg!r} resolves outside {config_dir} — deploy.sh only handles "
"this instance's automations/scripts/scenes"
)
if not resolved.endswith(".yaml"):
raise DeployScopeError(f"{arg!r} is not a .yaml file")
subdir = os.path.basename(os.path.dirname(resolved))
if subdir not in DOMAIN_SUBDIRS:
raise DeployScopeError(
f"{arg!r} is outside deploy.sh's scope (only automations/scripts/scenes are "
"handled here — dashboards/helpers are WS-only and a separate task)"
)
key = os.path.basename(resolved)[: -len(".yaml")]
targets.append((subdir, key, resolved))
return targets
# --- 1. drift-check ----------------------------------------------------------
def drift_check(live_snapshot_dir, repo_root, config_relpath, target_keys):
"""Compare a fresh live snapshot (already on disk, canonical) against
repo HEAD, for every object NOT in target_keys.
Returns (aborts, warnings):
aborts -- [(subdir, key, reason, live_text_or_None, repo_text_or_None)]
content differs, or repo HEAD has an object missing live
(deleted out-of-band) -- never silently overwritten.
warnings -- [(subdir, key, reason)]
object lives on the instance but has no repo file -- DELETE
is out of scope for this task, so this is informational only.
"""
aborts = []
warnings = []
for subdir in DOMAIN_SUBDIRS:
live = load_yaml_dir(os.path.join(live_snapshot_dir, subdir))
repo = load_repo_head_dir(repo_root, f"{config_relpath}/{subdir}")
for key in sorted(set(live) | set(repo)):
if (subdir, key) in target_keys:
continue
live_val = live.get(key)
repo_val = repo.get(key)
if live_val is None and repo_val is not None:
aborts.append(
(
subdir,
key,
"present in repo (HEAD), missing on the live instance — "
"deleted out-of-band since the last import",
None,
repo_val,
)
)
elif live_val is not None and repo_val is None:
warnings.append(
(
subdir,
key,
"present on the live instance, no repo file — object was removed "
"from the repo; DELETE is out of scope here, left untouched on the "
"instance",
)
)
elif live_val != repo_val:
aborts.append(
(
subdir,
key,
"content differs between the live instance and repo HEAD — "
"uncaptured drift",
live_val,
repo_val,
)
)
return aborts, warnings
# --- 2. validation -----------------------------------------------------------
def local_validate(target_objects):
"""target_objects: {(subdir, key): parsed_obj}. Returns [(subdir, key, reason)]."""
errors = []
for (subdir, key), obj in target_objects.items():
if not isinstance(obj, dict):
errors.append((subdir, key, "not a mapping at the top level"))
continue
for required in REQUIRED_KEYS[subdir]:
if required not in obj:
errors.append((subdir, key, f"missing required key '{required}'"))
if subdir in ("automations", "scenes") and "id" in obj and str(obj["id"]) != key:
errors.append(
(subdir, key, f"'id' field {obj['id']!r} does not match filename '{key}.yaml'")
)
return errors
def remote_validate(client):
"""Live check_config gate. Returns (valid, raw_response)."""
return client.check_config()
# --- 3+4. write + verify -----------------------------------------------------
def write_and_verify(client, target_objects):
"""target_objects: {(subdir, key): parsed_obj}, already locally validated.
Returns {"written": [...], "write_failed": [...], "verify_failed": [...]}.
No auto-rollback on a verify mismatch writes to other objects continue,
and the mismatch is reported so an operator can act on it directly.
"""
report = {"written": [], "write_failed": [], "verify_failed": []}
for (subdir, key), obj in sorted(target_objects.items()):
domain = DOMAIN_API_NAME[subdir]
try:
client.post_config(domain, key, obj)
except Exception as exc: # noqa: BLE001 - surface any transport/HTTP error verbatim
report["write_failed"].append((subdir, key, str(exc)))
continue
try:
live_obj = client.get(f"/api/config/{domain}/config/{key}")
except Exception as exc: # noqa: BLE001 - surface any transport error verbatim
report["verify_failed"].append((subdir, key, f"verify GET failed: {exc}"))
continue
if live_obj is None:
report["verify_failed"].append(
(subdir, key, "verify GET returned 404 immediately after a successful write")
)
continue
expected_text = dump_canonical_yaml(obj)
actual_text = dump_canonical_yaml(live_obj)
if expected_text != actual_text:
report["verify_failed"].append(
(subdir, key, "post-write GET does not match what was written", expected_text, actual_text)
)
continue
report["written"].append((subdir, key))
return report
# --- orchestration ------------------------------------------------------------
def new_result(instance, dry_run):
return {
"instance": instance,
"dry_run": dry_run,
"targets": [],
"drift_aborts": [],
"drift_warnings": [],
"validation_errors": [],
"check_config_valid": None,
"check_config_raw": None,
"written": [],
"write_failed": [],
"verify_failed": [],
"aborted": False,
"abort_reason": None,
}
def run(repo_root, config_dir, config_relpath, instance, file_args, dry_run, client):
"""The full DRIFT-CHECK -> VALIDATE -> WRITE -> VERIFY sequence.
`client` is duck-typed: .get(path), .post_config(domain, id, body),
.check_config() -- see ha_write_api.WriteClient for the real
implementation and tests/test_deploy_api_offline.sh for the fake used
in offline tests.
"""
result = new_result(instance, dry_run)
try:
targets = build_targets(config_dir, repo_root, file_args)
except DeployScopeError as exc:
result["aborted"] = True
result["abort_reason"] = str(exc)
return result
if not targets:
result["aborted"] = True
result["abort_reason"] = "no target objects resolved — nothing to deploy"
return result
result["targets"] = [(subdir, key) for subdir, key, _ in targets]
target_keys = {(subdir, key) for subdir, key, _ in targets}
states = client.get("/api/states")
with tempfile.TemporaryDirectory(prefix="ha-deploy-drift-") as snapshot_dir:
live_report = import_api.new_report()
import_api.import_automations(client, states, snapshot_dir, live_report)
import_api.import_scripts(client, states, snapshot_dir, live_report)
import_api.import_scenes(client, states, snapshot_dir, live_report)
aborts, warnings = drift_check(snapshot_dir, repo_root, config_relpath, target_keys)
result["drift_aborts"] = aborts
result["drift_warnings"] = warnings
if aborts:
result["aborted"] = True
result["abort_reason"] = "drift detected outside deploy scope — never overwritten"
return result
target_objects = {}
parse_errors = []
for subdir, key, path in targets:
with open(path, encoding="utf-8") as f:
text = f.read()
try:
target_objects[(subdir, key)] = parse_yaml_text(text)
except Exception as exc: # noqa: BLE001 - any parse failure is a validation rejection
parse_errors.append((subdir, key, f"does not parse as YAML: {exc}"))
result["validation_errors"] = parse_errors + local_validate(target_objects)
if result["validation_errors"]:
result["aborted"] = True
result["abort_reason"] = "local validation failed — nothing was written"
return result
valid, raw = remote_validate(client)
result["check_config_valid"] = valid
result["check_config_raw"] = raw
if not valid:
result["aborted"] = True
result["abort_reason"] = "instance check_config reported invalid — nothing was written"
return result
if dry_run:
return result
write_report = write_and_verify(client, target_objects)
result["written"] = write_report["written"]
result["write_failed"] = write_report["write_failed"]
result["verify_failed"] = write_report["verify_failed"]
return result
# --- reporting ----------------------------------------------------------------
def _print_diff(live_text, repo_text, label_live, label_repo):
diff = difflib.unified_diff(
(repo_text or "").splitlines(keepends=True),
(live_text or "").splitlines(keepends=True),
fromfile=label_repo,
tofile=label_live,
)
sys.stderr.writelines(diff)
def print_report(result):
mode = "DRY-RUN (plan only, no mutating calls)" if result["dry_run"] else "LIVE"
print(f"== ha deploy: instance={result['instance']} mode={mode} ==", file=sys.stderr)
print(f"-> targets ({len(result['targets'])}):", file=sys.stderr)
for subdir, key in result["targets"]:
print(f" {subdir}/{key}.yaml", file=sys.stderr)
for subdir, key, reason in result["drift_warnings"]:
print(f"-> warning: {subdir}/{key}: {reason}", file=sys.stderr)
if result["drift_aborts"]:
print("-> DRIFT DETECTED — aborting, nothing was written:", file=sys.stderr)
for subdir, key, reason, live_text, repo_text in result["drift_aborts"]:
print(f" {subdir}/{key}: {reason}", file=sys.stderr)
_print_diff(live_text, repo_text, f"live:{subdir}/{key}", f"repo-HEAD:{subdir}/{key}")
if result["validation_errors"]:
print("-> LOCAL VALIDATION FAILED — aborting, nothing was written:", file=sys.stderr)
for subdir, key, reason in result["validation_errors"]:
print(f" {subdir}/{key}: {reason}", file=sys.stderr)
if result["check_config_valid"] is not None:
status = "valid" if result["check_config_valid"] else "INVALID"
print(f"-> check_config: {status}", file=sys.stderr)
if not result["check_config_valid"]:
print(f" {result['check_config_raw']}", file=sys.stderr)
if result["aborted"]:
print(f"== ABORTED: {result['abort_reason']} ==", file=sys.stderr)
return
if result["dry_run"]:
print("== dry-run complete: plan above would be applied on a live run ==", file=sys.stderr)
return
print(f"-> written ({len(result['written'])}):", file=sys.stderr)
for subdir, key in result["written"]:
print(f" {subdir}/{key}.yaml", file=sys.stderr)
if result["write_failed"]:
print(f"-> write failed ({len(result['write_failed'])}):", file=sys.stderr)
for subdir, key, reason in result["write_failed"]:
print(f" {subdir}/{key}: {reason}", file=sys.stderr)
if result["verify_failed"]:
print(f"-> verify failed ({len(result['verify_failed'])}):", file=sys.stderr)
for entry in result["verify_failed"]:
subdir, key, reason = entry[0], entry[1], entry[2]
print(f" {subdir}/{key}: {reason}", file=sys.stderr)
if len(entry) == 5:
_print_diff(entry[3], entry[4], f"written:{subdir}/{key}", f"live-after-write:{subdir}/{key}")
total_errors = len(result["write_failed"]) + len(result["verify_failed"])
print(
f"== deploy complete: {len(result['written'])} written, "
f"{len(result['write_failed'])} write errors, {len(result['verify_failed'])} verify errors ==",
file=sys.stderr,
)
if total_errors:
print("== see errors above — no auto-rollback was performed ==", file=sys.stderr)
def main(argv):
if len(argv) < 8:
print(
"usage: deploy_api.py <base_url> <token_path> <repo_root> <config_dir> "
"<config_relpath> <instance> <0|1:dry_run> [file...]",
file=sys.stderr,
)
return 2
_, base_url, token_path, repo_root, config_dir, config_relpath, instance, dry_run_flag = argv[:8]
file_args = argv[8:]
dry_run = dry_run_flag == "1"
try:
token = ha_api.read_token(token_path)
except ha_api.HaApiError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
client = ha_write_api.WriteClient(base_url, token)
try:
result = run(repo_root, config_dir, config_relpath, instance, file_args, dry_run, client)
except Exception as exc: # noqa: BLE001 - surface any transport/HTTP error verbatim
print(f"error: deploy failed: {exc}", file=sys.stderr)
return 1
print_report(result)
if result["aborted"] or result["write_failed"] or result["verify_failed"]:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))