File size: 7,523 Bytes
3fd3eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8c95c6
 
3fd3eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8c95c6
3fd3eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84739e0
 
 
 
 
3fd3eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8c95c6
3fd3eea
c8c95c6
3fd3eea
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
#!/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()