Sync workspace: config, docs, scripts, CI, operator rules, and submodule pointers.
- Update dbis_core, cross-chain-pmm-lps, explorer-monorepo, metamask-integration, pr-workspace/chains - Omit embedded publish git dirs and empty placeholders from index Made-with: Cursor
This commit is contained in:
@@ -24,10 +24,13 @@ Clients send `X-API-Key: your-long-random-secret` on `/v1/inventory/*`. `/health
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/health` | Liveness + paths |
|
||||
| GET | `/health` | Liveness; includes `oidc_issuer_configured` when `IT_BFF_OIDC_ISSUER` set |
|
||||
| GET | `/v1/summary` | Envelope: artifact mtimes, `guest_count`, duplicate IP bucket count, `seed_unreachable` |
|
||||
| GET | `/v1/collector-contract` | Serves `config/it-operations/live-collectors-contract.json` |
|
||||
| GET | `/v1/portmap/joined` | Stub (Phase 2); `stale: true` until UniFi/NPM collectors exist |
|
||||
| GET | `/v1/inventory/live` | Latest live guest inventory |
|
||||
| GET | `/v1/inventory/drift` | Latest drift report |
|
||||
| POST | `/v1/inventory/refresh` | Runs `scripts/it-ops/export-live-inventory-and-drift.sh` (requires `IT_READ_API_KEY`) |
|
||||
| POST | `/v1/inventory/refresh` | Runs export script (requires `IT_READ_API_KEY`); body includes `drift_exit_code` (**2** = duplicate guest IPs) |
|
||||
|
||||
Optional **`IT_READ_API_CORS_ORIGINS`**: comma-separated browser origins; enables `OPTIONS` and `Access-Control-Allow-*` for direct SPA calls (prefer Next.js `/api/it/*` proxy so keys stay server-side).
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
@@ -37,6 +38,9 @@ def _project_root() -> Path:
|
||||
ROOT = _project_root()
|
||||
REPORTS = ROOT / "reports" / "status"
|
||||
EXPORT_SCRIPT = ROOT / "scripts" / "it-ops" / "export-live-inventory-and-drift.sh"
|
||||
COLLECTOR_CONTRACT = (
|
||||
ROOT / "config" / "it-operations" / "live-collectors-contract.json"
|
||||
)
|
||||
API_KEY = os.environ.get("IT_READ_API_KEY", "").strip()
|
||||
HOST = os.environ.get("IT_READ_API_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("IT_READ_API_PORT", "8787"))
|
||||
@@ -45,6 +49,48 @@ _CORS_RAW = os.environ.get("IT_READ_API_CORS_ORIGINS", "").strip()
|
||||
CORS_ORIGINS = {o.strip() for o in _CORS_RAW.split(",") if o.strip()}
|
||||
|
||||
|
||||
def _file_meta(path: Path) -> dict:
|
||||
if not path.is_file():
|
||||
return {"path": str(path), "exists": False}
|
||||
st = path.stat()
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": True,
|
||||
"size_bytes": st.st_size,
|
||||
"mtime_utc": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _load_json_file(path: Path) -> tuple[dict | list | None, str | None]:
|
||||
if not path.is_file():
|
||||
return None, "file_missing"
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8")), None
|
||||
except json.JSONDecodeError as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _declared_git_head(repo: Path) -> str | None:
|
||||
"""Best-effort declared config revision (repo checkout on read API host)."""
|
||||
if os.environ.get("IT_SKIP_GIT_HEAD", "").strip().lower() in ("1", "yes", "true"):
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
return proc.stdout.strip()
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "SankofaITReadAPI/0.1"
|
||||
|
||||
@@ -98,6 +144,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
if path == "/health":
|
||||
oidc_issuer = os.environ.get("IT_BFF_OIDC_ISSUER", "").strip()
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
@@ -105,6 +152,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"service": "sankofa-it-read-api",
|
||||
"project_root": str(ROOT),
|
||||
"auth_required_for_v1": bool(API_KEY),
|
||||
"oidc_issuer_configured": bool(oidc_issuer),
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -113,6 +161,91 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not self._auth_ok():
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
|
||||
if path == "/v1/collector-contract":
|
||||
data, err = _load_json_file(COLLECTOR_CONTRACT)
|
||||
if err == "file_missing":
|
||||
self._json(404, {"error": "file_missing", "path": str(COLLECTOR_CONTRACT)})
|
||||
return
|
||||
if err:
|
||||
self._json(500, {"error": "invalid_json", "detail": err})
|
||||
return
|
||||
assert isinstance(data, (dict, list))
|
||||
self._json(200, data)
|
||||
return
|
||||
|
||||
if path == "/v1/portmap/joined":
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"implementation": "stub",
|
||||
"collected_at": now,
|
||||
"stale": True,
|
||||
"rows": [],
|
||||
"note": "UniFi/NPM live collectors not wired; see docs/02-architecture/IT_PORT_MAP_LAYERS_SPEC.md",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/v1/summary":
|
||||
live_p = REPORTS / "live_inventory.json"
|
||||
drift_p = REPORTS / "drift.json"
|
||||
live_d, le = _load_json_file(live_p)
|
||||
drift_d, de = _load_json_file(drift_p)
|
||||
if le == "file_missing" and de == "file_missing":
|
||||
self._json(404, {"error": "no_inventory_artifacts"})
|
||||
return
|
||||
dup = (
|
||||
len(drift_d.get("duplicate_ips", {}))
|
||||
if isinstance(drift_d, dict)
|
||||
else 0
|
||||
)
|
||||
notes = drift_d.get("notes", []) if isinstance(drift_d, dict) else []
|
||||
seed_err = any(
|
||||
"seed_unreachable" in str(n) for n in notes
|
||||
) or (
|
||||
isinstance(live_d, dict) and live_d.get("error") == "seed_unreachable"
|
||||
)
|
||||
vmid_doc_missing: list[str] = []
|
||||
vmid_live_extra: dict = {}
|
||||
if isinstance(drift_d, dict):
|
||||
raw_missing = drift_d.get("vmids_in_all_vmids_doc_not_on_cluster")
|
||||
if isinstance(raw_missing, list):
|
||||
vmid_doc_missing = [str(x) for x in raw_missing]
|
||||
extra = drift_d.get("vmids_on_cluster_not_in_all_vmids_table")
|
||||
if isinstance(extra, dict):
|
||||
vmid_live_extra = extra
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"service": "sankofa-it-read-api",
|
||||
"envelope_at": datetime.now(timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
),
|
||||
"declared_git_head": _declared_git_head(ROOT),
|
||||
"artifacts": {
|
||||
"live_inventory": _file_meta(live_p),
|
||||
"drift": _file_meta(drift_p),
|
||||
},
|
||||
"live_collected_at": (live_d or {}).get("collected_at")
|
||||
if isinstance(live_d, dict)
|
||||
else None,
|
||||
"drift_collected_at": (drift_d or {}).get("collected_at")
|
||||
if isinstance(drift_d, dict)
|
||||
else None,
|
||||
"guest_count": (drift_d or {}).get("guest_count")
|
||||
if isinstance(drift_d, dict)
|
||||
else None,
|
||||
"duplicate_ip_bucket_count": dup,
|
||||
"seed_unreachable": bool(seed_err),
|
||||
"drift_notes": notes if isinstance(notes, list) else [],
|
||||
"vmids_in_all_vmids_doc_not_on_cluster": vmid_doc_missing,
|
||||
"vmids_on_cluster_not_in_all_vmids_table": vmid_live_extra,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/v1/inventory/live":
|
||||
f = REPORTS / "live_inventory.json"
|
||||
elif path == "/v1/inventory/drift":
|
||||
@@ -120,18 +253,21 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._json(404, {"error": "not_found"})
|
||||
return
|
||||
if not f.is_file():
|
||||
data, err = _load_json_file(f)
|
||||
if err == "file_missing":
|
||||
self._json(404, {"error": "file_missing", "path": str(f)})
|
||||
return
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
self._json(500, {"error": "invalid_json", "detail": str(e)})
|
||||
if err:
|
||||
self._json(500, {"error": "invalid_json", "detail": err})
|
||||
return
|
||||
assert data is not None
|
||||
self._json(200, data)
|
||||
return
|
||||
|
||||
self._text(404, "Not found. GET /health or /v1/inventory/live\n")
|
||||
self._text(
|
||||
404,
|
||||
"Not found. GET /health, /v1/summary, /v1/collector-contract, /v1/inventory/*\n",
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
@@ -145,35 +281,47 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(500, {"error": "export_script_missing"})
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
proc = subprocess.run(
|
||||
["bash", str(EXPORT_SCRIPT)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
check=False,
|
||||
timeout=600,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
except subprocess.TimeoutExpired:
|
||||
self._json(500, {"error": "export_timeout"})
|
||||
return
|
||||
if proc.returncode not in (0, 2):
|
||||
self._json(
|
||||
500,
|
||||
{
|
||||
"error": "export_failed",
|
||||
"returncode": e.returncode,
|
||||
"stderr": (e.stderr or "")[-4000:],
|
||||
"returncode": proc.returncode,
|
||||
"stderr": (proc.stderr or "")[-4000:],
|
||||
},
|
||||
)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
self._json(500, {"error": "export_timeout"})
|
||||
return
|
||||
self._json(200, {"ok": True, "refreshed": True})
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"ok": True,
|
||||
"refreshed": True,
|
||||
"drift_exit_code": proc.returncode,
|
||||
"duplicate_guest_ip_conflict": proc.returncode == 2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
print(f"sankofa-it-read-api listening on http://{HOST}:{PORT}", file=sys.stderr)
|
||||
print(f" project_root={ROOT}", file=sys.stderr)
|
||||
print(f" GET /health GET /v1/inventory/live GET /v1/inventory/drift", file=sys.stderr)
|
||||
print(
|
||||
" GET /health /v1/summary /v1/collector-contract /v1/portmap/joined",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" GET /v1/inventory/live /v1/inventory/drift POST /v1/inventory/refresh", file=sys.stderr)
|
||||
if API_KEY:
|
||||
print(" X-API-Key required for /v1/*", file=sys.stderr)
|
||||
if CORS_ORIGINS:
|
||||
|
||||
Reference in New Issue
Block a user