2026-07-22 17:27:00 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Minimal REST client for the HA "api" adapter (see DESIGN.md, "Deploy path:
|
|
|
|
|
adapter per instance").
|
|
|
|
|
|
|
|
|
|
Every entrypoint here takes a *token_path*, never a token value, and reads
|
|
|
|
|
the token from disk itself. The bearer token must never appear as a process
|
|
|
|
|
argv (visible to any local user via `ps`) or in a log line — see DESIGN.md,
|
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
|
|
|
"Tokens", and the api-adapter requirements in kb/phases/backlog.md. Callers
|
2026-07-22 17:27:00 +02:00
|
|
|
needing the token value in-process (e.g. the WebSocket client in
|
|
|
|
|
ha_ws.py) should call read_token() directly rather than shelling out.
|
|
|
|
|
|
|
|
|
|
Read-only by construction: the only method this module exposes is GET.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HaApiError(RuntimeError):
|
|
|
|
|
"""Raised for adapter-level failures (missing/empty token, etc.)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_token(token_path):
|
|
|
|
|
"""Read and strip the bearer token from `token_path` (`~` expanded).
|
|
|
|
|
|
|
|
|
|
Hard error (raises HaApiError) if the file is absent or empty — the api
|
|
|
|
|
adapter has no filesystem fallback, so a missing token means the import
|
|
|
|
|
cannot do anything at all, unlike the fixtures fetch, which soft-skips.
|
|
|
|
|
"""
|
|
|
|
|
path = os.path.expanduser(token_path) if token_path else ""
|
|
|
|
|
if not path or not os.path.isfile(path):
|
|
|
|
|
raise HaApiError(
|
|
|
|
|
f"no HA token at '{path or '<unset>'}' — the api adapter requires a "
|
|
|
|
|
"deploy_agent long-lived access token (see DESIGN.md, 'Tokens'); "
|
|
|
|
|
"this is a hard error, not a soft-skip, because nothing in this "
|
|
|
|
|
"adapter works without one"
|
|
|
|
|
)
|
|
|
|
|
token = open(path, "r", encoding="utf-8").read().strip()
|
|
|
|
|
if not token:
|
|
|
|
|
raise HaApiError(f"token file '{path}' is empty")
|
|
|
|
|
return token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Client:
|
|
|
|
|
"""Thin wrapper around one HA instance's REST API (GET only)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, base_url, token, timeout=30):
|
|
|
|
|
self._base_url = base_url.rstrip("/")
|
|
|
|
|
self._token = token
|
|
|
|
|
self._timeout = timeout
|
|
|
|
|
|
|
|
|
|
def _headers(self):
|
|
|
|
|
return {"Authorization": f"Bearer {self._token}"}
|
|
|
|
|
|
|
|
|
|
def get(self, path):
|
|
|
|
|
"""GET `path` (e.g. "/api/states"), return parsed JSON.
|
|
|
|
|
|
|
|
|
|
Returns None on 404 so callers can treat "config not found" (e.g. an
|
|
|
|
|
automation removed between the states poll and the config fetch) as
|
|
|
|
|
a skip-and-report condition instead of a hard failure. Any other
|
|
|
|
|
non-2xx status raises requests.HTTPError.
|
|
|
|
|
"""
|
|
|
|
|
resp = requests.get(
|
|
|
|
|
self._base_url + path, headers=self._headers(), timeout=self._timeout
|
|
|
|
|
)
|
|
|
|
|
if resp.status_code == 404:
|
|
|
|
|
return None
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
return resp.json()
|
|
|
|
|
|
|
|
|
|
def get_raw_text(self, path):
|
|
|
|
|
"""GET `path`, return the raw response body (used for fixtures)."""
|
|
|
|
|
resp = requests.get(
|
|
|
|
|
self._base_url + path, headers=self._headers(), timeout=self._timeout
|
|
|
|
|
)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
return resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cli_get_raw(args):
|
|
|
|
|
if len(args) != 3:
|
|
|
|
|
print("usage: ha_api.py get-raw <base_url> <token_path> <api_path>", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
base_url, token_path, api_path = args
|
|
|
|
|
token = read_token(token_path)
|
|
|
|
|
client = Client(base_url, token)
|
|
|
|
|
sys.stdout.write(client.get_raw_text(api_path))
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv):
|
|
|
|
|
"""CLI entrypoint. Only sub-command: `get-raw` — print a raw GET body.
|
|
|
|
|
|
|
|
|
|
Used by import.sh's fixtures fetch for the api adapter so the token
|
|
|
|
|
never touches a subprocess argv (unlike `curl -H "Authorization: ..."`,
|
|
|
|
|
which would put it in `ps` output for anyone on the same host).
|
|
|
|
|
"""
|
|
|
|
|
if len(argv) < 2:
|
|
|
|
|
print("usage: ha_api.py get-raw <base_url> <token_path> <api_path>", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
command, rest = argv[1], argv[2:]
|
|
|
|
|
try:
|
|
|
|
|
if command == "get-raw":
|
|
|
|
|
return _cli_get_raw(rest)
|
|
|
|
|
print(f"ha_api.py: unknown command '{command}'", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
except (HaApiError, requests.RequestException) as exc:
|
|
|
|
|
print(f"ha_api.py: {exc}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main(sys.argv))
|