Coverage for integrations / gateway / kong_onboard.py: 92.9%
169 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Kong API Gateway onboarding for the Mindstory SDK.
4Programmatically registers the hevolve-completions service, routes, and
5plugins via the Kong Admin API. Every operation is idempotent: objects are
6created on first run and patched on subsequent runs.
8Usage:
9 python -m integrations.gateway.kong_onboard
10 python -m integrations.gateway.kong_onboard --kong-url http://kong:8001 --upstream http://ai:8000
11"""
13from __future__ import annotations
15import argparse
16import sys
17from typing import Any, Dict, Optional
19import requests
21# ---------------------------------------------------------------------------
22# Defaults aligned with KONG_GATEWAY.md
23# ---------------------------------------------------------------------------
24# Defaults: localhost for dev, cloud Kong via KONG_ADMIN_URL env var
25# Cloud Kong admin is only accessible from inside the VM (not publicly exposed)
26# Cloud proxy: https://azurekong.hertzai.com (port 443/8443)
27import os as _os
28DEFAULT_KONG_ADMIN_URL = _os.environ.get("KONG_ADMIN_URL", "http://localhost:8001")
29DEFAULT_UPSTREAM_URL = _os.environ.get("HEVOLVE_API_URL", "http://localhost:8000")
30SERVICE_NAME = "hevolve-completions"
31ROUTE_NAME = "completions-route"
33ROUTE_PATHS = [
34 "/v1/chat/completions",
35 "/v1/corrections",
36 "/v1/stats",
37 "/health",
38]
40# Plugin configurations from KONG_GATEWAY.md
41PLUGINS: list[Dict[str, Any]] = [
42 {
43 "name": "key-auth",
44 "config": {
45 "key_names": ["Authorization", "apikey"],
46 "key_in_header": True,
47 "key_in_query": False,
48 "hide_credentials": True,
49 },
50 },
51 {
52 "name": "rate-limiting",
53 "config": {
54 "minute": 60,
55 "hour": 1000,
56 "day": 10000,
57 "policy": "redis",
58 "redis_host": "localhost",
59 "redis_port": 6379,
60 "fault_tolerant": True,
61 "hide_client_headers": False,
62 },
63 },
64 {
65 "name": "cors",
66 "config": {
67 "origins": ["*"],
68 "methods": ["GET", "POST", "OPTIONS"],
69 "headers": ["Content-Type", "Authorization"],
70 "credentials": True,
71 "max_age": 3600,
72 },
73 },
74 {
75 "name": "request-size-limiting",
76 "config": {
77 "allowed_payload_size": 10, # MB — base64 images
78 },
79 },
80]
83# ---------------------------------------------------------------------------
84# Helpers
85# ---------------------------------------------------------------------------
87def _log(msg: str) -> None:
88 """Print a timestamped status line."""
89 print(f"[kong-onboard] {msg}")
92def _put_or_post(
93 session: requests.Session,
94 url: str,
95 payload: Dict[str, Any],
96 resource_label: str,
97) -> Dict[str, Any]:
98 """PUT to *url* (idempotent upsert). Falls back to POST if the Admin
99 API version does not support PUT-to-create.
101 Returns the JSON body of the successful response.
102 Raises ``requests.HTTPError`` on unrecoverable failure.
103 """
104 resp = session.put(url, json=payload)
105 if resp.status_code in (200, 201):
106 verb = "updated" if resp.status_code == 200 else "created"
107 _log(f" {resource_label}: {verb}")
108 return resp.json()
110 # Some older Kong builds reject PUT-to-create; fall back to POST.
111 if resp.status_code in (404, 405):
112 # Derive the collection URL by stripping the last path segment.
113 collection_url = url.rsplit("/", 1)[0]
114 resp2 = session.post(collection_url, json=payload)
115 if resp2.status_code == 409:
116 # Already exists — treat as success (idempotent).
117 _log(f" {resource_label}: already exists (no change)")
118 return resp2.json() if resp2.text else {}
119 resp2.raise_for_status()
120 _log(f" {resource_label}: created (POST fallback)")
121 return resp2.json()
123 if resp.status_code == 409:
124 _log(f" {resource_label}: already exists (no change)")
125 return resp.json() if resp.text else {}
127 resp.raise_for_status()
128 return {} # unreachable, but keeps mypy happy
131# ---------------------------------------------------------------------------
132# Core onboarding steps
133# ---------------------------------------------------------------------------
135def create_service(
136 session: requests.Session,
137 kong_url: str,
138 upstream_url: str,
139) -> Dict[str, Any]:
140 """Create or update the ``hevolve-completions`` service."""
141 _log("Step 1/4 — Service")
142 payload = {
143 "name": SERVICE_NAME,
144 "url": upstream_url,
145 "retries": 3,
146 "connect_timeout": 10000,
147 "write_timeout": 60000,
148 "read_timeout": 60000,
149 }
150 url = f"{kong_url}/services/{SERVICE_NAME}"
151 return _put_or_post(session, url, payload, f"service '{SERVICE_NAME}'")
154def create_route(
155 session: requests.Session,
156 kong_url: str,
157) -> Dict[str, Any]:
158 """Create or update the completions route on the service."""
159 _log("Step 2/4 — Route")
160 payload = {
161 "name": ROUTE_NAME,
162 "paths": ROUTE_PATHS,
163 "methods": ["POST", "GET"],
164 "protocols": ["https"],
165 "strip_path": False,
166 }
167 url = f"{kong_url}/services/{SERVICE_NAME}/routes/{ROUTE_NAME}"
168 return _put_or_post(session, url, payload, f"route '{ROUTE_NAME}'")
171def enable_plugin(
172 session: requests.Session,
173 kong_url: str,
174 plugin_cfg: Dict[str, Any],
175) -> Dict[str, Any]:
176 """Enable (or update) a single plugin on the service.
178 Uses PUT to ``/services/{name}/plugins/{plugin_name}`` for idempotency.
179 """
180 plugin_name = plugin_cfg["name"]
181 payload = {
182 "name": plugin_name,
183 "config": plugin_cfg["config"],
184 "enabled": True,
185 }
186 url = f"{kong_url}/services/{SERVICE_NAME}/plugins"
188 # Try to find existing plugin first for idempotent update
189 try:
190 existing = session.get(url)
191 if existing.status_code == 200:
192 data = existing.json().get("data", [])
193 for p in data:
194 if p.get("name") == plugin_name:
195 # Update existing plugin
196 plugin_id = p["id"]
197 resp = session.patch(
198 f"{kong_url}/plugins/{plugin_id}",
199 json=payload,
200 )
201 if resp.status_code in (200, 201):
202 _log(f" plugin '{plugin_name}': updated")
203 return resp.json()
204 except Exception:
205 pass
207 # Create new
208 resp = session.post(url, json=payload)
209 if resp.status_code == 409:
210 _log(f" plugin '{plugin_name}': already exists (no change)")
211 return resp.json() if resp.text else {}
212 resp.raise_for_status()
213 _log(f" plugin '{plugin_name}': created")
214 return resp.json()
217def enable_plugins(
218 session: requests.Session,
219 kong_url: str,
220) -> list[Dict[str, Any]]:
221 """Enable all required plugins on the service."""
222 _log("Step 3/4 — Plugins")
223 results = []
224 for plugin_cfg in PLUGINS:
225 result = enable_plugin(session, kong_url, plugin_cfg)
226 results.append(result)
227 return results
230# ---------------------------------------------------------------------------
231# Guest widget service — rate-limited, CORS-locked, no key-auth
232# ---------------------------------------------------------------------------
234GUEST_SERVICE_NAME = "hevolve-guest-widget"
235GUEST_ROUTE_NAME = "guest-register-route"
237GUEST_ROUTE_PATHS = [
238 "/api/social/auth/guest-register",
239]
241GUEST_PLUGINS: list[Dict[str, Any]] = [
242 {
243 "name": "rate-limiting",
244 "config": {
245 "minute": 5, # 5 guest tokens per minute per IP
246 "hour": 30, # 30 per hour — enough for real users, stops farming
247 "policy": "local", # No Redis dependency for this
248 "fault_tolerant": False, # Fail closed — deny if counter broken
249 "hide_client_headers": False,
250 },
251 },
252 {
253 "name": "cors",
254 "config": {
255 "origins": [
256 "https://docs.hevolve.ai",
257 "https://hevolve.ai",
258 "http://localhost:8000", # local dev
259 ],
260 "methods": ["POST", "OPTIONS"],
261 "headers": ["Content-Type"],
262 "credentials": False,
263 "max_age": 3600,
264 },
265 },
266 {
267 "name": "request-size-limiting",
268 "config": {
269 "allowed_payload_size": 1, # 1 MB — guest register is tiny
270 },
271 },
272 {
273 "name": "ip-restriction",
274 "config": {
275 "deny": [], # Populated by abuse detection
276 "allow": [], # Empty = allow all (deny-list mode)
277 },
278 },
279]
282def onboard_guest_widget(
283 session: requests.Session,
284 kong_url: str,
285 upstream_url: str,
286) -> bool:
287 """Register the guest-register endpoint in Kong with tight rate limits.
289 No key-auth — the widget is public. Protection is via rate limiting,
290 CORS origin lock, and short-lived tokens (server-side).
291 """
292 _log("")
293 _log("=== Guest Widget Service ===")
295 # Service
296 _log("Step 1/3 — Guest service")
297 svc_payload = {
298 "name": GUEST_SERVICE_NAME,
299 "url": upstream_url,
300 "retries": 1,
301 "connect_timeout": 5000,
302 "write_timeout": 10000,
303 "read_timeout": 10000,
304 }
305 _put_or_post(
306 session,
307 f"{kong_url}/services/{GUEST_SERVICE_NAME}",
308 svc_payload,
309 f"service '{GUEST_SERVICE_NAME}'",
310 )
312 # Route
313 _log("Step 2/3 — Guest route")
314 route_payload = {
315 "name": GUEST_ROUTE_NAME,
316 "paths": GUEST_ROUTE_PATHS,
317 "methods": ["POST", "OPTIONS"],
318 "protocols": ["https", "http"],
319 "strip_path": False,
320 }
321 _put_or_post(
322 session,
323 f"{kong_url}/services/{GUEST_SERVICE_NAME}/routes/{GUEST_ROUTE_NAME}",
324 route_payload,
325 f"route '{GUEST_ROUTE_NAME}'",
326 )
328 # Plugins
329 _log("Step 3/3 — Guest plugins")
330 for plugin_cfg in GUEST_PLUGINS:
331 enable_plugin(session, kong_url, plugin_cfg)
332 # Re-scope to guest service
333 plugin_name = plugin_cfg["name"]
334 payload = {
335 "name": plugin_name,
336 "config": plugin_cfg["config"],
337 "enabled": True,
338 }
339 # Apply to guest service specifically
340 guest_url = f"{kong_url}/services/{GUEST_SERVICE_NAME}/plugins"
341 try:
342 existing = session.get(guest_url)
343 if existing.status_code == 200:
344 data = existing.json().get("data", [])
345 for p in data:
346 if p.get("name") == plugin_name:
347 session.patch(f"{kong_url}/plugins/{p['id']}", json=payload)
348 break
349 else:
350 session.post(guest_url, json=payload)
351 else:
352 session.post(guest_url, json=payload)
353 except Exception:
354 session.post(guest_url, json=payload)
356 _log("Guest widget onboarding complete.")
357 return True
360def verify(session: requests.Session, kong_url: str) -> bool:
361 """Quick verification: fetch the service back from Kong."""
362 _log("Step 4/4 — Verify")
363 try:
364 resp = session.get(f"{kong_url}/services/{SERVICE_NAME}")
365 if resp.status_code == 200:
366 svc = resp.json()
367 _log(f" service id={svc.get('id', '?')}, host={svc.get('host', '?')}")
368 return True
369 _log(f" verification failed: HTTP {resp.status_code}")
370 return False
371 except requests.ConnectionError:
372 _log(" verification failed: Kong unreachable")
373 return False
376# ---------------------------------------------------------------------------
377# Orchestrator
378# ---------------------------------------------------------------------------
380def onboard(
381 kong_url: str = DEFAULT_KONG_ADMIN_URL,
382 upstream_url: str = DEFAULT_UPSTREAM_URL,
383 session: Optional[requests.Session] = None,
384) -> bool:
385 """Run all onboarding steps. Returns ``True`` on success."""
386 if session is None:
387 session = requests.Session()
389 _log(f"Kong Admin API : {kong_url}")
390 _log(f"Upstream target: {upstream_url}")
391 _log("")
393 try:
394 # Query existing state first
395 _log("Querying existing Kong configuration...")
396 try:
397 existing_svc = session.get(f"{kong_url}/services/{SERVICE_NAME}")
398 if existing_svc.status_code == 200:
399 svc = existing_svc.json()
400 _log(f" Found service '{SERVICE_NAME}' → {svc.get('host', '?')}:{svc.get('port', '?')}")
401 else:
402 _log(f" No existing service '{SERVICE_NAME}' — will create")
404 existing_routes = session.get(f"{kong_url}/services/{SERVICE_NAME}/routes")
405 if existing_routes.status_code == 200:
406 routes = existing_routes.json().get("data", [])
407 for r in routes:
408 _log(f" Found route '{r.get('name', '?')}' paths={r.get('paths', [])}")
409 existing_plugins = session.get(f"{kong_url}/services/{SERVICE_NAME}/plugins")
410 if existing_plugins.status_code == 200:
411 plugins = existing_plugins.json().get("data", [])
412 for p in plugins:
413 _log(f" Found plugin '{p.get('name', '?')}' enabled={p.get('enabled', '?')}")
414 except requests.ConnectionError:
415 _log(" Kong not reachable — will attempt creation")
416 _log("")
418 create_service(session, kong_url, upstream_url)
419 create_route(session, kong_url)
420 enable_plugins(session, kong_url)
421 onboard_guest_widget(session, kong_url, upstream_url)
422 ok = verify(session, kong_url)
423 except requests.ConnectionError:
424 _log("ERROR: Cannot reach Kong Admin API — is Kong running?")
425 return False
426 except requests.HTTPError as exc:
427 _log(f"ERROR: Kong returned an error: {exc}")
428 return False
430 if ok:
431 _log("")
432 _log("Onboarding complete. Mindstory SDK routes are live.")
433 return ok
436# ---------------------------------------------------------------------------
437# CLI
438# ---------------------------------------------------------------------------
440def build_parser() -> argparse.ArgumentParser:
441 parser = argparse.ArgumentParser(
442 description="Onboard the Mindstory SDK into Kong API Gateway",
443 )
444 parser.add_argument(
445 "--kong-url",
446 default=DEFAULT_KONG_ADMIN_URL,
447 help=f"Kong Admin API base URL (default: {DEFAULT_KONG_ADMIN_URL})",
448 )
449 parser.add_argument(
450 "--upstream",
451 default=DEFAULT_UPSTREAM_URL,
452 help=f"HevolveAI upstream URL (default: {DEFAULT_UPSTREAM_URL})",
453 )
454 return parser
457def main(argv: list[str] | None = None) -> int:
458 parser = build_parser()
459 args = parser.parse_args(argv)
460 ok = onboard(kong_url=args.kong_url, upstream_url=args.upstream)
461 return 0 if ok else 1
464if __name__ == "__main__":
465 sys.exit(main())