1e2cfe80f2
Agent terminals were idle (one-shot probe) while agents were busy in the background. Now every agent streams what it is actually doing: - agent_terminal: emit_threadsafe() so background threads can stream lines. - agent_ops: Data Custodian DML loop logs the real INSERT/UPDATE/DELETE SQL (+ Mongo ops) and Hadoop-offload Trino CTAS/INSERT to its terminal; ETL Guardian announces each orchestrated movement. - movements: trigger_and_watch streams the Airflow DAG / API call, conf, before/after Trino counts and result to the owning agent terminal. - etl_offload: per-dataset read + pyarrow->S3 parquet writes and cycle summaries stream to the ETL Guardian terminal. - agent_activity (new): round-robin live probes for Lakehouse Ops, Hadoop Ranger (NameNode JMX + YARN), Infra Sentinel (Dockhand inventory + host load) and Network Watcher (VLAN 20/21 path checks). - fix: YARN ResourceManager runs on 10.0.21.62:8088 (was .61). - ui: terminal dock merges the selected agent ops stream with the node probe.
257 lines
12 KiB
Python
257 lines
12 KiB
Python
"""Data movement registry + orchestration for the Command Center.
|
|
|
|
A "movement" is a named data flow step (generate into a source DB, move
|
|
HDFS -> Iceberg via Trino, mask -> curated, ...). Each maps to an Airflow DAG.
|
|
This module triggers movements via the Airflow REST API, watches the runs to
|
|
completion, records the latest run (state, duration, rows) and logs to the
|
|
agent feed. It is shared by the ETL-agents (autonomous triggering) and the
|
|
Data Flow tab (live status + manual triggering).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Body
|
|
from fastapi.responses import JSONResponse
|
|
|
|
router = APIRouter(prefix="/api/movements", tags=["movements"])
|
|
|
|
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080").rstrip("/")
|
|
AIRFLOW_USER = os.getenv("AIRFLOW_USER", "admin")
|
|
AIRFLOW_PASSWORD = os.getenv("AIRFLOW_PASSWORD", "")
|
|
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
|
TRINO_USER = os.getenv("TRINO_USER", "mo")
|
|
|
|
# from/to refer to logical Data Flow node ids (see dataflow.py).
|
|
MOVEMENTS: list[dict[str, Any]] = [
|
|
{"id": "gen_postgres", "label": "Generate → PostgreSQL", "kind": "generate",
|
|
"dag_id": "gen_postgres", "agent": "data-custodian", "from": "generator", "to": "postgres",
|
|
"default_conf": {"rows": 3000}},
|
|
{"id": "gen_mysql", "label": "Generate → MySQL", "kind": "generate",
|
|
"dag_id": "gen_mysql", "agent": "data-custodian", "from": "generator", "to": "mysql",
|
|
"default_conf": {"rows": 3000}},
|
|
{"id": "gen_mongodb", "label": "Generate → MongoDB", "kind": "generate",
|
|
"dag_id": "gen_mongodb", "agent": "data-custodian", "from": "generator", "to": "mongodb",
|
|
"default_conf": {"rows": 3000}},
|
|
{"id": "hdfs_to_kafka", "label": "HDFS → Kafka export", "kind": "stream",
|
|
"dag_id": None, "agent": "hadoop-ranger", "from": "hdfs", "to": "kafka",
|
|
"api": "/api/pipeline/streaming/hdfs/to-kafka",
|
|
"default_conf": {"source": "trino", "table": "iceberg.hadoop.historical_sales_hdfs", "topic": "hdfs.historical.sales"}},
|
|
{"id": "hadoop_to_trino", "label": "HDFS → Iceberg (Trino)", "kind": "movement",
|
|
"dag_id": "hadoop_to_trino", "agent": "hadoop-ranger", "from": "hdfs", "to": "iceberg_hadoop",
|
|
"default_conf": {"mode": "refresh"},
|
|
"count_sql": "SELECT count(*) FROM iceberg.hadoop.historical_sales_hdfs"},
|
|
{"id": "mask_to_curated", "label": "Mask PII → Curated (Iceberg)", "kind": "mask",
|
|
"dag_id": "mask_to_curated", "agent": "lakehouse-ops", "from": "sources", "to": "iceberg_curated",
|
|
"default_conf": {},
|
|
"count_sql": "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"},
|
|
{"id": "spark_to_s3", "label": "Spark → S3 curated", "kind": "movement",
|
|
"dag_id": "mask_to_curated", "agent": "lakehouse-ops", "from": "spark", "to": "s3_cdc",
|
|
"default_conf": {"target": "s3"},
|
|
"count_sql": "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"},
|
|
{"id": "spark_to_curated", "label": "Spark → Iceberg curated", "kind": "movement",
|
|
"dag_id": "mask_to_curated", "agent": "lakehouse-ops", "from": "spark", "to": "iceberg_curated",
|
|
"default_conf": {},
|
|
"count_sql": "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"},
|
|
]
|
|
MOVEMENT_BY_ID = {m["id"]: m for m in MOVEMENTS}
|
|
|
|
# latest run info per movement id
|
|
_last_runs: dict[str, dict[str, Any]] = {}
|
|
_token_cache: dict[str, Any] = {"token": None, "exp": 0.0}
|
|
|
|
|
|
def _feed(agent_id: str, message: str, level: str = "info") -> None:
|
|
try:
|
|
from main import add_feed
|
|
add_feed(agent_id, message, level)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _publish(event: dict[str, Any]) -> None:
|
|
try:
|
|
from main import publish_event
|
|
await publish_event(event)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _term(agent_id: str, text: str, level: str = "info", phase: str = "etl") -> None:
|
|
"""Stream a movement step to the owning agent's terminal."""
|
|
try:
|
|
from agent_terminal import terminal_log
|
|
await terminal_log(agent_id, text, level=level, phase=phase)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _airflow_token(client: httpx.AsyncClient) -> str:
|
|
now = time.time()
|
|
if _token_cache["token"] and _token_cache["exp"] > now + 30:
|
|
return _token_cache["token"]
|
|
r = await client.post(f"{AIRFLOW_URL}/auth/token",
|
|
json={"username": AIRFLOW_USER, "password": AIRFLOW_PASSWORD}, timeout=10)
|
|
r.raise_for_status()
|
|
tok = r.json()["access_token"]
|
|
_token_cache["token"] = tok
|
|
_token_cache["exp"] = now + 20 * 60
|
|
return tok
|
|
|
|
|
|
async def _trino_scalar(sql: str, deadline_s: float = 15.0) -> int | None:
|
|
end = time.time() + deadline_s
|
|
try:
|
|
async with httpx.AsyncClient(timeout=6.0) as client:
|
|
r = await client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(),
|
|
headers={"X-Trino-User": TRINO_USER})
|
|
data = r.json()
|
|
rows: list[Any] = data.get("data") or []
|
|
nxt = data.get("nextUri")
|
|
while nxt:
|
|
if time.time() > end:
|
|
try:
|
|
await client.delete(nxt, timeout=3.0)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
d = (await client.get(nxt)).json()
|
|
rows += d.get("data") or []
|
|
if d.get("error"):
|
|
return None
|
|
nxt = d.get("nextUri")
|
|
if rows:
|
|
return int(rows[0][0])
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
async def trigger_and_watch(mid: str, conf: dict[str, Any] | None = None, *, autonomous: bool = False) -> dict[str, Any]:
|
|
mv = MOVEMENT_BY_ID.get(mid)
|
|
if not mv:
|
|
return {"ok": False, "error": f"unknown movement {mid}"}
|
|
agent = mv["agent"]
|
|
if mv.get("api"):
|
|
try:
|
|
payload = {**(mv.get("default_conf") or {}), **(conf or {})}
|
|
await _term(agent, f"$ POST {mv['api']} # {mv['label']}", level="cmd")
|
|
await _term(agent, f" payload={payload}", level="cmd")
|
|
t0 = time.time()
|
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
r = await client.post(f"http://127.0.0.1:8000{mv['api']}", json=payload)
|
|
dur = round(time.time() - t0, 1)
|
|
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
|
state = "success" if r.status_code < 400 and body.get("ok", True) else "failed"
|
|
rows = body.get("rows_sent") or body.get("rows")
|
|
run = {
|
|
"movement_id": mid, "state": state, "duration_s": dur, "rows": rows,
|
|
"ended_at": datetime.now(timezone.utc).isoformat(), "conf": payload,
|
|
}
|
|
_last_runs[mid] = run
|
|
await _publish({"type": "movement", **run})
|
|
lvl = "info" if state == "success" else "err"
|
|
await _term(agent, f" ← {state} · {rows if rows is not None else '?'} rows in {dur}s", level="ok" if state == "success" else "err")
|
|
_feed(agent, f"[etl] {mv['label']}: {state} in {dur}s", lvl)
|
|
return {"ok": state == "success", **run}
|
|
except Exception as exc:
|
|
_last_runs[mid] = {**_last_runs.get(mid, {}), "state": "failed", "error": str(exc)}
|
|
await _term(agent, f" ✗ error {str(exc)[:140]}", level="err")
|
|
_feed(agent, f"[etl] {mv['label']}: error {str(exc)[:120]}", "err")
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
conf = {**(mv.get("default_conf") or {}), **(conf or {})}
|
|
count_sql = mv.get("count_sql")
|
|
verb = "autonomously triggered" if autonomous else "triggered"
|
|
await _term(agent, f"$ airflow dags trigger {mv['dag_id']} # {mv['label']} ({verb})", level="cmd")
|
|
await _term(agent, f" conf={conf}", level="cmd")
|
|
before = await _trino_scalar(count_sql) if count_sql else None
|
|
if count_sql:
|
|
await _term(agent, f" trino: {count_sql} → {before if before is not None else '?'} rows (before)", level="info")
|
|
|
|
_last_runs[mid] = {**_last_runs.get(mid, {}), "state": "running", "started_at": datetime.now(timezone.utc).isoformat()}
|
|
await _publish({"type": "movement", "movement_id": mid, "state": "running"})
|
|
_feed(agent, f"[etl] {mv['label']}: {verb} (conf={conf})", "info")
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
tok = await _airflow_token(client)
|
|
h = {"Authorization": f"Bearer {tok}"}
|
|
r = await client.post(f"{AIRFLOW_URL}/api/v2/dags/{mv['dag_id']}/dagRuns",
|
|
headers=h, json={"logical_date": None, "conf": conf}, timeout=20)
|
|
if r.status_code >= 400:
|
|
_last_runs[mid] = {**_last_runs[mid], "state": "failed", "error": f"airflow {r.status_code}"}
|
|
_feed(agent, f"[etl] {mv['label']}: could not start (Airflow {r.status_code})", "err")
|
|
return {"ok": False, "error": f"airflow {r.status_code}: {r.text[:200]}"}
|
|
run_id = r.json().get("dag_run_id")
|
|
t0 = time.time()
|
|
state = "running"
|
|
for _ in range(120): # up to ~10 min
|
|
await asyncio.sleep(5)
|
|
rr = await client.get(f"{AIRFLOW_URL}/api/v2/dags/{mv['dag_id']}/dagRuns/{run_id}", headers=h, timeout=10)
|
|
state = rr.json().get("state")
|
|
if state in ("success", "failed"):
|
|
break
|
|
dur = round(time.time() - t0, 1)
|
|
await _term(agent, f" ← Airflow run {run_id} finished state={state} in {dur}s", level="ok" if state == "success" else "err")
|
|
except Exception as exc:
|
|
_last_runs[mid] = {**_last_runs[mid], "state": "failed", "error": str(exc)}
|
|
await _term(agent, f" ✗ error {str(exc)[:140]}", level="err")
|
|
_feed(agent, f"[etl] {mv['label']}: error {str(exc)[:120]}", "err")
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
after = await _trino_scalar(count_sql) if count_sql else None
|
|
rows = None
|
|
if before is not None and after is not None:
|
|
rows = max(0, after - before) if conf.get("mode") != "refresh" else after
|
|
elif conf.get("rows"):
|
|
rows = conf.get("rows")
|
|
run = {
|
|
"movement_id": mid, "state": state, "duration_s": dur, "rows": rows,
|
|
"ended_at": datetime.now(timezone.utc).isoformat(), "run_id": run_id, "conf": conf,
|
|
}
|
|
_last_runs[mid] = run
|
|
await _publish({"type": "movement", **run})
|
|
rtxt = f"{rows} rows" if rows is not None else "data"
|
|
lvl = "info" if state == "success" else "err"
|
|
if count_sql:
|
|
await _term(agent, f" trino: count {before if before is not None else '?'} → {after if after is not None else '?'} ({rtxt})", level="info")
|
|
await _term(agent, f" ← {mv['label']}: {state} — {rtxt} in {dur}s", level="ok" if state == "success" else "err")
|
|
_feed(agent, f"[etl] {mv['label']}: {state} — {rtxt} in {dur}s", lvl)
|
|
return {"ok": state == "success", **run}
|
|
|
|
|
|
def last_runs() -> dict[str, dict[str, Any]]:
|
|
return dict(_last_runs)
|
|
|
|
|
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
|
@router.get("")
|
|
async def list_movements() -> JSONResponse:
|
|
out = []
|
|
for m in MOVEMENTS:
|
|
out.append({**{k: m[k] for k in ("id", "label", "kind", "dag_id", "agent", "from", "to")},
|
|
"last_run": _last_runs.get(m["id"])})
|
|
return JSONResponse({"ok": True, "movements": out})
|
|
|
|
|
|
@router.post("/{mid}/run")
|
|
async def run_movement(mid: str, body: dict[str, Any] = Body(default={})) -> JSONResponse:
|
|
if mid not in MOVEMENT_BY_ID:
|
|
return JSONResponse({"ok": False, "error": f"unknown movement {mid}"}, status_code=400)
|
|
conf = body.get("conf") if isinstance(body, dict) else None
|
|
# Run in the background so the request returns immediately; status via WS/feed.
|
|
asyncio.create_task(trigger_and_watch(mid, conf))
|
|
return JSONResponse({"ok": True, "movement_id": mid, "status": "triggered"})
|
|
|
|
|
|
@router.get("/runs")
|
|
async def movement_runs() -> JSONResponse:
|
|
return JSONResponse({"ok": True, "last_runs": _last_runs})
|