2026-07-22 18:21:11 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Deploy path (repo -> instance) for the HA "api" adapter — see
|
fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.
15 markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
rozwiazywala sie z katalogu, w ktorym lezala)
200 odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
-> nowa sciezka repo-root-relative, zgodnie z konwencja repo
5 linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
tylko w starym katalogu; przeliczone recznie
Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.
Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.
Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.
Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:12:24 +02:00
|
|
|
kb/decisions/ha-configs-as-code.md, "Sync model" and "Validation gate".
|
2026-07-22 18:21:11 +02:00
|
|
|
|
|
|
|
|
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))
|