teleo-infrastructure/observatory_read_adapter/app.py
twentyOne2x 92da54d804
Some checks are pending
CI / lint-and-test (push) Waiting to run
Add authenticated GCP Observatory read adapter (#154)
2026-07-15 03:43:05 +02:00

111 lines
3.6 KiB
Python

from __future__ import annotations
import asyncio
import hmac
import logging
import uuid
from typing import Any
from aiohttp import web
from .config import Settings
from .db import CloudSqlClaimReader
logger = logging.getLogger("teleo.observatory_read_adapter")
SETTINGS_KEY = web.AppKey("observatory_settings", Settings)
READER_KEY = web.AppKey("observatory_reader", object)
PUBLIC_PATHS = frozenset({"/healthz", "/readyz"})
def _private_json(payload: dict[str, Any], *, status: int = 200) -> web.Response:
response = web.json_response(payload, status=status)
response.headers["Cache-Control"] = "private, no-store, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Vary"] = "X-Api-Key"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
@web.middleware
async def api_key_auth(request: web.Request, handler: Any) -> web.StreamResponse:
if request.path in PUBLIC_PATHS:
return await handler(request)
settings = request.app[SETTINGS_KEY]
provided = request.headers.get("X-Api-Key", "")
try:
authenticated = hmac.compare_digest(
provided.encode("ascii"),
settings.api_key.encode("ascii"),
)
except UnicodeEncodeError:
authenticated = False
if not authenticated:
return _private_json({"error": "unauthorized"}, status=401)
return await handler(request)
async def healthz(_request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
async def readyz(request: web.Request) -> web.Response:
reader = request.app[READER_KEY]
try:
await asyncio.wait_for(asyncio.to_thread(reader.check_ready), timeout=12.0)
except Exception as exc:
logger.warning("readiness check failed (%s)", type(exc).__name__)
return web.json_response({"status": "unavailable"}, status=503)
return web.json_response({"status": "ready"})
async def get_sample_claim(request: web.Request) -> web.Response:
return await _claim_response(request, None)
async def get_claim(request: web.Request) -> web.Response:
raw_claim_id = request.match_info["claim_id"]
try:
claim_id = str(uuid.UUID(raw_claim_id))
except ValueError:
return _private_json({"error": "invalid_claim_id"}, status=400)
return await _claim_response(request, claim_id)
async def _claim_response(request: web.Request, claim_id: str | None) -> web.Response:
reader = request.app[READER_KEY]
try:
payload = await asyncio.wait_for(
asyncio.to_thread(reader.read_claim, claim_id),
timeout=12.0,
)
except Exception as exc:
logger.error("canonical claim read failed (%s)", type(exc).__name__)
return _private_json({"error": "canonical_claim_unavailable"}, status=503)
if payload is None:
return _private_json({"error": "claim_not_found"}, status=404)
return _private_json(payload)
async def _cleanup(app: web.Application) -> None:
reader = app[READER_KEY]
await asyncio.to_thread(reader.close)
def create_app(
settings: Settings | None = None,
*,
reader: Any | None = None,
) -> web.Application:
settings = settings or Settings.from_env()
settings.validate()
reader = reader or CloudSqlClaimReader(settings)
app = web.Application(middlewares=[api_key_auth], client_max_size=16 * 1024)
app[SETTINGS_KEY] = settings
app[READER_KEY] = reader
app.router.add_get("/healthz", healthz)
app.router.add_get("/readyz", readyz)
app.router.add_get("/v1/claims/sample", get_sample_claim)
app.router.add_get("/v1/claims/{claim_id}", get_claim)
app.on_cleanup.append(_cleanup)
return app