Spaces:
Sleeping
Sleeping
| #!/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 all repos + recreate all views (on-demand) | |
| GET /api/sources β return current sources.yaml as JSON | |
| * /* β proxy everything else to ClickHouse on 8123 | |
| This lets you push to GitHub, then immediately: | |
| curl https://your-space.hf.space/api/refresh | |
| curl https://your-space.hf.space/?query=SELECT * FROM ohlc LIMIT 5 | |
| """ | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| import time | |
| import urllib.request | |
| import urllib.error | |
| import yaml | |
| from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler | |
| CLICKHOUSE_URL = "http://127.0.0.1:8123" | |
| CONFIG_PATH = "/app/sources.yaml" | |
| REFRESH_SCRIPT = "/app/refresh_sources.py" | |
| GATEWAY_PORT = 7860 | |
| class GatewayHandler(BaseHTTPRequestHandler): | |
| # ββ /api/refresh βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _handle_refresh(self): | |
| """Run refresh_sources.sh and return JSON result.""" | |
| try: | |
| start = time.time() | |
| result = subprocess.run( | |
| ["python3", REFRESH_SCRIPT, "full"], | |
| capture_output=True, text=True, timeout=1200, | |
| ) | |
| elapsed = round(time.time() - start, 2) | |
| body = { | |
| "action": "refresh", | |
| "elapsed_seconds": elapsed, | |
| "exit_code": result.returncode, | |
| } | |
| # Try to parse script's JSON output | |
| try: | |
| body["result"] = json.loads(result.stdout) | |
| except (json.JSONDecodeError, ValueError): | |
| body["stdout"] = result.stdout | |
| if result.stderr: | |
| body["stderr"] = result.stderr | |
| self._json_response(200, body) | |
| except subprocess.TimeoutExpired: | |
| self._json_response(504, {"error": "refresh timed out after 1200s"}) | |
| except Exception as e: | |
| self._json_response(500, {"error": str(e)}) | |
| # ββ /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.""" | |
| # Read request body if present | |
| 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) | |
| # Forward response headers | |
| for key, val in resp.headers.items(): | |
| if key.lower() != "transfer-encoding": | |
| self.send_header(key, val) | |
| # Add CORS headers for browser-based SQL playgrounds | |
| self.send_header("Access-Control-Allow-Origin", "*") | |
| self.end_headers() | |
| # Stream response body | |
| 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}"}) | |
| # ββ HTTP method handlers βββββββββββββββββββββββββββββββββββββββββββββ | |
| def do_GET(self): | |
| if self.path == "/api/refresh": | |
| self._handle_refresh() | |
| elif self.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): | |
| """Cleaner log format. Ignores HF container ping logs for cleanliness.""" | |
| msg = format % args | |
| if "?logs=container" in msg: | |
| return | |
| sys.stderr.write(f"[gateway] {self.address_string()} {msg}\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 to be ready before accepting traffic | |
| wait_for_clickhouse() | |
| # Run initial full refresh (clone repos + create views) | |
| print("[gateway] Running initial data sync + view creation...") | |
| subprocess.run( | |
| ["python3", REFRESH_SCRIPT, "full"], | |
| timeout=1200, | |
| ) | |
| # Start gateway | |
| server = ThreadingHTTPServer(("0.0.0.0", GATEWAY_PORT), GatewayHandler) | |
| print(f"[gateway] Listening on port {GATEWAY_PORT}") | |
| print(f"[gateway] /api/refresh β on-demand git pull + view recreation") | |
| print(f"[gateway] /api/sources β current config as JSON") | |
| print(f"[gateway] /* β proxy to ClickHouse") | |
| server.serve_forever() | |