homelab-codex-ws/scripts/ha/lib/ha_api.py
oskar 6dffa5c565 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 16:21:16 +02:00

115 lines
4 KiB
Python
Executable file

#!/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,
"Tokens", and the api-adapter requirements in kb/phases/backlog.md. Callers
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))