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

1""" 

2Kong API Gateway onboarding for the Mindstory SDK. 

3 

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. 

7 

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""" 

12 

13from __future__ import annotations 

14 

15import argparse 

16import sys 

17from typing import Any, Dict, Optional 

18 

19import requests 

20 

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" 

32 

33ROUTE_PATHS = [ 

34 "/v1/chat/completions", 

35 "/v1/corrections", 

36 "/v1/stats", 

37 "/health", 

38] 

39 

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] 

81 

82 

83# --------------------------------------------------------------------------- 

84# Helpers 

85# --------------------------------------------------------------------------- 

86 

87def _log(msg: str) -> None: 

88 """Print a timestamped status line.""" 

89 print(f"[kong-onboard] {msg}") 

90 

91 

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. 

100 

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() 

109 

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() 

122 

123 if resp.status_code == 409: 

124 _log(f" {resource_label}: already exists (no change)") 

125 return resp.json() if resp.text else {} 

126 

127 resp.raise_for_status() 

128 return {} # unreachable, but keeps mypy happy 

129 

130 

131# --------------------------------------------------------------------------- 

132# Core onboarding steps 

133# --------------------------------------------------------------------------- 

134 

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}'") 

152 

153 

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}'") 

169 

170 

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. 

177 

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" 

187 

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 

206 

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() 

215 

216 

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 

228 

229 

230# --------------------------------------------------------------------------- 

231# Guest widget service — rate-limited, CORS-locked, no key-auth 

232# --------------------------------------------------------------------------- 

233 

234GUEST_SERVICE_NAME = "hevolve-guest-widget" 

235GUEST_ROUTE_NAME = "guest-register-route" 

236 

237GUEST_ROUTE_PATHS = [ 

238 "/api/social/auth/guest-register", 

239] 

240 

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] 

280 

281 

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. 

288 

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 ===") 

294 

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 ) 

311 

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 ) 

327 

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) 

355 

356 _log("Guest widget onboarding complete.") 

357 return True 

358 

359 

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 

374 

375 

376# --------------------------------------------------------------------------- 

377# Orchestrator 

378# --------------------------------------------------------------------------- 

379 

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() 

388 

389 _log(f"Kong Admin API : {kong_url}") 

390 _log(f"Upstream target: {upstream_url}") 

391 _log("") 

392 

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") 

403 

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("") 

417 

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 

429 

430 if ok: 

431 _log("") 

432 _log("Onboarding complete. Mindstory SDK routes are live.") 

433 return ok 

434 

435 

436# --------------------------------------------------------------------------- 

437# CLI 

438# --------------------------------------------------------------------------- 

439 

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 

455 

456 

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 

462 

463 

464if __name__ == "__main__": 

465 sys.exit(main())