#!/usr/bin/env python3 """ gateway.py — Lightweight HTTP gateway for the Observatory Sits on port 7860 (the only port HF Spaces exposes) and routes: GET /api/refresh → git pull + run SQL (supports ?target=sql|data|all) GET /api/status → sync state + health info GET /api/sources → return current sources.yaml as JSON * /* → proxy everything else to ClickHouse on 8123 Key design: • Port 7860 binds IMMEDIATELY — no blocking on data sync • Initial data sync runs in a background thread • ClickHouse stays online even if all git operations fail """ import json import os import subprocess import sys import threading import time import urllib.request import urllib.error import yaml from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs from datetime import datetime, timezone CLICKHOUSE_URL = "http://127.0.0.1:8123" CONFIG_PATH = "/app/sources.yaml" REFRESH_SCRIPT = "/app/refresh_sources.sh" HOMEPAGE_PATH = "/app/index.html" GATEWAY_PORT = 7860 # Load homepage HTML once at import time try: with open(HOMEPAGE_PATH, "rb") as _f: HOMEPAGE_HTML = _f.read() except FileNotFoundError: HOMEPAGE_HTML = b"

Market-Data Observatory

Open SQL UI

" # ── Global sync state (thread-safe via GIL for simple reads/writes) ────────── sync_state = { "status": "pending", # pending → syncing → ready / failed "started_at": None, "completed_at": None, "elapsed_seconds": None, "error": None, "last_refresh": None, } sync_lock = threading.Lock() def update_sync_state(**kwargs): with sync_lock: sync_state.update(kwargs) def get_sync_state(): with sync_lock: return dict(sync_state) # ── Background initial sync ───────────────────────────────────────────────── def initial_sync(): """Run full refresh in background. Gateway is already serving traffic.""" update_sync_state( status="syncing", started_at=datetime.now(timezone.utc).isoformat(), ) try: start = time.time() result = subprocess.run( ["bash", REFRESH_SCRIPT, "full"], capture_output=True, text=True, timeout=600, ) elapsed = round(time.time() - start, 2) if result.returncode == 0: update_sync_state( status="ready", completed_at=datetime.now(timezone.utc).isoformat(), elapsed_seconds=elapsed, last_refresh=datetime.now(timezone.utc).isoformat(), error=None, ) print(f"[gateway] Initial sync completed in {elapsed}s") else: update_sync_state( status="failed", completed_at=datetime.now(timezone.utc).isoformat(), elapsed_seconds=elapsed, error=result.stderr or result.stdout or "Unknown error", ) print(f"[gateway] Initial sync failed (exit {result.returncode})") if result.stdout: print(result.stdout) if result.stderr: print(f"[gateway] stderr: {result.stderr}", file=sys.stderr) except subprocess.TimeoutExpired: update_sync_state( status="failed", completed_at=datetime.now(timezone.utc).isoformat(), error="Initial sync timed out after 600s", ) print("[gateway] Initial sync timed out", file=sys.stderr) except Exception as e: update_sync_state( status="failed", completed_at=datetime.now(timezone.utc).isoformat(), error=str(e), ) print(f"[gateway] Initial sync error: {e}", file=sys.stderr) class GatewayHandler(BaseHTTPRequestHandler): # ── /api/refresh ───────────────────────────────────────────────────── def _handle_refresh(self): """Run refresh_sources.sh and return JSON result. Supports ?target=sql|data|all (default: full) """ # Parse target parameter parsed = urlparse(self.path) params = parse_qs(parsed.query) target = params.get("target", ["full"])[0] # Map target to script mode mode_map = {"sql": "sql", "data": "data", "all": "full", "full": "full"} mode = mode_map.get(target, "full") try: start = time.time() result = subprocess.run( ["bash", REFRESH_SCRIPT, mode], capture_output=True, text=True, timeout=300, ) elapsed = round(time.time() - start, 2) body = { "action": "refresh", "target": target, "elapsed_seconds": elapsed, "exit_code": result.returncode, } if result.stdout: body["stdout"] = result.stdout if result.stderr: body["stderr"] = result.stderr # Update global state update_sync_state( status="ready" if result.returncode == 0 else "failed", last_refresh=datetime.now(timezone.utc).isoformat(), ) self._json_response(200, body) except subprocess.TimeoutExpired: self._json_response(504, {"error": "refresh timed out after 300s"}) except Exception as e: self._json_response(500, {"error": str(e)}) # ── /api/status ────────────────────────────────────────────────────── def _handle_status(self): """Return sync state + ClickHouse health.""" state = get_sync_state() # Check ClickHouse health ch_status = "unknown" try: resp = urllib.request.urlopen(f"{CLICKHOUSE_URL}/ping", timeout=2) ch_status = "ok" if resp.read().decode().strip() == "Ok." else "degraded" except Exception: ch_status = "unreachable" body = { "clickhouse": ch_status, "data_sync": state, } self._json_response(200, body) # ── /api/sources ───────────────────────────────────────────────────── def _handle_sources(self): """Return current sources.yaml as JSON.""" try: with open(CONFIG_PATH, "r") as f: config = yaml.safe_load(f) self._json_response(200, config) except Exception as e: self._json_response(500, {"error": str(e)}) # ── Proxy to ClickHouse ────────────────────────────────────────────── def _proxy_to_clickhouse(self): """Forward the request to ClickHouse HTTP interface.""" content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length) if content_length else None url = f"{CLICKHOUSE_URL}{self.path}" req = urllib.request.Request(url, data=body, method=self.command) # Forward relevant headers for header in self.headers: lower = header.lower() if lower not in ("host", "content-length", "transfer-encoding"): req.add_header(header, self.headers[header]) try: resp = urllib.request.urlopen(req, timeout=600) self.send_response(resp.status) for key, val in resp.headers.items(): if key.lower() != "transfer-encoding": self.send_header(key, val) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() while True: chunk = resp.read(65536) if not chunk: break self.wfile.write(chunk) except urllib.error.HTTPError as e: self.send_response(e.code) for key, val in e.headers.items(): if key.lower() != "transfer-encoding": self.send_header(key, val) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(e.read()) except Exception as e: self._json_response(502, {"error": f"ClickHouse unreachable: {e}"}) # ── Homepage ────────────────────────────────────────────────────────── def _handle_homepage(self): """Serve the custom landing page.""" self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(HOMEPAGE_HTML))) self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(HOMEPAGE_HTML) # ── HTTP method handlers ───────────────────────────────────────────── def do_GET(self): parsed = urlparse(self.path) path = parsed.path query = parsed.query # Serve homepage at root ONLY if no query params (/?query=... goes to ClickHouse) if path == "/" and not query: self._handle_homepage() elif path == "/api/refresh": self._handle_refresh() elif path == "/api/status": self._handle_status() elif path == "/api/sources": self._handle_sources() else: self._proxy_to_clickhouse() def do_POST(self): self._proxy_to_clickhouse() def do_OPTIONS(self): """CORS preflight.""" self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "*") self.end_headers() # ── Helpers ────────────────────────────────────────────────────────── def _json_response(self, code, data): body = json.dumps(data, indent=2).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body) def log_message(self, format, *args): sys.stderr.write(f"[gateway] {self.address_string()} {format % args}\n") def wait_for_clickhouse(timeout=60): """Block until ClickHouse is responding on 8123.""" print(f"[gateway] Waiting for ClickHouse on {CLICKHOUSE_URL}...") for i in range(timeout): try: urllib.request.urlopen(f"{CLICKHOUSE_URL}/ping", timeout=2) print(f"[gateway] ClickHouse is ready (took {i+1}s)") return True except Exception: time.sleep(1) print(f"[gateway] WARNING: ClickHouse not ready after {timeout}s, starting anyway") return False if __name__ == "__main__": # Wait for ClickHouse (but don't crash if it's slow) wait_for_clickhouse() # Start the HTTP server FIRST — so HF Spaces health check passes immediately server = ThreadingHTTPServer(("0.0.0.0", GATEWAY_PORT), GatewayHandler) print(f"[gateway] Listening on port {GATEWAY_PORT}") print(f"[gateway] /api/refresh?target=sql|data|all → on-demand sync") print(f"[gateway] /api/status → sync state + health") print(f"[gateway] /api/sources → current config as JSON") print(f"[gateway] /* → proxy to ClickHouse") # Run initial data sync in background thread (non-blocking) print("[gateway] Starting initial data sync in background...") sync_thread = threading.Thread(target=initial_sync, daemon=True) sync_thread.start() # Serve forever in the foreground server.serve_forever()