From 897dcef7494876629deb0291f92a49e416988ff1 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:49:51 +0300 Subject: [PATCH 01/11] feat: add standalone Cloud Run simulation API --- .../pyproject.toml | 3 +- libs/policyengine-simulation-contract/uv.lock | 4 +- .../policyengine-simulation-api/README.md | 18 + .../openapi-python-client.yaml | 2 + .../pyproject.toml | 48 + .../policyengine_simulation_api/__init__.py | 5 + .../src/policyengine_simulation_api/app.py | 251 ++++ .../src/policyengine_simulation_api/auth.py | 79 ++ .../policyengine_simulation_api/backend.py | 225 ++++ .../src/policyengine_simulation_api/config.py | 112 ++ .../generate_openapi.py | 22 + .../tests/conftest.py | 86 ++ .../tests/test_app.py | 180 +++ .../tests/test_auth.py | 131 +++ .../tests/test_backend.py | 186 +++ .../tests/test_config.py | 28 + .../tests/test_openapi.py | 76 ++ projects/policyengine-simulation-api/uv.lock | 1046 +++++++++++++++++ .../policyengine-simulation-executor/uv.lock | 3 +- .../policyengine-simulation-gateway/uv.lock | 3 +- 20 files changed, 2500 insertions(+), 8 deletions(-) create mode 100644 projects/policyengine-simulation-api/README.md create mode 100644 projects/policyengine-simulation-api/openapi-python-client.yaml create mode 100644 projects/policyengine-simulation-api/pyproject.toml create mode 100644 projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py create mode 100644 projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py create mode 100644 projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py create mode 100644 projects/policyengine-simulation-api/src/policyengine_simulation_api/backend.py create mode 100644 projects/policyengine-simulation-api/src/policyengine_simulation_api/config.py create mode 100644 projects/policyengine-simulation-api/src/policyengine_simulation_api/generate_openapi.py create mode 100644 projects/policyengine-simulation-api/tests/conftest.py create mode 100644 projects/policyengine-simulation-api/tests/test_app.py create mode 100644 projects/policyengine-simulation-api/tests/test_auth.py create mode 100644 projects/policyengine-simulation-api/tests/test_backend.py create mode 100644 projects/policyengine-simulation-api/tests/test_config.py create mode 100644 projects/policyengine-simulation-api/tests/test_openapi.py create mode 100644 projects/policyengine-simulation-api/uv.lock diff --git a/libs/policyengine-simulation-contract/pyproject.toml b/libs/policyengine-simulation-contract/pyproject.toml index a1708d7b9..40247c06c 100644 --- a/libs/policyengine-simulation-contract/pyproject.toml +++ b/libs/policyengine-simulation-contract/pyproject.toml @@ -9,13 +9,12 @@ license = {file = "../../LICENSE"} requires-python = ">=3.13" dependencies = [ "pydantic>=2.0", - "modal>=1.4,<2", # gateway_models embeds the TelemetryEnvelope from the observability lib. "policyengine-simulation-observability", ] [project.optional-dependencies] -test = [ "pytest>=8.3.4", "pytest-asyncio>=0.25.3", "pytest-cov>=6.1.1",] +test = [ "pytest>=8.3.4", "pytest-asyncio>=0.25.3", "pytest-cov>=6.1.1", "modal>=1.4,<2",] build = [ "pyright>=1.1.401", "black>=25.1.0",] [build-system] diff --git a/libs/policyengine-simulation-contract/uv.lock b/libs/policyengine-simulation-contract/uv.lock index 9fa978c54..5dee88f2e 100644 --- a/libs/policyengine-simulation-contract/uv.lock +++ b/libs/policyengine-simulation-contract/uv.lock @@ -967,7 +967,6 @@ name = "policyengine-simulation-contract" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "modal" }, { name = "policyengine-simulation-observability" }, { name = "pydantic" }, ] @@ -978,6 +977,7 @@ build = [ { name = "pyright" }, ] test = [ + { name = "modal" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -986,7 +986,7 @@ test = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, - { name = "modal", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, diff --git a/projects/policyengine-simulation-api/README.md b/projects/policyengine-simulation-api/README.md new file mode 100644 index 000000000..26ba057fd --- /dev/null +++ b/projects/policyengine-simulation-api/README.md @@ -0,0 +1,18 @@ +# policyengine-simulation-api + +The permanent Cloud Run front door for PolicyEngine simulation submission and +polling. + +During migration Stage 5 this service is a contract-compatible control-plane +proxy backed by the existing Modal-hosted simulation gateway. It authenticates +callers, uses its own machine identity for the Modal hop, and preserves existing +job identifiers. It does not yet own job persistence, version routing, or +compute dispatch. + +## Local development + + uv sync --extra test + uv run pytest tests/ -v + +The production app is policyengine_simulation_api.app:app. Required runtime +configuration is documented in policyengine_simulation_api.config.Settings. diff --git a/projects/policyengine-simulation-api/openapi-python-client.yaml b/projects/policyengine-simulation-api/openapi-python-client.yaml new file mode 100644 index 000000000..a5a231b90 --- /dev/null +++ b/projects/policyengine-simulation-api/openapi-python-client.yaml @@ -0,0 +1,2 @@ +project_name_override: policyengine-api-simulation-client +package_name_override: policyengine_api_simulation_client diff --git a/projects/policyengine-simulation-api/pyproject.toml b/projects/policyengine-simulation-api/pyproject.toml new file mode 100644 index 000000000..24d0125d4 --- /dev/null +++ b/projects/policyengine-simulation-api/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "policyengine-simulation-api" +version = "0.1.0" +readme = "README.md" +authors = [ + {name = "PolicyEngine", email = "hello@policyengine.org"}, +] +license = {file = "../../LICENSE"} +requires-python = ">=3.13,<3.14" +dependencies = [ + "cryptography>=41.0.0", + "fastapi>=0.115.0,<1", + "httpx>=0.28.0,<1", + "policyengine-observability[fastapi]>=1.3.0,<2", + "policyengine-simulation-contract", + "policyengine-simulation-observability", + "pyjwt>=2.10.1,<3", + "uvicorn>=0.35.0,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/policyengine_simulation_api"] + +[tool.uv.sources] +policyengine-simulation-contract = { path = "../../libs/policyengine-simulation-contract", editable = true } +policyengine-simulation-observability = { path = "../../libs/policyengine-simulation-observability", editable = true } + +[project.optional-dependencies] +test = [ + "pytest>=8.3.4", + "pytest-asyncio>=0.25.3", + "pytest-cov>=6.1.1", +] +build = [ + "openapi-python-client>=0.21.6", + "pyright>=1.1.401", +] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.pyright] +include = ["src"] diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py new file mode 100644 index 000000000..8ba3c84c2 --- /dev/null +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py @@ -0,0 +1,5 @@ +"""PolicyEngine Cloud Run Simulation API.""" + +from policyengine_simulation_api.app import create_app + +__all__ = ["create_app"] diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py new file mode 100644 index 000000000..80f74e177 --- /dev/null +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py @@ -0,0 +1,251 @@ +"""FastAPI application for the Cloud Run Simulation API.""" + +from __future__ import annotations + +import logging +import time +import uuid +from contextlib import asynccontextmanager +from typing import Any, Callable, Mapping + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse, Response +from policyengine_observability import record_event +from policyengine_simulation_contract.gateway_models import ( + BudgetWindowBatchRequest, + BudgetWindowBatchStatusResponse, + BudgetWindowBatchSubmitResponse, + JobStatusResponse, + JobSubmitResponse, + PingRequest, + PingResponse, + SimulationRequest, +) +from policyengine_simulation_observability.observability import ( + configure_process_observability, + init_simulation_observability, +) + +from policyengine_simulation_api.auth import CallerAuthenticator +from policyengine_simulation_api.backend import ( + BackendAuthenticationError, + BackendResponse, + BackendTimeout, + BackendUnavailable, + OldGatewayBackend, + SimulationBackend, +) +from policyengine_simulation_api.config import Settings + + +logger = logging.getLogger(__name__) + + +def _model_json(model: Any) -> Mapping[str, Any]: + return model.model_dump(mode="json", by_alias=True, exclude_none=True) + + +def _response(result: BackendResponse) -> Response: + headers = { + **result.headers, + "X-PolicyEngine-Simulation-Backend": "old_gateway", + } + return Response( + content=result.content, + status_code=result.status_code, + headers=headers, + ) + + +def create_app( + *, + settings: Settings | None = None, + backend: SimulationBackend | None = None, + auth_dependency: Callable[..., Any] | None = None, +) -> FastAPI: + """Build the app with injectable auth/backend seams for hermetic tests.""" + + runtime_settings = settings or Settings.from_env() + runtime_backend = backend or OldGatewayBackend(runtime_settings) + authenticate = auth_dependency or CallerAuthenticator(runtime_settings) + + @asynccontextmanager + async def lifespan(_: FastAPI): + runtime_settings.validate() + await runtime_backend.start() + try: + yield + finally: + await runtime_backend.close() + + app = FastAPI( + title="PolicyEngine Simulation API", + description=("Authenticated simulation submission and polling control plane."), + version="1.0.0", + lifespan=lifespan, + ) + + configure_process_observability( + platform="cloud_run", + service_role="simulation_api", + ) + init_simulation_observability(app, service_role="simulation_api") + + @app.middleware("http") + async def request_context(request: Request, call_next): + request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) + request.state.request_id = request_id + started = time.monotonic() + response = await call_next(request) + elapsed_ms = round((time.monotonic() - started) * 1000, 2) + response.headers["X-Request-ID"] = request_id + logger.info( + "simulation_api_request", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "elapsed_ms": elapsed_ms, + "backend": "old_gateway", + }, + ) + return response + + async def forward( + request: Request, + method: str, + path: str, + body: Mapping[str, Any] | None = None, + ) -> Response: + try: + result = await runtime_backend.request( + method, + path, + json_body=body, + request_id=request.state.request_id, + ) + return _response(result) + except BackendTimeout: + record_event( + "simulation_api_backend_timeout", + request_id=request.state.request_id, + route=path, + ) + return JSONResponse( + status_code=504, + content={"detail": "Simulation backend timed out."}, + headers={"Retry-After": "10"}, + ) + except (BackendUnavailable, BackendAuthenticationError): + record_event( + "simulation_api_backend_unavailable", + request_id=request.state.request_id, + route=path, + ) + return JSONResponse( + status_code=503, + content={"detail": "Simulation backend is unavailable."}, + headers={"Retry-After": "10"}, + ) + + protected = [Depends(authenticate)] + + @app.post( + "/simulate/economy/comparison", + operation_id="submit_simulation_simulate_economy_comparison_post", + response_model=JobSubmitResponse, + response_model_exclude_none=True, + dependencies=protected, + ) + async def submit_comparison( + body: SimulationRequest, + request: Request, + ) -> Response: + return await forward( + request, + "POST", + "/simulate/economy/comparison", + _model_json(body), + ) + + @app.post( + "/simulate/economy/budget-window", + operation_id=("submit_budget_window_batch_simulate_economy_budget_window_post"), + response_model=BudgetWindowBatchSubmitResponse, + response_model_exclude_none=True, + dependencies=protected, + ) + async def submit_budget_window( + body: BudgetWindowBatchRequest, + request: Request, + ) -> Response: + return await forward( + request, + "POST", + "/simulate/economy/budget-window", + _model_json(body), + ) + + @app.get( + "/jobs/{job_id}", + operation_id="get_job_status_jobs__job_id__get", + response_model=JobStatusResponse, + response_model_exclude_none=True, + dependencies=protected, + ) + async def get_job(job_id: str, request: Request) -> Response: + return await forward(request, "GET", f"/jobs/{job_id}") + + @app.get( + "/budget-window-jobs/{batch_job_id}", + operation_id=( + "get_budget_window_job_status_budget_window_jobs__batch_job_id__get" + ), + response_model=BudgetWindowBatchStatusResponse, + response_model_exclude_none=True, + dependencies=protected, + ) + async def get_budget_window_job( + batch_job_id: str, + request: Request, + ) -> Response: + return await forward( + request, + "GET", + f"/budget-window-jobs/{batch_job_id}", + ) + + @app.get("/versions", operation_id="list_versions_versions_get") + async def versions(request: Request) -> Response: + return await forward(request, "GET", "/versions") + + @app.get( + "/versions/{kind}", + operation_id="get_country_versions_versions__kind__get", + ) + async def versions_by_kind(kind: str, request: Request) -> Response: + return await forward(request, "GET", f"/versions/{kind}") + + @app.get("/health", operation_id="health_health_get") + async def health() -> dict[str, str]: + return {"status": "healthy"} + + @app.get("/ready") + async def ready() -> Response: + if await runtime_backend.ready(): + return JSONResponse({"status": "ready"}) + return JSONResponse({"status": "not_ready"}, status_code=503) + + @app.post( + "/ping", + operation_id="ping_ping_post", + response_model=PingResponse, + ) + async def ping(body: PingRequest, request: Request) -> Response: + return await forward(request, "POST", "/ping", _model_json(body)) + + return app + + +app = create_app() diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py new file mode 100644 index 000000000..fe2398c8a --- /dev/null +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py @@ -0,0 +1,79 @@ +"""Inbound bearer authentication for protected Simulation API routes.""" + +from __future__ import annotations + +from functools import lru_cache +import logging + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import jwt + +from policyengine_simulation_api.config import Settings + + +_bearer = HTTPBearer(auto_error=False) +logger = logging.getLogger(__name__) + + +class JWTDecoder: + """Validate Auth0 RS256 access tokens without database-layer dependencies.""" + + def __init__(self, issuer: str, audience: str): + self.issuer = issuer + self.audience = audience + self.jwks_client = jwt.PyJWKClient(f"{issuer}.well-known/jwks.json") + + def __call__( + self, + token: HTTPAuthorizationCredentials | None, + ) -> dict[str, str]: + if token is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + try: + signing_key = self.jwks_client.get_signing_key_from_jwt( + token.credentials + ).key + return jwt.decode( + token.credentials, + signing_key, + algorithms=["RS256"], + audience=self.audience, + issuer=self.issuer, + ) + except Exception as error: + logger.info( + "invalid_simulation_api_bearer_token", + extra={"error_type": type(error).__name__}, + ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from error + + +@lru_cache(maxsize=8) +def _decoder(issuer: str, audience: str) -> JWTDecoder: + return JWTDecoder(issuer=issuer, audience=audience) + + +class CallerAuthenticator: + """FastAPI dependency that preserves the gateway's JWT contract.""" + + def __init__(self, settings: Settings): + self.settings = settings + + def __call__( + self, + token: HTTPAuthorizationCredentials | None = Depends(_bearer), + ) -> dict[str, str] | None: + if not self.settings.auth_required: + return None + return _decoder( + self.settings.auth_issuer, + self.settings.auth_audience, + )(token) + + +def reset_decoder_cache() -> None: + """Clear the decoder cache for tests and credential rotations.""" + + _decoder.cache_clear() diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/backend.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/backend.py new file mode 100644 index 000000000..7ddc585e2 --- /dev/null +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/backend.py @@ -0,0 +1,225 @@ +"""Backend protocol and Stage 5 old-gateway implementation.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from typing import Any, Mapping, Protocol + +import httpx + +from policyengine_simulation_api.config import Settings + + +SAFE_RESPONSE_HEADERS = frozenset( + { + "cache-control", + "content-type", + "etag", + "retry-after", + "x-request-id", + } +) + + +class BackendUnavailable(RuntimeError): + """The backend could not be reached.""" + + +class BackendTimeout(RuntimeError): + """The backend did not answer within the configured timeout.""" + + +class BackendAuthenticationError(BackendUnavailable): + """The service could not authenticate to the backend.""" + + +@dataclass(frozen=True) +class BackendResponse: + status_code: int + content: bytes + headers: dict[str, str] + + +class SimulationBackend(Protocol): + async def start(self) -> None: ... + + async def close(self) -> None: ... + + async def ready(self) -> bool: ... + + async def request( + self, + method: str, + path: str, + *, + json_body: Mapping[str, Any] | None = None, + request_id: str | None = None, + ) -> BackendResponse: ... + + +class ClientCredentialsTokenProvider: + """Fetch and cache the Simulation API's old-gateway M2M token.""" + + REFRESH_MARGIN_SECONDS = 60 + + def __init__(self, settings: Settings, client: httpx.AsyncClient): + self.settings = settings + self.client = client + self._token: str | None = None + self._expires_at = 0.0 + self._lock = asyncio.Lock() + + async def get_token(self) -> str: + async with self._lock: + now = time.time() + if ( + self._token is None + or now >= self._expires_at - self.REFRESH_MARGIN_SECONDS + ): + await self._fetch() + if self._token is None: # pragma: no cover + raise BackendAuthenticationError("Auth0 returned no token.") + return self._token + + def invalidate(self) -> None: + self._token = None + self._expires_at = 0.0 + + async def _fetch(self) -> None: + try: + response = await self.client.post( + f"{self.settings.old_gateway_auth_issuer}oauth/token", + json={ + "client_id": self.settings.old_gateway_auth_client_id, + "client_secret": self.settings.old_gateway_auth_client_secret, + "audience": self.settings.old_gateway_auth_audience, + "grant_type": "client_credentials", + }, + ) + response.raise_for_status() + payload = response.json() + token = payload.get("access_token") + expires_in = payload.get("expires_in") + if not token or expires_in is None: + raise BackendAuthenticationError( + "Auth0 response omitted access_token or expires_in." + ) + self._token = str(token) + self._expires_at = time.time() + max(int(expires_in), 1) + except BackendAuthenticationError: + raise + except (httpx.HTTPError, TypeError, ValueError) as exc: + raise BackendAuthenticationError( + "Unable to obtain old-gateway credentials." + ) from exc + + +class OldGatewayBackend: + """Forward compatibility requests to the existing Modal gateway.""" + + def __init__( + self, + settings: Settings, + *, + transport: httpx.AsyncBaseTransport | None = None, + ): + self.settings = settings + self.transport = transport + self.client: httpx.AsyncClient | None = None + self.token_provider: ClientCredentialsTokenProvider | None = None + + async def start(self) -> None: + if self.client is not None: + return + timeout = httpx.Timeout( + connect=self.settings.connect_timeout_seconds, + read=self.settings.request_timeout_seconds, + write=self.settings.request_timeout_seconds, + pool=self.settings.connect_timeout_seconds, + ) + self.client = httpx.AsyncClient( + timeout=timeout, + transport=self.transport, + follow_redirects=False, + ) + self.token_provider = ClientCredentialsTokenProvider( + self.settings, + self.client, + ) + + async def close(self) -> None: + if self.client is not None: + await self.client.aclose() + self.client = None + self.token_provider = None + + def _runtime(self) -> tuple[httpx.AsyncClient, ClientCredentialsTokenProvider]: + if self.client is None or self.token_provider is None: + raise BackendUnavailable("Old-gateway backend is not started.") + return self.client, self.token_provider + + async def ready(self) -> bool: + try: + response = await self.request("GET", "/health") + except (BackendUnavailable, BackendTimeout): + return False + return response.status_code == 200 + + async def request( + self, + method: str, + path: str, + *, + json_body: Mapping[str, Any] | None = None, + request_id: str | None = None, + ) -> BackendResponse: + client, token_provider = self._runtime() + method = method.upper() + max_attempts = 2 if method == "GET" else 1 + attempt = 0 + + while attempt < max_attempts: + attempt += 1 + token = await token_provider.get_token() + headers = {"Authorization": f"Bearer {token}"} + if request_id: + headers["X-Request-ID"] = request_id + + try: + response = await client.request( + method, + f"{self.settings.old_gateway_url}{path}", + json=json_body, + headers=headers, + ) + except (httpx.ConnectError, httpx.ConnectTimeout) as exc: + if method == "GET" and attempt < max_attempts: + continue + raise BackendUnavailable("Old gateway is unavailable.") from exc + except httpx.TimeoutException as exc: + raise BackendTimeout("Old gateway timed out.") from exc + except httpx.RequestError as exc: + raise BackendUnavailable("Old gateway is unavailable.") from exc + + if response.status_code == 401 and attempt == 1: + token_provider.invalidate() + # A 401 is rejected before endpoint processing, so one + # credential-refresh replay is safe for POST as well as GET. + max_attempts = 2 + continue + + return BackendResponse( + status_code=response.status_code, + content=response.content, + headers={ + key: value + for key, value in response.headers.items() + if key.lower() in SAFE_RESPONSE_HEADERS + }, + ) + + raise BackendAuthenticationError( + "Old gateway rejected refreshed service credentials." + ) diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/config.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/config.py new file mode 100644 index 000000000..2d8508c69 --- /dev/null +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/config.py @@ -0,0 +1,112 @@ +"""Runtime configuration for the Cloud Run Simulation API.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from urllib.parse import urlparse + + +PRODUCTION_ENVIRONMENTS = frozenset({"main", "prod", "production"}) + + +class ConfigurationError(RuntimeError): + """Raised when the service cannot start safely.""" + + +def _truthy(value: str | None, *, default: bool = False) -> bool: + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def _normalized_issuer(value: str) -> str: + return f"{value.rstrip('/')}/" if value else "" + + +@dataclass(frozen=True) +class Settings: + """All configuration required by the Stage 5 control-plane proxy.""" + + environment: str + public_url: str + auth_required: bool + auth_issuer: str + auth_audience: str + old_gateway_url: str + old_gateway_auth_issuer: str + old_gateway_auth_audience: str + old_gateway_auth_client_id: str + old_gateway_auth_client_secret: str + connect_timeout_seconds: float = 5.0 + request_timeout_seconds: float = 25.0 + + @classmethod + def from_env(cls) -> "Settings": + return cls( + environment=os.getenv("APP_ENVIRONMENT", "local").lower(), + public_url=os.getenv("SIMULATION_API_PUBLIC_URL", ""), + auth_required=_truthy( + os.getenv("SIMULATION_API_AUTH_REQUIRED"), + default=True, + ), + auth_issuer=_normalized_issuer(os.getenv("SIMULATION_API_AUTH_ISSUER", "")), + auth_audience=os.getenv("SIMULATION_API_AUTH_AUDIENCE", ""), + old_gateway_url=os.getenv("OLD_GATEWAY_URL", "").rstrip("/"), + old_gateway_auth_issuer=_normalized_issuer( + os.getenv("OLD_GATEWAY_AUTH_ISSUER", "") + ), + old_gateway_auth_audience=os.getenv("OLD_GATEWAY_AUTH_AUDIENCE", ""), + old_gateway_auth_client_id=os.getenv("OLD_GATEWAY_AUTH_CLIENT_ID", ""), + old_gateway_auth_client_secret=os.getenv( + "OLD_GATEWAY_AUTH_CLIENT_SECRET", "" + ), + connect_timeout_seconds=float( + os.getenv("OLD_GATEWAY_CONNECT_TIMEOUT_SECONDS", "5") + ), + request_timeout_seconds=float( + os.getenv("OLD_GATEWAY_REQUEST_TIMEOUT_SECONDS", "25") + ), + ) + + @property + def production(self) -> bool: + return self.environment in PRODUCTION_ENVIRONMENTS + + def validate(self) -> None: + if self.production and not self.auth_required: + raise ConfigurationError( + "Caller authentication cannot be disabled in production." + ) + + if bool(self.auth_issuer) != bool(self.auth_audience): + raise ConfigurationError( + "Set both SIMULATION_API_AUTH_ISSUER and SIMULATION_API_AUTH_AUDIENCE." + ) + if self.auth_required and not self.auth_issuer: + raise ConfigurationError( + "Caller authentication is required but issuer/audience are missing." + ) + + required_backend = { + "OLD_GATEWAY_URL": self.old_gateway_url, + "OLD_GATEWAY_AUTH_ISSUER": self.old_gateway_auth_issuer, + "OLD_GATEWAY_AUTH_AUDIENCE": self.old_gateway_auth_audience, + "OLD_GATEWAY_AUTH_CLIENT_ID": self.old_gateway_auth_client_id, + "OLD_GATEWAY_AUTH_CLIENT_SECRET": self.old_gateway_auth_client_secret, + } + missing = [name for name, value in required_backend.items() if not value] + if missing: + raise ConfigurationError( + f"Missing old-gateway configuration: {', '.join(missing)}." + ) + + if self.connect_timeout_seconds <= 0 or self.request_timeout_seconds <= 0: + raise ConfigurationError("Old-gateway timeouts must be positive.") + + public_host = urlparse(self.public_url).hostname + upstream_host = urlparse(self.old_gateway_url).hostname + if public_host and upstream_host and public_host == upstream_host: + raise ConfigurationError( + "OLD_GATEWAY_URL must not point to the Simulation API itself." + ) diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/generate_openapi.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/generate_openapi.py new file mode 100644 index 000000000..a666f927f --- /dev/null +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/generate_openapi.py @@ -0,0 +1,22 @@ +"""Generate the Cloud Run Simulation API OpenAPI document.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from policyengine_simulation_api.app import create_app + + +def main() -> None: + output = Path(__file__).resolve().parents[2] / "artifacts" / "openapi.json" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(create_app().openapi(), indent=2) + "\n", + encoding="utf-8", + ) + print(f"OpenAPI spec written to {output}") + + +if __name__ == "__main__": + main() diff --git a/projects/policyengine-simulation-api/tests/conftest.py b/projects/policyengine-simulation-api/tests/conftest.py new file mode 100644 index 000000000..155c2b6b2 --- /dev/null +++ b/projects/policyengine-simulation-api/tests/conftest.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from policyengine_simulation_api.app import create_app +from policyengine_simulation_api.backend import BackendResponse +from policyengine_simulation_api.config import Settings + + +def make_settings(**overrides) -> Settings: + values = { + "environment": "test", + "public_url": "https://simulation.example.test", + "auth_required": True, + "auth_issuer": "https://issuer.example/", + "auth_audience": "simulation-api", + "old_gateway_url": "https://old-gateway.example.test", + "old_gateway_auth_issuer": "https://issuer.example/", + "old_gateway_auth_audience": "simulation-api", + "old_gateway_auth_client_id": "simulation-api-service", + "old_gateway_auth_client_secret": "secret", + } + values.update(overrides) + return Settings(**values) + + +class FakeBackend: + def __init__(self): + self.requests: list[dict[str, Any]] = [] + self.responses: dict[tuple[str, str], BackendResponse] = {} + self.is_ready = True + self.started = False + + async def start(self) -> None: + self.started = True + + async def close(self) -> None: + self.started = False + + async def ready(self) -> bool: + return self.is_ready + + async def request( + self, + method: str, + path: str, + *, + json_body: Mapping[str, Any] | None = None, + request_id: str | None = None, + ) -> BackendResponse: + self.requests.append( + { + "method": method, + "path": path, + "json_body": json_body, + "request_id": request_id, + } + ) + return self.responses.get( + (method, path), + BackendResponse( + status_code=200, + content=b"{}", + headers={"content-type": "application/json"}, + ), + ) + + +@pytest.fixture +def backend() -> FakeBackend: + return FakeBackend() + + +@pytest.fixture +def client(backend: FakeBackend): + app = create_app( + settings=make_settings(), + backend=backend, + auth_dependency=lambda: None, + ) + with TestClient(app) as test_client: + yield test_client diff --git a/projects/policyengine-simulation-api/tests/test_app.py b/projects/policyengine-simulation-api/tests/test_app.py new file mode 100644 index 000000000..c7757c8cf --- /dev/null +++ b/projects/policyengine-simulation-api/tests/test_app.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import json + +import pytest + +from policyengine_simulation_api.app import create_app +from policyengine_simulation_api.backend import ( + BackendResponse, + BackendTimeout, + BackendUnavailable, +) + +from conftest import FakeBackend, make_settings + + +def response(status: int, payload: dict) -> BackendResponse: + return BackendResponse( + status_code=status, + content=json.dumps(payload).encode(), + headers={"content-type": "application/json", "retry-after": "3"}, + ) + + +def test_health_is_local_and_compatible(client, backend): + result = client.get("/health") + + assert result.status_code == 200 + assert result.json() == {"status": "healthy"} + assert backend.requests == [] + + +def test_readiness_tracks_backend(client, backend): + assert client.get("/ready").json() == {"status": "ready"} + + backend.is_ready = False + result = client.get("/ready") + + assert result.status_code == 503 + assert result.json() == {"status": "not_ready"} + + +def test_comparison_submission_preserves_upstream_response(client, backend): + payload = { + "job_id": "fc-123", + "status": "submitted", + "poll_url": "/jobs/fc-123", + "country": "us", + "version": "1.0.0", + "resolved_app_name": "worker", + "policyengine_bundle": {"model_version": "1.0.0"}, + } + backend.responses[("POST", "/simulate/economy/comparison")] = response( + 202, + payload, + ) + + result = client.post( + "/simulate/economy/comparison", + json={"country": "us", "scope": "macro", "reform": {}}, + ) + + assert result.status_code == 202 + assert result.json() == payload + assert result.headers["x-policyengine-simulation-backend"] == "old_gateway" + assert backend.requests[-1]["path"] == "/simulate/economy/comparison" + + +def test_job_status_preserves_id_and_status(client, backend): + backend.responses[("GET", "/jobs/fc-123")] = response( + 202, + {"status": "running", "run_id": "run-1"}, + ) + + result = client.get("/jobs/fc-123") + + assert result.status_code == 202 + assert result.json() == {"status": "running", "run_id": "run-1"} + assert backend.requests[-1]["path"] == "/jobs/fc-123" + + +def test_budget_window_routes_use_original_batch_id(client, backend): + backend.responses[("POST", "/simulate/economy/budget-window")] = response( + 202, + { + "batch_job_id": "batch-123", + "status": "submitted", + "poll_url": "/budget-window-jobs/batch-123", + }, + ) + backend.responses[("GET", "/budget-window-jobs/batch-123")] = response( + 200, + {"status": "complete", "completed_years": ["2026"]}, + ) + + submitted = client.post( + "/simulate/economy/budget-window", + json={ + "country": "us", + "region": "us", + "scope": "macro", + "reform": {}, + "start_year": "2026", + "window_size": 1, + }, + ) + polled = client.get("/budget-window-jobs/batch-123") + + assert submitted.status_code == 202 + assert submitted.json()["batch_job_id"] == "batch-123" + assert polled.status_code == 200 + assert backend.requests[-1]["path"] == "/budget-window-jobs/batch-123" + + +def test_versions_and_ping_are_public_proxy_routes(client, backend): + backend.responses[("GET", "/versions/us")] = response( + 200, + {"latest": "1.2.3"}, + ) + backend.responses[("POST", "/ping")] = response(200, {"incremented": 2}) + + assert client.get("/versions/us").json() == {"latest": "1.2.3"} + assert client.post("/ping", json={"value": 1}).json() == {"incremented": 2} + + +def test_request_id_is_propagated_and_returned(client, backend): + result = client.get("/versions", headers={"X-Request-ID": "request-123"}) + + assert result.headers["x-request-id"] == "request-123" + assert backend.requests[-1]["request_id"] == "request-123" + + +def test_request_validation_matches_shared_contract(client): + result = client.post( + "/simulate/economy/comparison", + json={"country": "us", "unknown": True}, + ) + + assert result.status_code == 422 + + +@pytest.mark.parametrize( + ("error", "expected_status", "expected_detail"), + [ + (BackendUnavailable("unavailable"), 503, "Simulation backend is unavailable."), + (BackendTimeout("timed out"), 504, "Simulation backend timed out."), + ], +) +def test_backend_failures_are_sanitized(error, expected_status, expected_detail): + class FailingBackend(FakeBackend): + async def request(self, *args, **kwargs): + raise error + + app = create_app( + settings=make_settings(), + backend=FailingBackend(), + auth_dependency=lambda: None, + ) + + from fastapi.testclient import TestClient + + with TestClient(app) as client: + result = client.get("/jobs/job-1") + + assert result.status_code == expected_status + assert result.json() == {"detail": expected_detail} + assert result.headers["retry-after"] == "10" + + +@pytest.mark.parametrize("status_code", [400, 404, 409, 500]) +def test_upstream_error_status_and_body_are_preserved(client, backend, status_code): + backend.responses[("GET", "/jobs/job-1")] = response( + status_code, + {"detail": "upstream response"}, + ) + + result = client.get("/jobs/job-1") + + assert result.status_code == status_code + assert result.json() == {"detail": "upstream response"} diff --git a/projects/policyengine-simulation-api/tests/test_auth.py b/projects/policyengine-simulation-api/tests/test_auth.py new file mode 100644 index 000000000..2634f5cd2 --- /dev/null +++ b/projects/policyengine-simulation-api/tests/test_auth.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient + +from policyengine_simulation_api import auth as auth_module +from policyengine_simulation_api.app import create_app + +from conftest import FakeBackend, make_settings + + +@pytest.fixture +def signing_keys(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key, private_key.public_key() + + +def _token(private_key, **overrides) -> str: + now = datetime.now(timezone.utc) + claims = { + "sub": "api-v1", + "iss": "https://issuer.example/", + "aud": "simulation-api", + "iat": now, + "exp": now + timedelta(minutes=5), + } + claims.update(overrides) + return jwt.encode(claims, private_key, algorithm="RS256") + + +def _use_static_signing_key(monkeypatch, public_key) -> None: + decoder = auth_module.JWTDecoder( + issuer="https://issuer.example/", + audience="simulation-api", + ) + decoder.jwks_client = SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ) + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: decoder) + + +def test_missing_token_preserves_403_contract(monkeypatch): + class Decoder: + def __call__(self, token): + from fastapi import HTTPException + + raise HTTPException(status_code=403) + + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: Decoder()) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get("/jobs/job-1") + + assert result.status_code == 403 + + +def test_public_routes_do_not_require_token(monkeypatch): + class Decoder: + def __call__(self, token): + raise AssertionError("public route attempted authentication") + + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: Decoder()) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + assert client.get("/health").status_code == 200 + assert client.get("/versions").status_code == 200 + assert client.post("/ping", json={"value": 1}).status_code == 200 + + +def test_valid_api_v1_token_can_poll(monkeypatch, signing_keys): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"Authorization": f"Bearer {_token(private_key)}"}, + ) + + assert result.status_code == 200 + + +@pytest.mark.parametrize( + "claim_overrides", + [ + {"iss": "https://wrong-issuer.example/"}, + {"aud": "wrong-audience"}, + {"exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, + ], + ids=["wrong-issuer", "wrong-audience", "expired"], +) +def test_invalid_api_v1_token_claims_return_403( + monkeypatch, + signing_keys, + claim_overrides, +): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={ + "Authorization": f"Bearer {_token(private_key, **claim_overrides)}" + }, + ) + + assert result.status_code == 403 + + +def test_malformed_token_returns_403(monkeypatch, signing_keys): + _, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"Authorization": "Bearer not-a-jwt"}, + ) + + assert result.status_code == 403 diff --git a/projects/policyengine-simulation-api/tests/test_backend.py b/projects/policyengine-simulation-api/tests/test_backend.py new file mode 100644 index 000000000..ded625df7 --- /dev/null +++ b/projects/policyengine-simulation-api/tests/test_backend.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from policyengine_simulation_api.backend import ( + BackendUnavailable, + OldGatewayBackend, +) + +from conftest import make_settings + + +@pytest.mark.asyncio +async def test_backend_uses_own_token_and_preserves_safe_response_headers(): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/oauth/token": + return httpx.Response( + 200, + json={"access_token": "service-token", "expires_in": 3600}, + ) + return httpx.Response( + 202, + json={"job_id": "fc-123", "status": "submitted"}, + headers={ + "Retry-After": "2", + "Set-Cookie": "must-not-pass", + "X-Request-ID": "upstream-request", + }, + ) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + result = await backend.request( + "POST", + "/simulate/economy/comparison", + json_body={"country": "us"}, + request_id="caller-request", + ) + finally: + await backend.close() + + upstream = requests[-1] + assert upstream.headers["authorization"] == "Bearer service-token" + assert upstream.headers["x-request-id"] == "caller-request" + assert json.loads(result.content) == { + "job_id": "fc-123", + "status": "submitted", + } + assert result.headers["retry-after"] == "2" + assert "set-cookie" not in result.headers + + +@pytest.mark.asyncio +async def test_backend_refreshes_token_once_after_401(): + token_fetches = 0 + gateway_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_fetches, gateway_calls + if request.url.path == "/oauth/token": + token_fetches += 1 + return httpx.Response( + 200, + json={ + "access_token": f"token-{token_fetches}", + "expires_in": 3600, + }, + ) + gateway_calls += 1 + if gateway_calls == 1: + return httpx.Response(401) + return httpx.Response(200, json={"status": "complete"}) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + result = await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + assert result.status_code == 200 + assert token_fetches == 2 + assert gateway_calls == 2 + + +@pytest.mark.asyncio +async def test_short_lived_backend_token_is_never_cached_past_expiry(): + token_fetches = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_fetches + if request.url.path == "/oauth/token": + token_fetches += 1 + return httpx.Response( + 200, + json={"access_token": f"token-{token_fetches}", "expires_in": 1}, + ) + return httpx.Response(200, json={"status": "complete"}) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + await backend.request("GET", "/jobs/fc-123") + await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + assert token_fetches == 2 + + +@pytest.mark.asyncio +async def test_post_connection_error_is_not_retried(): + gateway_attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal gateway_attempts + if request.url.path == "/oauth/token": + return httpx.Response( + 200, + json={"access_token": "token", "expires_in": 3600}, + ) + gateway_attempts += 1 + raise httpx.ConnectError("unavailable", request=request) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + with pytest.raises(BackendUnavailable): + await backend.request( + "POST", + "/simulate/economy/comparison", + json_body={"country": "us"}, + ) + finally: + await backend.close() + + assert gateway_attempts == 1 + + +@pytest.mark.asyncio +async def test_get_connection_error_is_retried_once(): + gateway_attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal gateway_attempts + if request.url.path == "/oauth/token": + return httpx.Response( + 200, + json={"access_token": "token", "expires_in": 3600}, + ) + gateway_attempts += 1 + if gateway_attempts == 1: + raise httpx.ConnectError("unavailable", request=request) + return httpx.Response(200, json={"status": "complete"}) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + result = await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + assert result.status_code == 200 + assert gateway_attempts == 2 diff --git a/projects/policyengine-simulation-api/tests/test_config.py b/projects/policyengine-simulation-api/tests/test_config.py new file mode 100644 index 000000000..6d7ee5e1e --- /dev/null +++ b/projects/policyengine-simulation-api/tests/test_config.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pytest + +from policyengine_simulation_api.config import ConfigurationError + +from conftest import make_settings + + +def test_production_refuses_disabled_auth(): + with pytest.raises(ConfigurationError, match="cannot be disabled"): + make_settings(environment="production", auth_required=False).validate() + + +def test_partial_caller_auth_is_rejected(): + with pytest.raises(ConfigurationError, match="Set both"): + make_settings(auth_audience="").validate() + + +def test_recursive_gateway_url_is_rejected(): + with pytest.raises(ConfigurationError, match="must not point"): + make_settings( + old_gateway_url="https://simulation.example.test", + ).validate() + + +def test_complete_configuration_is_valid(): + make_settings().validate() diff --git a/projects/policyengine-simulation-api/tests/test_openapi.py b/projects/policyengine-simulation-api/tests/test_openapi.py new file mode 100644 index 000000000..0e979cb12 --- /dev/null +++ b/projects/policyengine-simulation-api/tests/test_openapi.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from policyengine_simulation_api.app import create_app + + +EXPECTED_ROUTES = { + ("GET", "/budget-window-jobs/{batch_job_id}"), + ("GET", "/health"), + ("GET", "/jobs/{job_id}"), + ("GET", "/ready"), + ("GET", "/versions"), + ("GET", "/versions/{kind}"), + ("POST", "/ping"), + ("POST", "/simulate/economy/budget-window"), + ("POST", "/simulate/economy/comparison"), +} + + +def methods(spec: dict) -> set[tuple[str, str]]: + return { + (method.upper(), path) + for path, operations in spec["paths"].items() + for method in operations + if method.lower() in {"get", "post", "put", "patch", "delete"} + } + + +def operation_ids(spec: dict) -> dict[tuple[str, str], str]: + return { + (method.upper(), path): operation["operationId"] + for path, operations in spec["paths"].items() + for method, operation in operations.items() + if method.lower() in {"get", "post", "put", "patch", "delete"} + } + + +def test_route_table_is_frozen(): + assert methods(create_app().openapi()) == EXPECTED_ROUTES + + +def test_old_gateway_routes_are_all_present(): + gateway_spec_path = ( + Path(__file__).resolve().parents[2] + / "policyengine-simulation-gateway" + / "tests" + / "golden" + / "openapi.json" + ) + gateway_spec = json.loads(gateway_spec_path.read_text()) + cloud_run_routes = methods(create_app().openapi()) + + assert methods(gateway_spec) <= cloud_run_routes + + +def test_normalized_contract_schemas_match_old_gateway(): + gateway_spec_path = ( + Path(__file__).resolve().parents[2] + / "policyengine-simulation-gateway" + / "tests" + / "golden" + / "openapi.json" + ) + gateway_spec = json.loads(gateway_spec_path.read_text()) + cloud_run_spec = create_app().openapi() + + assert ( + cloud_run_spec["components"]["schemas"] == gateway_spec["components"]["schemas"] + ) + gateway_operation_ids = operation_ids(gateway_spec) + cloud_run_operation_ids = operation_ids(cloud_run_spec) + assert { + key: cloud_run_operation_ids[key] for key in gateway_operation_ids + } == gateway_operation_ids diff --git a/projects/policyengine-simulation-api/uv.lock b/projects/policyengine-simulation-api/uv.lock new file mode 100644 index 000000000..6b1b1f005 --- /dev/null +++ b/projects/policyengine-simulation-api/uv.lock @@ -0,0 +1,1046 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.82.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "logfire" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/fa/14cbd976e8ff77290c0c87f93c45029785537ffb84e2f5215416786cda11/logfire-4.6.0.tar.gz", hash = "sha256:974bc8f4efd3aa9df2d0c7b1d53b35dc4612ff5cee2be6c40dd65e5c26eab694", size = 533174, upload-time = "2025-09-10T14:56:42.168Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/9c/6384dc69601bfba19c1f5909a472b00cc4b5863f411ecf85be308beacf8f/logfire-4.6.0-py3-none-any.whl", hash = "sha256:d322844db7a2f1935976f2852a07e3753489ecc4e83fa288173b05a453bb1cbf", size = 219556, upload-time = "2025-09-10T14:56:37.278Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openapi-python-client" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "ruamel-yaml" }, + { name = "ruff" }, + { name = "shellingham" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/f6/67dfe73b5073251b6e2aba3d67191819798dd33dd09fcb2d12242662a960/openapi_python_client-0.29.0.tar.gz", hash = "sha256:4ff439a63765aaef548e69c4eedd86dfad57891dc322709450af0c2c9a72a23e", size = 125697, upload-time = "2026-05-30T20:32:10.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/d1/7391a205622c40b8e9d1b79b892b4944d6e3637674b47a56196e2c96c5d1/openapi_python_client-0.29.0-py3-none-any.whl", hash = "sha256:1085864c1e0a42fb50e1f5eb84363b19de07ebfb8a3f82a146c8529b948b8f12", size = 182954, upload-time = "2026-05-30T20:32:09.037Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/bbbf879826b7f3c89a45252010b5796fb1f1a0d45d9dc4709db0ef9a06c8/opentelemetry_api-1.30.0.tar.gz", hash = "sha256:375893400c1435bf623f7dfb3bcd44825fe6b56c34d0667c542ea8257b1a1240", size = 63703, upload-time = "2025-02-04T18:17:13.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/0a/eea862fae6413d8181b23acf8e13489c90a45f17986ee9cf4eab8a0b9ad9/opentelemetry_api-1.30.0-py3-none-any.whl", hash = "sha256:d5f5284890d73fdf47f843dda3210edf37a38d66f44f2b5aedc1e89ed455dc09", size = 64955, upload-time = "2025-02-04T18:16:46.167Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/d7/44098bf1ef89fc5810cdbda05faa2ae9322a0dbda4921cdc965dc68a9856/opentelemetry_exporter_otlp_proto_common-1.30.0.tar.gz", hash = "sha256:ddbfbf797e518411857d0ca062c957080279320d6235a279f7b64ced73c13897", size = 19640, upload-time = "2025-02-04T18:17:16.234Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/54/f4b3de49f8d7d3a78fd6e6e1a6fd27dd342eb4d82c088b9078c6a32c3808/opentelemetry_exporter_otlp_proto_common-1.30.0-py3-none-any.whl", hash = "sha256:5468007c81aa9c44dc961ab2cf368a29d3475977df83b4e30aeed42aa7bc3b38", size = 18747, upload-time = "2025-02-04T18:16:51.512Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/3e/c7246df92c25e6ce95c349ad21597b4471b01ec9471e95d5261f1629fe92/opentelemetry_exporter_otlp_proto_grpc-1.30.0.tar.gz", hash = "sha256:d0f10f0b9b9a383b7d04a144d01cb280e70362cccc613987e234183fd1f01177", size = 26256, upload-time = "2025-02-04T18:17:16.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/35/d9f63fd84c2ed8dbd407bcbb933db4ed6e1b08e7fbdaca080b9ac309b927/opentelemetry_exporter_otlp_proto_grpc-1.30.0-py3-none-any.whl", hash = "sha256:2906bcae3d80acc54fd1ffcb9e44d324e8631058b502ebe4643ca71d1ff30830", size = 18550, upload-time = "2025-02-04T18:16:52.532Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/f9/abb9191d536e6a2e2b7903f8053bf859a76bf784e3ca19a5749550ef19e4/opentelemetry_exporter_otlp_proto_http-1.30.0.tar.gz", hash = "sha256:c3ae75d4181b1e34a60662a6814d0b94dd33b628bee5588a878bed92cee6abdc", size = 15073, upload-time = "2025-02-04T18:17:18.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/3c/cdf34bc459613f2275aff9b258f35acdc4c4938dad161d17437de5d4c034/opentelemetry_exporter_otlp_proto_http-1.30.0-py3-none-any.whl", hash = "sha256:9578e790e579931c5ffd50f1e6975cbdefb6a0a0a5dea127a6ae87df10e0a589", size = 17245, upload-time = "2025-02-04T18:16:53.514Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/5a/4c7f02235ac1269b48f3855f6be1afc641f31d4888d28b90b732fbce7141/opentelemetry_instrumentation-0.51b0.tar.gz", hash = "sha256:4ca266875e02f3988536982467f7ef8c32a38b8895490ddce9ad9604649424fa", size = 27760, upload-time = "2025-02-04T18:21:09.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/2c/48fa93f1acca9f79a06da0df7bfe916632ecc7fce1971067b3e46bcae55b/opentelemetry_instrumentation-0.51b0-py3-none-any.whl", hash = "sha256:c6de8bd26b75ec8b0e54dff59e198946e29de6a10ec65488c357d4b34aa5bdcf", size = 30923, upload-time = "2025-02-04T18:19:37.829Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/67/8aa6e1129f641f0f3f8786e6c5d18c1f2bbe490bd4b0e91a6879e85154d2/opentelemetry_instrumentation_asgi-0.51b0.tar.gz", hash = "sha256:b3fe97c00f0bfa934371a69674981d76591c68d937b6422a5716ca21081b4148", size = 24201, upload-time = "2025-02-04T18:21:14.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/7e/0a95ab37302729543631a789ba8e71dea75c520495739dbbbdfdc580b401/opentelemetry_instrumentation_asgi-0.51b0-py3-none-any.whl", hash = "sha256:e8072993db47303b633c6ec1bc74726ba4d32bd0c46c28dfadf99f79521a324c", size = 16340, upload-time = "2025-02-04T18:19:49.924Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/8db4422b5084177d1ef6c7855c69bf2e9e689f595a4a9b59e60588e0d427/opentelemetry_instrumentation_fastapi-0.51b0.tar.gz", hash = "sha256:1624e70f2f4d12ceb792d8a0c331244cd6723190ccee01336273b4559bc13abc", size = 19249, upload-time = "2025-02-04T18:21:28.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/1c/ec2d816b78edf2404d7b3df6d09eefb690b70bfd191b7da06f76634f1bdc/opentelemetry_instrumentation_fastapi-0.51b0-py3-none-any.whl", hash = "sha256:10513bbc11a1188adb9c1d2c520695f7a8f2b5f4de14e8162098035901cd6493", size = 12117, upload-time = "2025-02-04T18:20:15.267Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/d5/4a3990c461ae7e55212115e0f8f3aa412b5ce6493579e85c292245ac69ea/opentelemetry_instrumentation_httpx-0.51b0.tar.gz", hash = "sha256:061d426a04bf5215a859fea46662e5074f920e5cbde7e6ad6825a0a1b595802c", size = 17700, upload-time = "2025-02-04T18:21:31.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ba/23d4ab6402408c01f1c3f32e0c04ea6dae575bf19bcb9a0049c9e768c983/opentelemetry_instrumentation_httpx-0.51b0-py3-none-any.whl", hash = "sha256:2e3fdf755ba6ead6ab43031497c3d55d4c796d0368eccc0ce48d304b7ec6486a", size = 14109, upload-time = "2025-02-04T18:20:19.947Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/6e/c1ff2e3b0cd3a189a6be03fd4d63441d73d7addd9117ab5454e667b9b6c7/opentelemetry_proto-1.30.0.tar.gz", hash = "sha256:afe5c9c15e8b68d7c469596e5b32e8fc085eb9febdd6fb4e20924a93a0389179", size = 34362, upload-time = "2025-02-04T18:17:28.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/d7/85de6501f7216995295f7ec11e470142e6a6e080baacec1753bbf272e007/opentelemetry_proto-1.30.0-py3-none-any.whl", hash = "sha256:c6290958ff3ddacc826ca5abbeb377a31c2334387352a259ba0df37c243adc11", size = 55854, upload-time = "2025-02-04T18:17:08.024Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ee/d710062e8a862433d1be0b85920d0c653abe318878fef2d14dfe2c62ff7b/opentelemetry_sdk-1.30.0.tar.gz", hash = "sha256:c9287a9e4a7614b9946e933a67168450b9ab35f08797eb9bc77d998fa480fa18", size = 158633, upload-time = "2025-02-04T18:17:28.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/28/64d781d6adc6bda2260067ce2902bd030cf45aec657e02e28c5b4480b976/opentelemetry_sdk-1.30.0-py3-none-any.whl", hash = "sha256:14fe7afc090caad881addb6926cec967129bd9260c4d33ae6a217359f6b61091", size = 118717, upload-time = "2025-02-04T18:17:09.353Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/c0/0f9ef4605fea7f2b83d55dd0b0d7aebe8feead247cd6facd232b30907b4f/opentelemetry_semantic_conventions-0.51b0.tar.gz", hash = "sha256:3fabf47f35d1fd9aebcdca7e6802d86bd5ebc3bc3408b7e3248dde6e87a18c47", size = 107191, upload-time = "2025-02-04T18:17:29.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/75/d7bdbb6fd8630b4cafb883482b75c4fc276b6426619539d266e32ac53266/opentelemetry_semantic_conventions-0.51b0-py3-none-any.whl", hash = "sha256:fdc777359418e8d06c86012c3dc92c88a6453ba662e941593adb062e48c2eeae", size = 177416, upload-time = "2025-02-04T18:17:11.305Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/64/32510c0a803465eb6ef1f5bd514d0f5627f8abc9444ed94f7240faf6fcaa/opentelemetry_util_http-0.51b0.tar.gz", hash = "sha256:05edd19ca1cc3be3968b1e502fd94816901a365adbeaab6b6ddb974384d3a0b9", size = 8043, upload-time = "2025-02-04T18:21:59.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/dd/c371eeb9cc78abbdad231a27ce1a196a37ef96328d876ccbb381dea4c8ee/opentelemetry_util_http-0.51b0-py3-none-any.whl", hash = "sha256:0561d7a6e9c422b9ef9ae6e77eafcfcd32a2ab689f5e801475cbb67f189efa20", size = 7304, upload-time = "2025-02-04T18:21:05.483Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "policyengine-observability" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2a/5e0e4c0f30b20a59ffff14364e15374f9eee494f658f41559aba97c152a2/policyengine_observability-1.3.0.tar.gz", hash = "sha256:4c2f3bb2ee59886aef6c90f1edb9c0b957ace75b1518616c0e61905b8be431e0", size = 107961, upload-time = "2026-07-01T21:10:53.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a2/80e4bc1c4923e8f4c04483bfd22df2500b0d132c4ce1a6dec6749c94d947/policyengine_observability-1.3.0-py3-none-any.whl", hash = "sha256:7a9444e2cb8ddd7f6d54d601bd1e0a7651bd44783b78627b293a27e741f91930", size = 31087, upload-time = "2026-07-01T21:10:51.793Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] + +[[package]] +name = "policyengine-simulation-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "policyengine-observability", extra = ["fastapi"] }, + { name = "policyengine-simulation-contract" }, + { name = "policyengine-simulation-observability" }, + { name = "pyjwt" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +build = [ + { name = "openapi-python-client" }, + { name = "pyright" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=41.0.0" }, + { name = "fastapi", specifier = ">=0.115.0,<1" }, + { name = "httpx", specifier = ">=0.28.0,<1" }, + { name = "openapi-python-client", marker = "extra == 'build'", specifier = ">=0.21.6" }, + { name = "policyengine-observability", extras = ["fastapi"], specifier = ">=1.3.0,<2" }, + { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, + { name = "pyjwt", specifier = ">=2.10.1,<3" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, + { name = "uvicorn", specifier = ">=0.35.0,<1" }, +] +provides-extras = ["test", "build"] + +[[package]] +name = "policyengine-simulation-contract" +version = "0.1.0" +source = { editable = "../../libs/policyengine-simulation-contract" } +dependencies = [ + { name = "policyengine-simulation-observability" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, + { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["test", "build"] + +[[package]] +name = "policyengine-simulation-observability" +version = "0.1.0" +source = { editable = "../../libs/policyengine-simulation-observability" } +dependencies = [ + { name = "fastapi" }, + { name = "importlib-metadata" }, + { name = "logfire" }, + { name = "policyengine-observability", extra = ["fastapi"] }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx2", marker = "extra == 'test'" }, + { name = "importlib-metadata", specifier = ">=8" }, + { name = "logfire", specifier = ">=3.0.0" }, + { name = "policyengine-observability", extras = ["fastapi"], specifier = ">=1.3.0,<2" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["test", "build"] + +[[package]] +name = "protobuf" +version = "5.29.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" }, + { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" }, + { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/projects/policyengine-simulation-executor/uv.lock b/projects/policyengine-simulation-executor/uv.lock index 385c77a75..bd9a0406a 100644 --- a/projects/policyengine-simulation-executor/uv.lock +++ b/projects/policyengine-simulation-executor/uv.lock @@ -1830,7 +1830,6 @@ name = "policyengine-simulation-contract" version = "0.1.0" source = { editable = "../../libs/policyengine-simulation-contract" } dependencies = [ - { name = "modal" }, { name = "policyengine-simulation-observability" }, { name = "pydantic" }, ] @@ -1838,7 +1837,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, - { name = "modal", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, diff --git a/projects/policyengine-simulation-gateway/uv.lock b/projects/policyengine-simulation-gateway/uv.lock index f8a6b87cc..0b67dd2f3 100644 --- a/projects/policyengine-simulation-gateway/uv.lock +++ b/projects/policyengine-simulation-gateway/uv.lock @@ -1289,7 +1289,6 @@ name = "policyengine-simulation-contract" version = "0.1.0" source = { editable = "../../libs/policyengine-simulation-contract" } dependencies = [ - { name = "modal" }, { name = "policyengine-simulation-observability" }, { name = "pydantic" }, ] @@ -1297,7 +1296,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, - { name = "modal", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, From 97d8c2799aff248bf48a2191e1930efd318ff842 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:57:35 +0300 Subject: [PATCH 02/11] feat: deploy simulation API with gcloud and GitHub Actions --- .../bootstrap-cloud-run-simulation-api.sh | 192 ++++++++++++++++ .../scripts/cloud-run-simulation-api-smoke.sh | 18 ++ ...figure-cloud-run-simulation-api-domains.sh | 49 +++++ .../workflows/cloud-run-simulation-api.yml | 208 ++++++++++++++++++ .../policyengine-simulation-api/DEPLOYMENT.md | 60 +++++ .../policyengine-simulation-api/Dockerfile | 26 +++ .../Dockerfile.dockerignore | 19 ++ .../tests/test_deployment_assets.py | 105 +++++++++ 8 files changed, 677 insertions(+) create mode 100644 .github/scripts/bootstrap-cloud-run-simulation-api.sh create mode 100644 .github/scripts/cloud-run-simulation-api-smoke.sh create mode 100644 .github/scripts/configure-cloud-run-simulation-api-domains.sh create mode 100644 .github/workflows/cloud-run-simulation-api.yml create mode 100644 projects/policyengine-simulation-api/DEPLOYMENT.md create mode 100644 projects/policyengine-simulation-api/Dockerfile create mode 100644 projects/policyengine-simulation-api/Dockerfile.dockerignore create mode 100644 projects/policyengine-simulation-api/tests/test_deployment_assets.py diff --git a/.github/scripts/bootstrap-cloud-run-simulation-api.sh b/.github/scripts/bootstrap-cloud-run-simulation-api.sh new file mode 100644 index 000000000..7b3404ca7 --- /dev/null +++ b/.github/scripts/bootstrap-cloud-run-simulation-api.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +set -euo pipefail + +gcloud_bin="${GCLOUD_BIN:-gcloud}" +project_id="${SIMULATION_GCP_PROJECT_ID:?SIMULATION_GCP_PROJECT_ID is required}" +billing_account="${SIMULATION_GCP_BILLING_ACCOUNT:-}" +region="${SIMULATION_GCP_REGION:-us-central1}" +repository="${SIMULATION_ARTIFACT_REPOSITORY:-policyengine-simulation-api}" +github_repository="${SIMULATION_GITHUB_REPOSITORY:-PolicyEngine/policyengine-sim-api}" +pool_id="${SIMULATION_WIF_POOL_ID:-simulation-api-github}" +provider_id="${SIMULATION_WIF_PROVIDER_ID:-github}" +dry_run="${SIMULATION_BOOTSTRAP_DRY_RUN:-0}" + +deployer_account_id="simulation-api-github-deployer" +staging_runtime_account_id="simulation-api-stg-runtime" +production_runtime_account_id="simulation-api-prod-runtime" +staging_secret="simulation-api-old-gateway-client-secret-staging" +production_secret="simulation-api-old-gateway-client-secret-production" + +run() { + if [ "${dry_run}" = "1" ]; then + printf '+' + printf ' %q' "$@" + printf '\n' + return 0 + fi + "$@" +} + +exists() { + if [ "${dry_run}" = "1" ]; then + return 1 + fi + "$@" >/dev/null 2>&1 +} + +if ! exists "${gcloud_bin}" projects describe "${project_id}"; then + project_args=( + projects create "${project_id}" + --name "PolicyEngine Simulation API" + ) + if [ -n "${SIMULATION_GCP_FOLDER_ID:-}" ]; then + project_args+=(--folder "${SIMULATION_GCP_FOLDER_ID}") + elif [ -n "${SIMULATION_GCP_ORGANIZATION_ID:-}" ]; then + project_args+=(--organization "${SIMULATION_GCP_ORGANIZATION_ID}") + fi + run "${gcloud_bin}" "${project_args[@]}" +fi + +if [ -n "${billing_account}" ]; then + run "${gcloud_bin}" billing projects link "${project_id}" \ + --billing-account "${billing_account}" +fi + +run "${gcloud_bin}" services enable \ + artifactregistry.googleapis.com \ + cloudresourcemanager.googleapis.com \ + iam.googleapis.com \ + iamcredentials.googleapis.com \ + run.googleapis.com \ + secretmanager.googleapis.com \ + sts.googleapis.com \ + --project "${project_id}" + +if ! exists "${gcloud_bin}" artifacts repositories describe "${repository}" \ + --project "${project_id}" \ + --location "${region}"; then + run "${gcloud_bin}" artifacts repositories create "${repository}" \ + --project "${project_id}" \ + --location "${region}" \ + --repository-format docker \ + --description "Cloud Run images for the PolicyEngine Simulation API" +fi + +ensure_service_account() { + local account_id="${1:?service account ID is required}" + local display_name="${2:?display name is required}" + local email="${account_id}@${project_id}.iam.gserviceaccount.com" + if ! exists "${gcloud_bin}" iam service-accounts describe "${email}" \ + --project "${project_id}"; then + run "${gcloud_bin}" iam service-accounts create "${account_id}" \ + --project "${project_id}" \ + --display-name "${display_name}" + fi +} + +ensure_service_account "${deployer_account_id}" \ + "Simulation API GitHub deployer" +ensure_service_account "${staging_runtime_account_id}" \ + "Simulation API staging runtime" +ensure_service_account "${production_runtime_account_id}" \ + "Simulation API production runtime" + +deployer_email="${deployer_account_id}@${project_id}.iam.gserviceaccount.com" +staging_runtime_email="${staging_runtime_account_id}@${project_id}.iam.gserviceaccount.com" +production_runtime_email="${production_runtime_account_id}@${project_id}.iam.gserviceaccount.com" + +for role in \ + roles/artifactregistry.writer \ + roles/run.admin \ + roles/secretmanager.viewer \ + roles/serviceusage.serviceUsageConsumer; do + run "${gcloud_bin}" projects add-iam-policy-binding "${project_id}" \ + --member "serviceAccount:${deployer_email}" \ + --role "${role}" \ + --condition=None \ + --quiet +done + +for runtime_email in "${staging_runtime_email}" "${production_runtime_email}"; do + run "${gcloud_bin}" iam service-accounts add-iam-policy-binding \ + "${runtime_email}" \ + --project "${project_id}" \ + --member "serviceAccount:${deployer_email}" \ + --role roles/iam.serviceAccountUser \ + --quiet +done + +ensure_secret() { + local secret="${1:?secret name is required}" + if ! exists "${gcloud_bin}" secrets describe "${secret}" \ + --project "${project_id}"; then + run "${gcloud_bin}" secrets create "${secret}" \ + --project "${project_id}" \ + --replication-policy automatic + fi +} + +ensure_secret "${staging_secret}" +ensure_secret "${production_secret}" + +run "${gcloud_bin}" secrets add-iam-policy-binding "${staging_secret}" \ + --project "${project_id}" \ + --member "serviceAccount:${staging_runtime_email}" \ + --role roles/secretmanager.secretAccessor \ + --quiet +run "${gcloud_bin}" secrets add-iam-policy-binding "${production_secret}" \ + --project "${project_id}" \ + --member "serviceAccount:${production_runtime_email}" \ + --role roles/secretmanager.secretAccessor \ + --quiet + +if ! exists "${gcloud_bin}" iam workload-identity-pools describe "${pool_id}" \ + --project "${project_id}" \ + --location global; then + run "${gcloud_bin}" iam workload-identity-pools create "${pool_id}" \ + --project "${project_id}" \ + --location global \ + --display-name "Simulation API GitHub Actions" +fi + +if ! exists "${gcloud_bin}" iam workload-identity-pools providers describe \ + "${provider_id}" \ + --project "${project_id}" \ + --location global \ + --workload-identity-pool "${pool_id}"; then + run "${gcloud_bin}" iam workload-identity-pools providers create-oidc \ + "${provider_id}" \ + --project "${project_id}" \ + --location global \ + --workload-identity-pool "${pool_id}" \ + --display-name "PolicyEngine simulation repository" \ + --issuer-uri "https://token.actions.githubusercontent.com" \ + --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \ + --attribute-condition "assertion.repository == '${github_repository}'" +fi + +if [ "${dry_run}" = "1" ]; then + project_number="000000000000" +else + project_number="$("${gcloud_bin}" projects describe "${project_id}" \ + --format 'value(projectNumber)')" +fi +run "${gcloud_bin}" iam service-accounts add-iam-policy-binding \ + "${deployer_email}" \ + --project "${project_id}" \ + --member "principalSet://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/attribute.repository/${github_repository}" \ + --role roles/iam.workloadIdentityUser \ + --quiet + +provider_name="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${provider_id}" + +printf '\nBootstrap complete. Configure these GitHub values:\n' +printf ' SIMULATION_GCP_PROJECT_ID=%s\n' "${project_id}" +printf ' SIMULATION_GCP_REGION=%s\n' "${region}" +printf ' SIMULATION_ARTIFACT_REPOSITORY=%s\n' "${repository}" +printf ' SIMULATION_GCP_WIF_PROVIDER=%s\n' "${provider_name}" +printf ' SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT=%s\n' "${deployer_email}" +printf ' staging SIMULATION_RUNTIME_SERVICE_ACCOUNT=%s\n' "${staging_runtime_email}" +printf ' production SIMULATION_RUNTIME_SERVICE_ACCOUNT=%s\n' "${production_runtime_email}" +printf '\nAdd one secret version to each empty Secret Manager secret before deploying.\n' diff --git a/.github/scripts/cloud-run-simulation-api-smoke.sh b/.github/scripts/cloud-run-simulation-api-smoke.sh new file mode 100644 index 000000000..23b600d3d --- /dev/null +++ b/.github/scripts/cloud-run-simulation-api-smoke.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -euo pipefail + +base_url="${1:?Simulation API base URL is required}" +base_url="${base_url%/}" + +curl --fail --silent --show-error "${base_url}/health" | + jq -e '.status == "healthy"' >/dev/null +curl --fail --silent --show-error "${base_url}/ready" | + jq -e '.status == "ready"' >/dev/null +curl --fail --silent --show-error "${base_url}/versions" >/dev/null +curl --fail --silent --show-error \ + --request POST \ + --header "Content-Type: application/json" \ + --data '{"value": 1}' \ + "${base_url}/ping" | + jq -e '.incremented == 2' >/dev/null diff --git a/.github/scripts/configure-cloud-run-simulation-api-domains.sh b/.github/scripts/configure-cloud-run-simulation-api-domains.sh new file mode 100644 index 000000000..9fd38b40f --- /dev/null +++ b/.github/scripts/configure-cloud-run-simulation-api-domains.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -euo pipefail + +gcloud_bin="${GCLOUD_BIN:-gcloud}" +project_id="${SIMULATION_GCP_PROJECT_ID:?SIMULATION_GCP_PROJECT_ID is required}" +region="${SIMULATION_GCP_REGION:-us-central1}" +production_domain="${SIMULATION_PRODUCTION_DOMAIN:-simulation.api.policyengine.org}" +staging_domain="${SIMULATION_STAGING_DOMAIN:-staging.simulation.api.policyengine.org}" +dry_run="${SIMULATION_DOMAINS_DRY_RUN:-0}" + +run() { + if [ "${dry_run}" = "1" ]; then + printf '+' + printf ' %q' "$@" + printf '\n' + return 0 + fi + "$@" +} + +exists() { + if [ "${dry_run}" = "1" ]; then + return 1 + fi + "$@" >/dev/null 2>&1 +} + +ensure_mapping() { + local service="${1:?service is required}" + local domain="${2:?domain is required}" + + if ! exists "${gcloud_bin}" beta run domain-mappings describe \ + --project "${project_id}" \ + --region "${region}" \ + --domain "${domain}"; then + run "${gcloud_bin}" beta run domain-mappings create \ + --project "${project_id}" \ + --region "${region}" \ + --service "${service}" \ + --domain "${domain}" + fi +} + +ensure_mapping policyengine-simulation-api-staging "${staging_domain}" +ensure_mapping policyengine-simulation-api "${production_domain}" + +printf '\nInspect the mappings for the DNS records that must be published:\n' +printf ' %s\n' "${staging_domain}" "${production_domain}" diff --git a/.github/workflows/cloud-run-simulation-api.yml b/.github/workflows/cloud-run-simulation-api.yml new file mode 100644 index 000000000..38729ddfa --- /dev/null +++ b/.github/workflows/cloud-run-simulation-api.yml @@ -0,0 +1,208 @@ +name: Deploy Simulation API to Cloud Run + +on: + push: + branches: [main] + paths: + - "projects/policyengine-simulation-api/**" + - "libs/policyengine-fastapi/**" + - "libs/policyengine-simulation-contract/**" + - "libs/policyengine-simulation-observability/**" + - ".github/scripts/cloud-run-simulation-api-smoke.sh" + - ".github/workflows/cloud-run-simulation-api.yml" + workflow_dispatch: + inputs: + promote_production: + description: Promote the tested production candidate + type: boolean + default: false + rollback_revision: + description: Existing production revision to restore to 100% + type: string + required: false + +permissions: + contents: read + id-token: write + +concurrency: + group: cloud-run-simulation-api + cancel-in-progress: false + +env: + PROJECT_DIR: projects/policyengine-simulation-api + REGION: ${{ vars.SIMULATION_GCP_REGION || 'us-central1' }} + PROJECT_ID: ${{ vars.SIMULATION_GCP_PROJECT_ID }} + REPOSITORY: ${{ vars.SIMULATION_ARTIFACT_REPOSITORY || 'policyengine-simulation-api' }} + IMAGE_NAME: policyengine-simulation-api + +jobs: + test: + if: ${{ !inputs.rollback_revision }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v8.1.0 + - run: uv sync --extra test --frozen + working-directory: ${{ env.PROJECT_DIR }} + - run: uv run pytest tests/ -v + working-directory: ${{ env.PROJECT_DIR }} + + build: + if: ${{ !inputs.rollback_revision }} + needs: test + runs-on: ubuntu-latest + outputs: + image: ${{ steps.image.outputs.uri }} + steps: + - uses: actions/checkout@v6 + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + - uses: google-github-actions/setup-gcloud@v2 + - run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet + - id: image + run: echo "uri=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + - run: docker build --file "${PROJECT_DIR}/Dockerfile" --tag "${{ steps.image.outputs.uri }}" . + - run: docker push "${{ steps.image.outputs.uri }}" + + deploy-staging: + if: ${{ !inputs.rollback_revision }} + needs: build + runs-on: ubuntu-latest + environment: staging + outputs: + tag: ${{ steps.metadata.outputs.tag }} + url: ${{ steps.url.outputs.url }} + steps: + - uses: actions/checkout@v6 + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + - uses: google-github-actions/setup-gcloud@v2 + - id: metadata + run: echo "tag=s${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + - name: Deploy tagged staging candidate + env: + IMAGE: ${{ needs.build.outputs.image }} + TAG: ${{ steps.metadata.outputs.tag }} + run: | + gcloud run deploy policyengine-simulation-api-staging \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --image "${IMAGE}" \ + --tag "${TAG}" \ + --no-traffic \ + --allow-unauthenticated \ + --service-account "${{ vars.SIMULATION_RUNTIME_SERVICE_ACCOUNT }}" \ + --set-env-vars "^@^APP_ENVIRONMENT=staging@SIMULATION_API_PUBLIC_URL=https://staging.simulation.api.policyengine.org@SIMULATION_API_AUTH_REQUIRED=1@SIMULATION_API_AUTH_ISSUER=${{ secrets.SIMULATION_API_AUTH_ISSUER }}@SIMULATION_API_AUTH_AUDIENCE=${{ secrets.SIMULATION_API_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-api-old-gateway-client-secret-staging:latest" \ + --cpu 1 \ + --memory 512Mi \ + --concurrency 80 \ + --timeout 60 \ + --min-instances 0 \ + --max-instances 2 + - id: url + env: + TAG: ${{ steps.metadata.outputs.tag }} + run: | + url="$(gcloud run services describe policyengine-simulation-api-staging \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format=json | jq -r --arg tag "${TAG}" '.status.traffic[] | select(.tag == $tag) | .url')" + test -n "${url}" + echo "url=${url}" >> "$GITHUB_OUTPUT" + - run: bash .github/scripts/cloud-run-simulation-api-smoke.sh "${{ steps.url.outputs.url }}" + - name: Promote tested staging tag + run: | + gcloud run services update-traffic policyengine-simulation-api-staging \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --to-tags "${{ steps.metadata.outputs.tag }}=100" + + deploy-production-candidate: + if: ${{ !inputs.rollback_revision }} + needs: [build, deploy-staging] + runs-on: ubuntu-latest + environment: production + outputs: + tag: ${{ steps.metadata.outputs.tag }} + steps: + - uses: actions/checkout@v6 + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + - uses: google-github-actions/setup-gcloud@v2 + - id: metadata + run: echo "tag=p${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + - name: Deploy no-traffic production candidate + env: + IMAGE: ${{ needs.build.outputs.image }} + TAG: ${{ steps.metadata.outputs.tag }} + run: | + gcloud run deploy policyengine-simulation-api \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --image "${IMAGE}" \ + --tag "${TAG}" \ + --no-traffic \ + --allow-unauthenticated \ + --service-account "${{ vars.SIMULATION_RUNTIME_SERVICE_ACCOUNT }}" \ + --set-env-vars "^@^APP_ENVIRONMENT=production@SIMULATION_API_PUBLIC_URL=https://simulation.api.policyengine.org@SIMULATION_API_AUTH_REQUIRED=1@SIMULATION_API_AUTH_ISSUER=${{ secrets.SIMULATION_API_AUTH_ISSUER }}@SIMULATION_API_AUTH_AUDIENCE=${{ secrets.SIMULATION_API_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-api-old-gateway-client-secret-production:latest" \ + --cpu 1 \ + --memory 512Mi \ + --concurrency 80 \ + --timeout 60 \ + --min-instances 1 \ + --max-instances 20 + - name: Resolve and smoke candidate + env: + TAG: ${{ steps.metadata.outputs.tag }} + run: | + url="$(gcloud run services describe policyengine-simulation-api \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format=json | jq -r --arg tag "${TAG}" '.status.traffic[] | select(.tag == $tag) | .url')" + test -n "${url}" + bash .github/scripts/cloud-run-simulation-api-smoke.sh "${url}" + + promote-production: + if: ${{ inputs.promote_production && !inputs.rollback_revision }} + needs: deploy-production-candidate + runs-on: ubuntu-latest + environment: production + steps: + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + - uses: google-github-actions/setup-gcloud@v2 + - run: | + gcloud run services update-traffic policyengine-simulation-api \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --to-tags "${{ needs.deploy-production-candidate.outputs.tag }}=100" + + rollback-production: + if: ${{ inputs.rollback_revision }} + runs-on: ubuntu-latest + environment: production + steps: + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + - uses: google-github-actions/setup-gcloud@v2 + - run: | + gcloud run services update-traffic policyengine-simulation-api \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --to-revisions "${{ inputs.rollback_revision }}=100" diff --git a/projects/policyengine-simulation-api/DEPLOYMENT.md b/projects/policyengine-simulation-api/DEPLOYMENT.md new file mode 100644 index 000000000..eced7fb57 --- /dev/null +++ b/projects/policyengine-simulation-api/DEPLOYMENT.md @@ -0,0 +1,60 @@ +# Cloud Run deployment + +Infrastructure is bootstrapped with gcloud and application revisions are owned +by the dedicated GitHub Actions workflow. + +## One-time bootstrap + +Authenticate as an organization principal that can create projects, attach +billing, and administer IAM. Then run: + + SIMULATION_GCP_PROJECT_ID=policyengine-simulation-api \ + SIMULATION_GCP_BILLING_ACCOUNT=BILLING_ACCOUNT_ID \ + bash .github/scripts/bootstrap-cloud-run-simulation-api.sh + +Set SIMULATION_GCP_FOLDER_ID or SIMULATION_GCP_ORGANIZATION_ID when project +placement is not inherited automatically. + +The script is idempotent. It enables APIs and creates the Artifact Registry +repository, staging/production runtime identities, GitHub deployer identity, +least-privilege IAM bindings, empty Secret Manager secrets, and the dedicated +GitHub workload identity pool/provider. + +Use SIMULATION_BOOTSTRAP_DRY_RUN=1 to print the commands without changing GCP. + +## Secrets and GitHub environments + +Add one version to each secret created by the bootstrap: + +- simulation-api-old-gateway-client-secret-staging +- simulation-api-old-gateway-client-secret-production + +Configure the printed project/WIF values as repository variables or secrets. +Configure SIMULATION_RUNTIME_SERVICE_ACCOUNT separately in the staging and +production GitHub environments. Each environment also supplies its own old +gateway URL/client ID and the shared Auth0 issuer/audiences. + +## Deploy, promote, and roll back + +The Deploy Simulation API to Cloud Run workflow: + +1. tests and builds one immutable image; +2. deploys a tagged no-traffic staging revision and promotes it after smoke + checks; +3. deploys a tagged no-traffic production candidate and smoke-tests it; +4. promotes production only when manually dispatched with + promote_production=true. + +To roll back, manually dispatch the same workflow with rollback_revision set to +the known-good Cloud Run revision name. The workflow restores that revision to +100 percent without rebuilding. + +After the first successful deployment creates both services, configure the +domain mappings idempotently: + + SIMULATION_GCP_PROJECT_ID=policyengine-simulation-api \ + bash .github/scripts/configure-cloud-run-simulation-api-domains.sh + +Inspect the mappings and publish the returned DNS records for +staging.simulation.api.policyengine.org and simulation.api.policyengine.org. +Use SIMULATION_DOMAINS_DRY_RUN=1 to preview this phase. diff --git a/projects/policyengine-simulation-api/Dockerfile b/projects/policyengine-simulation-api/Dockerfile new file mode 100644 index 000000000..d241eb500 --- /dev/null +++ b/projects/policyengine-simulation-api/Dockerfile @@ -0,0 +1,26 @@ +FROM --platform=$BUILDPLATFORM python:3.13-slim AS runtime + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PORT=8080 + +WORKDIR /app + +COPY LICENSE ./LICENSE +COPY libs ./libs/ +COPY projects/policyengine-simulation-api ./projects/policyengine-simulation-api/ + +WORKDIR /app/projects/policyengine-simulation-api +RUN uv sync --frozen --no-dev --no-editable + +RUN addgroup --system simulation-api \ + && adduser --system --ingroup simulation-api simulation-api + +USER simulation-api + +EXPOSE 8080 + +CMD ["/opt/venv/bin/uvicorn", "policyengine_simulation_api.app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/projects/policyengine-simulation-api/Dockerfile.dockerignore b/projects/policyengine-simulation-api/Dockerfile.dockerignore new file mode 100644 index 000000000..89eb521b3 --- /dev/null +++ b/projects/policyengine-simulation-api/Dockerfile.dockerignore @@ -0,0 +1,19 @@ +.git +**/.venv +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +**/*.pyc + +projects/* +!projects/policyengine-simulation-api/ +!projects/policyengine-simulation-api/** +projects/policyengine-simulation-api/artifacts +projects/policyengine-simulation-api/tests + +libs/* +!libs/policyengine-simulation-contract/ +!libs/policyengine-simulation-contract/** +!libs/policyengine-simulation-observability/ +!libs/policyengine-simulation-observability/** +libs/**/tests diff --git a/projects/policyengine-simulation-api/tests/test_deployment_assets.py b/projects/policyengine-simulation-api/tests/test_deployment_assets.py new file mode 100644 index 000000000..0c9842c2a --- /dev/null +++ b/projects/policyengine-simulation-api/tests/test_deployment_assets.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import subprocess +import tomllib +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +BOOTSTRAP_SCRIPT = ( + REPOSITORY_ROOT / ".github" / "scripts" / "bootstrap-cloud-run-simulation-api.sh" +) +DOMAIN_SCRIPT = ( + REPOSITORY_ROOT + / ".github" + / "scripts" + / "configure-cloud-run-simulation-api-domains.sh" +) +WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "cloud-run-simulation-api.yml" +DOCKERIGNORE = ( + REPOSITORY_ROOT + / "projects" + / "policyengine-simulation-api" + / "Dockerfile.dockerignore" +) + + +def test_bootstrap_script_has_valid_shell_syntax(): + subprocess.run(["bash", "-n", BOOTSTRAP_SCRIPT], check=True) + subprocess.run(["bash", "-n", DOMAIN_SCRIPT], check=True) + + +def test_bootstrap_dry_run_is_self_contained(): + environment = { + **os.environ, + "SIMULATION_GCP_PROJECT_ID": "simulation-api-test", + "SIMULATION_BOOTSTRAP_DRY_RUN": "1", + } + + result = subprocess.run( + ["bash", BOOTSTRAP_SCRIPT], + check=True, + capture_output=True, + text=True, + env=environment, + ) + + assert "projects create simulation-api-test" in result.stdout + assert "artifacts repositories create policyengine-simulation-api" in result.stdout + assert "roles/serviceusage.serviceUsageConsumer" in result.stdout + assert "Bootstrap complete" in result.stdout + assert "000000000000" in result.stdout + + +def test_deployment_uses_gcloud_workflow_without_terraform(): + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "gcloud run deploy" in workflow + assert "gcloud run services update-traffic" in workflow + assert "terraform" not in workflow.lower() + assert not list( + (REPOSITORY_ROOT / "projects" / "policyengine-simulation-api" / "infra").glob( + "*.tf" + ) + ) + + +def test_container_context_excludes_local_environments_and_unrelated_projects(): + ignore_rules = DOCKERIGNORE.read_text(encoding="utf-8") + + assert "**/.venv" in ignore_rules + assert "projects/*" in ignore_rules + assert "!projects/policyengine-simulation-api/**" in ignore_rules + assert "libs/*" in ignore_rules + + +def test_production_lock_excludes_modal_and_database_runtime_packages(): + lockfile = tomllib.loads( + ( + REPOSITORY_ROOT / "projects" / "policyengine-simulation-api" / "uv.lock" + ).read_text(encoding="utf-8") + ) + resolved_packages = {package["name"] for package in lockfile["package"]} + + for package in ("modal", "policyengine-fastapi", "sqlalchemy", "sqlmodel"): + assert package not in resolved_packages + + +def test_domain_mapping_dry_run_targets_both_services(): + result = subprocess.run( + ["bash", DOMAIN_SCRIPT], + check=True, + capture_output=True, + text=True, + env={ + **os.environ, + "SIMULATION_GCP_PROJECT_ID": "simulation-api-test", + "SIMULATION_DOMAINS_DRY_RUN": "1", + }, + ) + + assert "policyengine-simulation-api-staging" in result.stdout + assert "staging.simulation.api.policyengine.org" in result.stdout + assert "policyengine-simulation-api" in result.stdout + assert "simulation.api.policyengine.org" in result.stdout From 1c15d59512491f121b5101330928bbc0f8cf6082 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:57:52 +0300 Subject: [PATCH 03/11] build: publish clients from Cloud Run simulation API --- .github/workflows/pr.yml | 3 ++- .github/workflows/publish-clients.yml | 8 ++++---- Makefile | 1 + projects/policyengine-apis-integ/pyproject.toml | 2 +- projects/policyengine-apis-integ/uv.lock | 4 ++-- scripts/generate-clients.sh | 8 ++++---- scripts/publish-clients.sh | 2 +- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 90e95d4c9..40122376b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,7 @@ jobs: # Modal image installs with uv_sync(frozen=True). project: - projects/policyengine-simulation-executor + - projects/policyengine-simulation-api - projects/policyengine-simulation-gateway - libs/policyengine-simulation-contract - libs/policyengine-simulation-observability @@ -75,7 +76,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - service: [simulation-executor] + service: [simulation-api, simulation-executor] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/publish-clients.yml b/.github/workflows/publish-clients.yml index 1f7667ea2..967668d72 100644 --- a/.github/workflows/publish-clients.yml +++ b/.github/workflows/publish-clients.yml @@ -2,7 +2,7 @@ name: Publish API clients on: workflow_run: - workflows: ["Deploy Simulation API to Modal"] + workflows: ["Deploy Simulation API to Cloud Run"] types: - completed branches: @@ -13,7 +13,7 @@ jobs: publish: name: Publish API clients runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }} steps: - name: Checkout repo @@ -33,7 +33,7 @@ jobs: - name: Build simulation API client run: | - cd projects/policyengine-simulation-gateway/artifacts/clients/python + cd projects/policyengine-simulation-api/artifacts/clients/python # Update version DATE=$(date +%Y%m%d) RUN_NUMBER="${{ github.run_number }}" @@ -45,4 +45,4 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI }} - packages-dir: projects/policyengine-simulation-gateway/artifacts/clients/python/dist/ + packages-dir: projects/policyengine-simulation-api/artifacts/clients/python/dist/ diff --git a/Makefile b/Makefile index c54ad07ee..b9e7e28da 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,7 @@ publish-clients: generate-clients test: @echo "Running unit tests for all projects and libs..." @for proj in projects/policyengine-simulation-executor \ + projects/policyengine-simulation-api \ projects/policyengine-simulation-gateway \ libs/policyengine-simulation-contract \ libs/policyengine-simulation-observability; do \ diff --git a/projects/policyengine-apis-integ/pyproject.toml b/projects/policyengine-apis-integ/pyproject.toml index eb54ad383..c19a67099 100644 --- a/projects/policyengine-apis-integ/pyproject.toml +++ b/projects/policyengine-apis-integ/pyproject.toml @@ -27,7 +27,7 @@ markers = [ ] [tool.uv.sources] -policyengine_api_simulation_client = { path = "../policyengine-simulation-gateway/artifacts/clients/python" } +policyengine_api_simulation_client = { path = "../policyengine-simulation-api/artifacts/clients/python" } [tool.pyright] #The generated clients do not do the "public export" convention diff --git a/projects/policyengine-apis-integ/uv.lock b/projects/policyengine-apis-integ/uv.lock index 1f867d4a6..2e7c22088 100644 --- a/projects/policyengine-apis-integ/uv.lock +++ b/projects/policyengine-apis-integ/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "backoff", marker = "extra == 'test'", specifier = ">=2.2.1" }, { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, - { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-gateway/artifacts/clients/python" }, + { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-api/artifacts/clients/python" }, { name = "pydantic-settings", specifier = ">=2.8.1,<3.0.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", specifier = ">=8.3.4,<9.0.0" }, @@ -263,7 +263,7 @@ test = [{ name = "backoff", specifier = ">=2.2.1" }] [[package]] name = "policyengine-api-simulation-client" version = "1.0.0" -source = { directory = "../policyengine-simulation-gateway/artifacts/clients/python" } +source = { directory = "../policyengine-simulation-api/artifacts/clients/python" } dependencies = [ { name = "attrs" }, { name = "httpx" }, diff --git a/scripts/generate-clients.sh b/scripts/generate-clients.sh index f0aa95718..d2112f526 100755 --- a/scripts/generate-clients.sh +++ b/scripts/generate-clients.sh @@ -19,9 +19,9 @@ generate_client() { # Install build dependencies if not already installed uv sync --extra build - # Use gateway's OpenAPI generator for simulation service (Modal-based) + # The permanent Cloud Run Simulation API owns the public schema. if [ "$SERVICE" = "simulation" ]; then - uv run python -m policyengine_simulation_gateway.generate_openapi + uv run python -m policyengine_simulation_api.generate_openapi else uv run python -m policyengine_api_${SERVICE//-/_}.generate_openapi fi @@ -60,8 +60,8 @@ generate_client() { cd ../.. } -# Generate client for simulation service (Modal gateway) -generate_client "simulation" "projects/policyengine-simulation-gateway" +# Generate the client from the permanent Cloud Run front door. +generate_client "simulation" "projects/policyengine-simulation-api" echo "✅ Client generated successfully!" echo "" diff --git a/scripts/publish-clients.sh b/scripts/publish-clients.sh index 518b4fbc9..20c7060e7 100755 --- a/scripts/publish-clients.sh +++ b/scripts/publish-clients.sh @@ -12,7 +12,7 @@ publish_client() { case "$SERVICE" in simulation) - CLIENT_DIR="projects/policyengine-simulation-gateway/artifacts/clients/python" + CLIENT_DIR="projects/policyengine-simulation-api/artifacts/clients/python" ;; *) echo "❌ Unsupported API client service: ${SERVICE}" From b10fbc759c19e2251f3cc9f0ceb558e4db030c80 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 19:00:29 +0300 Subject: [PATCH 04/11] feat: add simulation API migration telemetry --- .../src/policyengine_simulation_api/app.py | 19 ++++++++++++++ .../src/policyengine_simulation_api/auth.py | 6 ++++- .../tests/test_app.py | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py index 80f74e177..bcc853cb0 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import time import uuid @@ -118,6 +119,7 @@ async def forward( path: str, body: Mapping[str, Any] | None = None, ) -> Response: + backend_started = time.monotonic() try: result = await runtime_backend.request( method, @@ -125,6 +127,23 @@ async def forward( json_body=body, request_id=request.state.request_id, ) + attributes: dict[str, Any] = { + "request_id": request.state.request_id, + "route": path, + "method": method, + "status_code": result.status_code, + "elapsed_ms": round( + (time.monotonic() - backend_started) * 1000, + 2, + ), + } + try: + job_state = json.loads(result.content).get("status") + except (AttributeError, json.JSONDecodeError, UnicodeDecodeError): + job_state = None + if isinstance(job_state, str): + attributes["job_state"] = job_state + record_event("simulation_api_backend_response", **attributes) return _response(result) except BackendTimeout: record_event( diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py index fe2398c8a..466c3ba99 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py @@ -8,6 +8,7 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import jwt +from policyengine_observability import record_event from policyengine_simulation_api.config import Settings @@ -29,6 +30,7 @@ def __call__( token: HTTPAuthorizationCredentials | None, ) -> dict[str, str]: if token is None: + record_event("simulation_api_auth_rejected", reason="missing_token") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) try: @@ -43,10 +45,12 @@ def __call__( issuer=self.issuer, ) except Exception as error: + reason = type(error).__name__ logger.info( "invalid_simulation_api_bearer_token", - extra={"error_type": type(error).__name__}, + extra={"error_type": reason}, ) + record_event("simulation_api_auth_rejected", reason=reason) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from error diff --git a/projects/policyengine-simulation-api/tests/test_app.py b/projects/policyengine-simulation-api/tests/test_app.py index c7757c8cf..150cea9ed 100644 --- a/projects/policyengine-simulation-api/tests/test_app.py +++ b/projects/policyengine-simulation-api/tests/test_app.py @@ -4,6 +4,7 @@ import pytest +from policyengine_simulation_api import app as app_module from policyengine_simulation_api.app import create_app from policyengine_simulation_api.backend import ( BackendResponse, @@ -79,6 +80,31 @@ def test_job_status_preserves_id_and_status(client, backend): assert backend.requests[-1]["path"] == "/jobs/fc-123" +def test_job_status_records_sanitized_backend_telemetry( + client, + backend, + monkeypatch, +): + events = [] + monkeypatch.setattr( + app_module, + "record_event", + lambda name, **attributes: events.append((name, attributes)), + ) + backend.responses[("GET", "/jobs/fc-123")] = response( + 202, + {"status": "running", "job_id": "must-not-be-recorded"}, + ) + + client.get("/jobs/fc-123") + + name, attributes = events[-1] + assert name == "simulation_api_backend_response" + assert attributes["job_state"] == "running" + assert attributes["status_code"] == 202 + assert "job_id" not in attributes + + def test_budget_window_routes_use_original_batch_id(client, backend): backend.responses[("POST", "/simulate/economy/budget-window")] = response( 202, From b0653984ffa02fc538641f3974a68fd8c501568c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 00:59:36 +0300 Subject: [PATCH 05/11] fix: close simulation API stage 5 gaps --- .../src/policyengine_simulation_api/app.py | 122 ++++++++++++++++-- .../tests/test_app.py | 6 +- .../tests/test_openapi.py | 17 ++- 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py index bcc853cb0..430a17022 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py @@ -40,6 +40,9 @@ logger = logging.getLogger(__name__) +BACKEND_RESPONSE_HEADER = { + "X-PolicyEngine-Simulation-Backend": "old_gateway", +} def _model_json(model: Any) -> Mapping[str, Any]: @@ -49,7 +52,7 @@ def _model_json(model: Any) -> Mapping[str, Any]: def _response(result: BackendResponse) -> Response: headers = { **result.headers, - "X-PolicyEngine-Simulation-Backend": "old_gateway", + **BACKEND_RESPONSE_HEADER, } return Response( content=result.content, @@ -118,8 +121,13 @@ async def forward( method: str, path: str, body: Mapping[str, Any] | None = None, + *, + identifiers: Mapping[str, str] | None = None, + response_identifier_key: str | None = None, ) -> Response: backend_started = time.monotonic() + route = getattr(request.scope.get("route"), "path", request.url.path) + event_identifiers = dict(identifiers or {}) try: result = await runtime_backend.request( method, @@ -129,52 +137,83 @@ async def forward( ) attributes: dict[str, Any] = { "request_id": request.state.request_id, - "route": path, + "route": route, "method": method, "status_code": result.status_code, "elapsed_ms": round( (time.monotonic() - backend_started) * 1000, 2, ), + **event_identifiers, } try: - job_state = json.loads(result.content).get("status") + response_payload = json.loads(result.content) except (AttributeError, json.JSONDecodeError, UnicodeDecodeError): - job_state = None + response_payload = None + job_state = ( + response_payload.get("status") + if isinstance(response_payload, dict) + else None + ) if isinstance(job_state, str): attributes["job_state"] = job_state + response_identifier = ( + response_payload.get(response_identifier_key) + if isinstance(response_payload, dict) and response_identifier_key + else None + ) + if response_identifier_key and isinstance(response_identifier, str): + attributes[response_identifier_key] = response_identifier record_event("simulation_api_backend_response", **attributes) return _response(result) except BackendTimeout: record_event( "simulation_api_backend_timeout", request_id=request.state.request_id, - route=path, + route=route, + **event_identifiers, ) return JSONResponse( status_code=504, content={"detail": "Simulation backend timed out."}, - headers={"Retry-After": "10"}, + headers={ + "Retry-After": "10", + **BACKEND_RESPONSE_HEADER, + }, ) except (BackendUnavailable, BackendAuthenticationError): record_event( "simulation_api_backend_unavailable", request_id=request.state.request_id, - route=path, + route=route, + **event_identifiers, ) return JSONResponse( status_code=503, content={"detail": "Simulation backend is unavailable."}, - headers={"Retry-After": "10"}, + headers={ + "Retry-After": "10", + **BACKEND_RESPONSE_HEADER, + }, ) protected = [Depends(authenticate)] @app.post( "/simulate/economy/comparison", + summary="Submit Simulation", + description=( + "Submit a simulation job.\n\n" + "Routes to the appropriate simulation app based on country and version.\n" + "Returns immediately with a job_id for polling." + ), operation_id="submit_simulation_simulate_economy_comparison_post", response_model=JobSubmitResponse, response_model_exclude_none=True, + responses={ + 200: {"description": "Job submitted successfully"}, + 400: {"description": "Invalid request (unknown country/version)"}, + }, dependencies=protected, ) async def submit_comparison( @@ -186,13 +225,23 @@ async def submit_comparison( "POST", "/simulate/economy/comparison", _model_json(body), + response_identifier_key="job_id", ) @app.post( "/simulate/economy/budget-window", + summary="Submit Budget Window Batch", + description=( + "Submit a budget-window batch job.\n\n" + "Returns immediately with a parent batch job ID for polling." + ), operation_id=("submit_budget_window_batch_simulate_economy_budget_window_post"), response_model=BudgetWindowBatchSubmitResponse, response_model_exclude_none=True, + responses={ + 200: {"description": "Budget-window batch submitted successfully"}, + 400: {"description": "Invalid request (unknown country/version/year)"}, + }, dependencies=protected, ) async def submit_budget_window( @@ -204,25 +253,57 @@ async def submit_budget_window( "POST", "/simulate/economy/budget-window", _model_json(body), + response_identifier_key="batch_job_id", ) @app.get( "/jobs/{job_id}", + summary="Get Job Status", + description=( + "Poll for job status.\n\n" + "Returns:\n" + ' - 200 with status="complete" and result when done\n' + ' - 202 with status="running" while in progress\n' + " - 404 if job_id not found\n" + ' - 500 with status="failed" and error on failure' + ), operation_id="get_job_status_jobs__job_id__get", response_model=JobStatusResponse, response_model_exclude_none=True, + responses={ + 200: {"description": "Job complete", "model": JobStatusResponse}, + 202: {"description": "Job still running"}, + 404: {"description": "Job not found"}, + 500: {"description": "Job failed"}, + }, dependencies=protected, ) async def get_job(job_id: str, request: Request) -> Response: - return await forward(request, "GET", f"/jobs/{job_id}") + return await forward( + request, + "GET", + f"/jobs/{job_id}", + identifiers={"job_id": job_id}, + ) @app.get( "/budget-window-jobs/{batch_job_id}", + summary="Get Budget Window Job Status", + description="Poll for budget-window batch status.", operation_id=( "get_budget_window_job_status_budget_window_jobs__batch_job_id__get" ), response_model=BudgetWindowBatchStatusResponse, response_model_exclude_none=True, + responses={ + 200: { + "description": "Batch complete", + "model": BudgetWindowBatchStatusResponse, + }, + 202: {"description": "Batch submitted or running"}, + 404: {"description": "Batch job not found"}, + 500: {"description": "Batch failed"}, + }, dependencies=protected, ) async def get_budget_window_job( @@ -233,21 +314,33 @@ async def get_budget_window_job( request, "GET", f"/budget-window-jobs/{batch_job_id}", + identifiers={"batch_job_id": batch_job_id}, ) - @app.get("/versions", operation_id="list_versions_versions_get") - async def versions(request: Request) -> Response: + @app.get( + "/versions", + summary="List Versions", + description="List all available routing versions.", + operation_id="list_versions_versions_get", + ) + async def list_versions(request: Request) -> dict: return await forward(request, "GET", "/versions") @app.get( "/versions/{kind}", + summary="Get Country Versions", + description="Get available versions for policyengine, US, or UK routing.", operation_id="get_country_versions_versions__kind__get", ) - async def versions_by_kind(kind: str, request: Request) -> Response: + async def get_country_versions(kind: str, request: Request) -> dict: return await forward(request, "GET", f"/versions/{kind}") - @app.get("/health", operation_id="health_health_get") - async def health() -> dict[str, str]: + @app.get( + "/health", + description="Health check endpoint.", + operation_id="health_health_get", + ) + async def health() -> dict: return {"status": "healthy"} @app.get("/ready") @@ -258,6 +351,7 @@ async def ready() -> Response: @app.post( "/ping", + description="Verify the API is able to receive and process requests.", operation_id="ping_ping_post", response_model=PingResponse, ) diff --git a/projects/policyengine-simulation-api/tests/test_app.py b/projects/policyengine-simulation-api/tests/test_app.py index 150cea9ed..0b677ed1a 100644 --- a/projects/policyengine-simulation-api/tests/test_app.py +++ b/projects/policyengine-simulation-api/tests/test_app.py @@ -80,7 +80,7 @@ def test_job_status_preserves_id_and_status(client, backend): assert backend.requests[-1]["path"] == "/jobs/fc-123" -def test_job_status_records_sanitized_backend_telemetry( +def test_job_status_records_structured_backend_telemetry( client, backend, monkeypatch, @@ -102,7 +102,8 @@ def test_job_status_records_sanitized_backend_telemetry( assert name == "simulation_api_backend_response" assert attributes["job_state"] == "running" assert attributes["status_code"] == 202 - assert "job_id" not in attributes + assert attributes["route"] == "/jobs/{job_id}" + assert attributes["job_id"] == "fc-123" def test_budget_window_routes_use_original_batch_id(client, backend): @@ -191,6 +192,7 @@ async def request(self, *args, **kwargs): assert result.status_code == expected_status assert result.json() == {"detail": expected_detail} assert result.headers["retry-after"] == "10" + assert result.headers["x-policyengine-simulation-backend"] == "old_gateway" @pytest.mark.parametrize("status_code", [400, 404, 409, 500]) diff --git a/projects/policyengine-simulation-api/tests/test_openapi.py b/projects/policyengine-simulation-api/tests/test_openapi.py index 0e979cb12..c54bf2f73 100644 --- a/projects/policyengine-simulation-api/tests/test_openapi.py +++ b/projects/policyengine-simulation-api/tests/test_openapi.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy import json from pathlib import Path @@ -37,6 +38,17 @@ def operation_ids(spec: dict) -> dict[tuple[str, str], str]: } +def normalized_compatibility_paths(spec: dict) -> dict: + """Remove the two intentional Cloud Run-only OpenAPI additions.""" + paths = deepcopy(spec["paths"]) + paths.pop("/ready", None) + for operations in paths.values(): + for method, operation in operations.items(): + if method.lower() in {"get", "post", "put", "patch", "delete"}: + operation.pop("security", None) + return paths + + def test_route_table_is_frozen(): assert methods(create_app().openapi()) == EXPECTED_ROUTES @@ -55,7 +67,7 @@ def test_old_gateway_routes_are_all_present(): assert methods(gateway_spec) <= cloud_run_routes -def test_normalized_contract_schemas_match_old_gateway(): +def test_normalized_contract_matches_old_gateway(): gateway_spec_path = ( Path(__file__).resolve().parents[2] / "policyengine-simulation-gateway" @@ -66,6 +78,9 @@ def test_normalized_contract_schemas_match_old_gateway(): gateway_spec = json.loads(gateway_spec_path.read_text()) cloud_run_spec = create_app().openapi() + assert normalized_compatibility_paths( + cloud_run_spec + ) == normalized_compatibility_paths(gateway_spec) assert ( cloud_run_spec["components"]["schemas"] == gateway_spec["components"]["schemas"] ) From c2b6c2c1198f4d670f164c9bdf63f671ecb08036 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 01:14:44 +0300 Subject: [PATCH 06/11] fix: keep Cloud Run traffic changes manual --- .../set-cloud-run-simulation-api-revision.sh | 62 +++++++++++++++++++ .../workflows/cloud-run-simulation-api.yml | 52 ---------------- .../policyengine-simulation-api/DEPLOYMENT.md | 27 +++++--- .../tests/test_deployment_assets.py | 34 +++++++++- 4 files changed, 112 insertions(+), 63 deletions(-) create mode 100755 .github/scripts/set-cloud-run-simulation-api-revision.sh diff --git a/.github/scripts/set-cloud-run-simulation-api-revision.sh b/.github/scripts/set-cloud-run-simulation-api-revision.sh new file mode 100755 index 000000000..608a5d4a4 --- /dev/null +++ b/.github/scripts/set-cloud-run-simulation-api-revision.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +# Operator-run production/staging command. GitHub Actions intentionally never +# invokes this script or changes Cloud Run traffic. + +set -euo pipefail + +gcloud_bin="${GCLOUD_BIN:-gcloud}" +project_id="${SIMULATION_GCP_PROJECT_ID:?SIMULATION_GCP_PROJECT_ID is required}" +region="${SIMULATION_GCP_REGION:-us-central1}" +environment="${SIMULATION_DEPLOYMENT_ENVIRONMENT:?SIMULATION_DEPLOYMENT_ENVIRONMENT is required}" +revision="${SIMULATION_TARGET_REVISION:?SIMULATION_TARGET_REVISION is required}" +dry_run="${SIMULATION_TRAFFIC_DRY_RUN:-0}" + +case "${environment}" in + staging) + service="policyengine-simulation-api-staging" + ;; + production) + service="policyengine-simulation-api" + ;; + *) + printf 'SIMULATION_DEPLOYMENT_ENVIRONMENT must be staging or production\n' >&2 + exit 2 + ;; +esac + +if [ "${dry_run}" = "1" ]; then + printf '+ %q' "${gcloud_bin}" + printf ' %q' run services update-traffic "${service}" \ + --project "${project_id}" \ + --region "${region}" \ + --to-revisions "${revision}=100" + printf '\n' + exit 0 +fi + +revision_json="$("${gcloud_bin}" run revisions describe "${revision}" \ + --project "${project_id}" \ + --region "${region}" \ + --format=json)" +revision_service="$(jq -r \ + '.metadata.labels["serving.knative.dev/service"] // empty' \ + <<<"${revision_json}")" + +if [ "${revision_service}" != "${service}" ]; then + printf 'Revision %s belongs to %s, not %s\n' \ + "${revision}" "${revision_service:-an unknown service}" "${service}" >&2 + exit 2 +fi + +if ! jq -e \ + '.status.conditions[]? | select(.type == "Ready" and .status == "True")' \ + >/dev/null <<<"${revision_json}"; then + printf 'Revision %s is not Ready\n' "${revision}" >&2 + exit 2 +fi + +"${gcloud_bin}" run services update-traffic "${service}" \ + --project "${project_id}" \ + --region "${region}" \ + --to-revisions "${revision}=100" diff --git a/.github/workflows/cloud-run-simulation-api.yml b/.github/workflows/cloud-run-simulation-api.yml index 38729ddfa..a9ed93f2d 100644 --- a/.github/workflows/cloud-run-simulation-api.yml +++ b/.github/workflows/cloud-run-simulation-api.yml @@ -11,15 +11,6 @@ on: - ".github/scripts/cloud-run-simulation-api-smoke.sh" - ".github/workflows/cloud-run-simulation-api.yml" workflow_dispatch: - inputs: - promote_production: - description: Promote the tested production candidate - type: boolean - default: false - rollback_revision: - description: Existing production revision to restore to 100% - type: string - required: false permissions: contents: read @@ -38,7 +29,6 @@ env: jobs: test: - if: ${{ !inputs.rollback_revision }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -52,7 +42,6 @@ jobs: working-directory: ${{ env.PROJECT_DIR }} build: - if: ${{ !inputs.rollback_revision }} needs: test runs-on: ubuntu-latest outputs: @@ -71,7 +60,6 @@ jobs: - run: docker push "${{ steps.image.outputs.uri }}" deploy-staging: - if: ${{ !inputs.rollback_revision }} needs: build runs-on: ubuntu-latest environment: staging @@ -119,15 +107,8 @@ jobs: test -n "${url}" echo "url=${url}" >> "$GITHUB_OUTPUT" - run: bash .github/scripts/cloud-run-simulation-api-smoke.sh "${{ steps.url.outputs.url }}" - - name: Promote tested staging tag - run: | - gcloud run services update-traffic policyengine-simulation-api-staging \ - --project "${PROJECT_ID}" \ - --region "${REGION}" \ - --to-tags "${{ steps.metadata.outputs.tag }}=100" deploy-production-candidate: - if: ${{ !inputs.rollback_revision }} needs: [build, deploy-staging] runs-on: ubuntu-latest environment: production @@ -173,36 +154,3 @@ jobs: --format=json | jq -r --arg tag "${TAG}" '.status.traffic[] | select(.tag == $tag) | .url')" test -n "${url}" bash .github/scripts/cloud-run-simulation-api-smoke.sh "${url}" - - promote-production: - if: ${{ inputs.promote_production && !inputs.rollback_revision }} - needs: deploy-production-candidate - runs-on: ubuntu-latest - environment: production - steps: - - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} - service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 - - run: | - gcloud run services update-traffic policyengine-simulation-api \ - --project "${PROJECT_ID}" \ - --region "${REGION}" \ - --to-tags "${{ needs.deploy-production-candidate.outputs.tag }}=100" - - rollback-production: - if: ${{ inputs.rollback_revision }} - runs-on: ubuntu-latest - environment: production - steps: - - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} - service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 - - run: | - gcloud run services update-traffic policyengine-simulation-api \ - --project "${PROJECT_ID}" \ - --region "${REGION}" \ - --to-revisions "${{ inputs.rollback_revision }}=100" diff --git a/projects/policyengine-simulation-api/DEPLOYMENT.md b/projects/policyengine-simulation-api/DEPLOYMENT.md index eced7fb57..a33e2392b 100644 --- a/projects/policyengine-simulation-api/DEPLOYMENT.md +++ b/projects/policyengine-simulation-api/DEPLOYMENT.md @@ -34,20 +34,27 @@ Configure SIMULATION_RUNTIME_SERVICE_ACCOUNT separately in the staging and production GitHub environments. Each environment also supplies its own old gateway URL/client ID and the shared Auth0 issuer/audiences. -## Deploy, promote, and roll back +## Deploy candidates The Deploy Simulation API to Cloud Run workflow: 1. tests and builds one immutable image; -2. deploys a tagged no-traffic staging revision and promotes it after smoke - checks; -3. deploys a tagged no-traffic production candidate and smoke-tests it; -4. promotes production only when manually dispatched with - promote_production=true. - -To roll back, manually dispatch the same workflow with rollback_revision set to -the known-good Cloud Run revision name. The workflow restores that revision to -100 percent without rebuilding. +2. deploys and smoke-tests a tagged no-traffic staging revision; +3. deploys and smoke-tests a tagged no-traffic production revision. + +The workflow never changes a service's traffic. After reviewing its smoke +evidence, an authorized operator promotes an exact known-good revision manually: + + SIMULATION_GCP_PROJECT_ID=policyengine-simulation-api \ + SIMULATION_DEPLOYMENT_ENVIRONMENT=staging \ + SIMULATION_TARGET_REVISION=REVISION_NAME \ + bash .github/scripts/set-cloud-run-simulation-api-revision.sh + +Use `SIMULATION_DEPLOYMENT_ENVIRONMENT=production` for production. Rollback uses +the same command with the prior known-good revision. The script verifies that +the revision is Ready and belongs to the selected service before assigning it +100 percent. Use `SIMULATION_TRAFFIC_DRY_RUN=1` to inspect the command without +changing traffic. After the first successful deployment creates both services, configure the domain mappings idempotently: diff --git a/projects/policyengine-simulation-api/tests/test_deployment_assets.py b/projects/policyengine-simulation-api/tests/test_deployment_assets.py index 0c9842c2a..b0522e8e6 100644 --- a/projects/policyengine-simulation-api/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-api/tests/test_deployment_assets.py @@ -16,6 +16,9 @@ / "scripts" / "configure-cloud-run-simulation-api-domains.sh" ) +TRAFFIC_SCRIPT = ( + REPOSITORY_ROOT / ".github" / "scripts" / "set-cloud-run-simulation-api-revision.sh" +) WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "cloud-run-simulation-api.yml" DOCKERIGNORE = ( REPOSITORY_ROOT @@ -28,6 +31,7 @@ def test_bootstrap_script_has_valid_shell_syntax(): subprocess.run(["bash", "-n", BOOTSTRAP_SCRIPT], check=True) subprocess.run(["bash", "-n", DOMAIN_SCRIPT], check=True) + subprocess.run(["bash", "-n", TRAFFIC_SCRIPT], check=True) def test_bootstrap_dry_run_is_self_contained(): @@ -56,7 +60,8 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): workflow = WORKFLOW.read_text(encoding="utf-8") assert "gcloud run deploy" in workflow - assert "gcloud run services update-traffic" in workflow + assert "update-traffic" not in workflow + assert "set-cloud-run-simulation-api-revision.sh" not in workflow assert "terraform" not in workflow.lower() assert not list( (REPOSITORY_ROOT / "projects" / "policyengine-simulation-api" / "infra").glob( @@ -65,6 +70,33 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): ) +def test_traffic_changes_are_operator_run_and_revision_validated(): + script = TRAFFIC_SCRIPT.read_text(encoding="utf-8") + + assert "run revisions describe" in script + assert 'select(.type == "Ready" and .status == "True")' in script + assert '["serving.knative.dev/service"]' in script + assert 'run services update-traffic "${service}"' in script + assert '--to-revisions "${revision}=100"' in script + + result = subprocess.run( + ["bash", TRAFFIC_SCRIPT], + check=True, + capture_output=True, + text=True, + env={ + **os.environ, + "SIMULATION_GCP_PROJECT_ID": "simulation-api-test", + "SIMULATION_DEPLOYMENT_ENVIRONMENT": "production", + "SIMULATION_TARGET_REVISION": "policyengine-simulation-api-00001-abc", + "SIMULATION_TRAFFIC_DRY_RUN": "1", + }, + ) + + assert "policyengine-simulation-api-00001-abc=100" in result.stdout + assert "policyengine-simulation-api-staging" not in result.stdout + + def test_container_context_excludes_local_environments_and_unrelated_projects(): ignore_rules = DOCKERIGNORE.read_text(encoding="utf-8") From a1d1334ba47e99f930f3c613b87b67b35116a3cc Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 01:18:28 +0300 Subject: [PATCH 07/11] fix: structure request job correlation --- .../src/policyengine_simulation_api/app.py | 17 +++++++++-- .../tests/test_app.py | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py index 430a17022..aff9d6779 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py +++ b/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py @@ -61,6 +61,18 @@ def _response(result: BackendResponse) -> Response: ) +def _route_template(request: Request) -> str: + return getattr(request.scope.get("route"), "path", request.url.path) + + +def _request_identifiers(request: Request) -> dict[str, str]: + return { + key: value + for key in ("job_id", "batch_job_id") + if isinstance((value := request.path_params.get(key)), str) + } + + def create_app( *, settings: Settings | None = None, @@ -108,10 +120,11 @@ async def request_context(request: Request, call_next): extra={ "request_id": request_id, "method": request.method, - "path": request.url.path, + "path": _route_template(request), "status_code": response.status_code, "elapsed_ms": elapsed_ms, "backend": "old_gateway", + **_request_identifiers(request), }, ) return response @@ -126,7 +139,7 @@ async def forward( response_identifier_key: str | None = None, ) -> Response: backend_started = time.monotonic() - route = getattr(request.scope.get("route"), "path", request.url.path) + route = _route_template(request) event_identifiers = dict(identifiers or {}) try: result = await runtime_backend.request( diff --git a/projects/policyengine-simulation-api/tests/test_app.py b/projects/policyengine-simulation-api/tests/test_app.py index 0b677ed1a..abfce6fb9 100644 --- a/projects/policyengine-simulation-api/tests/test_app.py +++ b/projects/policyengine-simulation-api/tests/test_app.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import pytest @@ -106,6 +107,33 @@ def test_job_status_records_structured_backend_telemetry( assert attributes["job_id"] == "fc-123" +def test_request_log_templates_route_and_keeps_structured_job_id( + client, + backend, + caplog, +): + backend.responses[("GET", "/jobs/fc-123")] = BackendResponse( + 202, + b'{"job_id":"fc-123","status":"running"}', + {"content-type": "application/json"}, + ) + + with caplog.at_level(logging.INFO): + result = client.get( + "/jobs/fc-123", + headers={"Authorization": "Bearer caller"}, + ) + + assert result.status_code == 202 + request_record = next( + record + for record in caplog.records + if record.getMessage() == "simulation_api_request" + ) + assert request_record.path == "/jobs/{job_id}" + assert request_record.job_id == "fc-123" + + def test_budget_window_routes_use_original_batch_id(client, backend): backend.responses[("POST", "/simulate/economy/budget-window")] = response( 202, From 484d96a1e013c68caac1a37cc4111c4a0ef86c53 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 17:28:36 +0300 Subject: [PATCH 08/11] refactor: rename simulation API to entrypoint --- ...tstrap-cloud-run-simulation-entrypoint.sh} | 62 ++++++++--------- ... cloud-run-simulation-entrypoint-smoke.sh} | 2 +- ...loud-run-simulation-entrypoint-domains.sh} | 14 ++-- ...oud-run-simulation-entrypoint-revision.sh} | 16 ++--- ...ml => cloud-run-simulation-entrypoint.yml} | 56 ++++++++-------- .github/workflows/pr.yml | 4 +- .github/workflows/publish-clients.yml | 8 +-- Makefile | 2 +- .../policyengine-apis-integ/pyproject.toml | 2 +- projects/policyengine-apis-integ/uv.lock | 4 +- .../policyengine-simulation-api/Dockerfile | 26 ------- .../policyengine_simulation_api/__init__.py | 5 -- .../DEPLOYMENT.md | 36 +++++----- .../Dockerfile | 26 +++++++ .../Dockerfile.dockerignore | 8 +-- .../README.md | 8 +-- .../openapi-python-client.yaml | 0 .../pyproject.toml | 4 +- .../__init__.py | 5 ++ .../app.py | 22 +++--- .../auth.py | 10 +-- .../backend.py | 4 +- .../config.py | 16 +++-- .../generate_openapi.py | 4 +- .../tests/conftest.py | 10 +-- .../tests/test_app.py | 10 +-- .../tests/test_auth.py | 8 +-- .../tests/test_backend.py | 2 +- .../tests/test_config.py | 2 +- .../tests/test_deployment_assets.py | 67 ++++++++++++------- .../tests/test_openapi.py | 2 +- .../uv.lock | 2 +- .../Dockerfile | 6 +- scripts/generate-clients.sh | 8 +-- scripts/publish-clients.sh | 2 +- 35 files changed, 241 insertions(+), 222 deletions(-) rename .github/scripts/{bootstrap-cloud-run-simulation-api.sh => bootstrap-cloud-run-simulation-entrypoint.sh} (72%) rename .github/scripts/{cloud-run-simulation-api-smoke.sh => cloud-run-simulation-entrypoint-smoke.sh} (89%) rename .github/scripts/{configure-cloud-run-simulation-api-domains.sh => configure-cloud-run-simulation-entrypoint-domains.sh} (60%) rename .github/scripts/{set-cloud-run-simulation-api-revision.sh => set-cloud-run-simulation-entrypoint-revision.sh} (67%) rename .github/workflows/{cloud-run-simulation-api.yml => cloud-run-simulation-entrypoint.yml} (59%) delete mode 100644 projects/policyengine-simulation-api/Dockerfile delete mode 100644 projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/DEPLOYMENT.md (55%) create mode 100644 projects/policyengine-simulation-entrypoint/Dockerfile rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/Dockerfile.dockerignore (58%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/README.md (61%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/openapi-python-client.yaml (100%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/pyproject.toml (92%) create mode 100644 projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/__init__.py rename projects/{policyengine-simulation-api/src/policyengine_simulation_api => policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint}/app.py (95%) rename projects/{policyengine-simulation-api/src/policyengine_simulation_api => policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint}/auth.py (85%) rename projects/{policyengine-simulation-api/src/policyengine_simulation_api => policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint}/backend.py (98%) rename projects/{policyengine-simulation-api/src/policyengine_simulation_api => policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint}/config.py (87%) rename projects/{policyengine-simulation-api/src/policyengine_simulation_api => policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint}/generate_openapi.py (76%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/tests/conftest.py (86%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/tests/test_app.py (95%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/tests/test_auth.py (94%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/tests/test_backend.py (98%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/tests/test_config.py (91%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/tests/test_deployment_assets.py (60%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/tests/test_openapi.py (97%) rename projects/{policyengine-simulation-api => policyengine-simulation-entrypoint}/uv.lock (99%) diff --git a/.github/scripts/bootstrap-cloud-run-simulation-api.sh b/.github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh similarity index 72% rename from .github/scripts/bootstrap-cloud-run-simulation-api.sh rename to .github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh index 7b3404ca7..a23cb742b 100644 --- a/.github/scripts/bootstrap-cloud-run-simulation-api.sh +++ b/.github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh @@ -3,20 +3,20 @@ set -euo pipefail gcloud_bin="${GCLOUD_BIN:-gcloud}" -project_id="${SIMULATION_GCP_PROJECT_ID:?SIMULATION_GCP_PROJECT_ID is required}" -billing_account="${SIMULATION_GCP_BILLING_ACCOUNT:-}" -region="${SIMULATION_GCP_REGION:-us-central1}" -repository="${SIMULATION_ARTIFACT_REPOSITORY:-policyengine-simulation-api}" -github_repository="${SIMULATION_GITHUB_REPOSITORY:-PolicyEngine/policyengine-sim-api}" -pool_id="${SIMULATION_WIF_POOL_ID:-simulation-api-github}" -provider_id="${SIMULATION_WIF_PROVIDER_ID:-github}" -dry_run="${SIMULATION_BOOTSTRAP_DRY_RUN:-0}" - -deployer_account_id="simulation-api-github-deployer" -staging_runtime_account_id="simulation-api-stg-runtime" -production_runtime_account_id="simulation-api-prod-runtime" -staging_secret="simulation-api-old-gateway-client-secret-staging" -production_secret="simulation-api-old-gateway-client-secret-production" +project_id="${SIMULATION_ENTRYPOINT_GCP_PROJECT_ID:?SIMULATION_ENTRYPOINT_GCP_PROJECT_ID is required}" +billing_account="${SIMULATION_ENTRYPOINT_GCP_BILLING_ACCOUNT:-}" +region="${SIMULATION_ENTRYPOINT_GCP_REGION:-us-central1}" +repository="${SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY:-policyengine-simulation-entrypoint}" +github_repository="${SIMULATION_ENTRYPOINT_GITHUB_REPOSITORY:-PolicyEngine/policyengine-sim-api}" +pool_id="${SIMULATION_ENTRYPOINT_WIF_POOL_ID:-simulation-entrypoint-github}" +provider_id="${SIMULATION_ENTRYPOINT_WIF_PROVIDER_ID:-github}" +dry_run="${SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN:-0}" + +deployer_account_id="sim-entrypoint-gh-deployer" +staging_runtime_account_id="sim-entrypoint-stg-runtime" +production_runtime_account_id="sim-entrypoint-prod-runtime" +staging_secret="simulation-entrypoint-old-gateway-client-secret-staging" +production_secret="simulation-entrypoint-old-gateway-client-secret-production" run() { if [ "${dry_run}" = "1" ]; then @@ -38,12 +38,12 @@ exists() { if ! exists "${gcloud_bin}" projects describe "${project_id}"; then project_args=( projects create "${project_id}" - --name "PolicyEngine Simulation API" + --name "PolicyEngine Simulation Entrypoint" ) - if [ -n "${SIMULATION_GCP_FOLDER_ID:-}" ]; then - project_args+=(--folder "${SIMULATION_GCP_FOLDER_ID}") - elif [ -n "${SIMULATION_GCP_ORGANIZATION_ID:-}" ]; then - project_args+=(--organization "${SIMULATION_GCP_ORGANIZATION_ID}") + if [ -n "${SIMULATION_ENTRYPOINT_GCP_FOLDER_ID:-}" ]; then + project_args+=(--folder "${SIMULATION_ENTRYPOINT_GCP_FOLDER_ID}") + elif [ -n "${SIMULATION_ENTRYPOINT_GCP_ORGANIZATION_ID:-}" ]; then + project_args+=(--organization "${SIMULATION_ENTRYPOINT_GCP_ORGANIZATION_ID}") fi run "${gcloud_bin}" "${project_args[@]}" fi @@ -70,7 +70,7 @@ if ! exists "${gcloud_bin}" artifacts repositories describe "${repository}" \ --project "${project_id}" \ --location "${region}" \ --repository-format docker \ - --description "Cloud Run images for the PolicyEngine Simulation API" + --description "Cloud Run images for the PolicyEngine Simulation Entrypoint" fi ensure_service_account() { @@ -86,11 +86,11 @@ ensure_service_account() { } ensure_service_account "${deployer_account_id}" \ - "Simulation API GitHub deployer" + "Simulation Entrypoint GitHub deployer" ensure_service_account "${staging_runtime_account_id}" \ - "Simulation API staging runtime" + "Simulation Entrypoint staging runtime" ensure_service_account "${production_runtime_account_id}" \ - "Simulation API production runtime" + "Simulation Entrypoint production runtime" deployer_email="${deployer_account_id}@${project_id}.iam.gserviceaccount.com" staging_runtime_email="${staging_runtime_account_id}@${project_id}.iam.gserviceaccount.com" @@ -147,7 +147,7 @@ if ! exists "${gcloud_bin}" iam workload-identity-pools describe "${pool_id}" \ run "${gcloud_bin}" iam workload-identity-pools create "${pool_id}" \ --project "${project_id}" \ --location global \ - --display-name "Simulation API GitHub Actions" + --display-name "Simulation Entrypoint GitHub Actions" fi if ! exists "${gcloud_bin}" iam workload-identity-pools providers describe \ @@ -182,11 +182,11 @@ run "${gcloud_bin}" iam service-accounts add-iam-policy-binding \ provider_name="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${provider_id}" printf '\nBootstrap complete. Configure these GitHub values:\n' -printf ' SIMULATION_GCP_PROJECT_ID=%s\n' "${project_id}" -printf ' SIMULATION_GCP_REGION=%s\n' "${region}" -printf ' SIMULATION_ARTIFACT_REPOSITORY=%s\n' "${repository}" -printf ' SIMULATION_GCP_WIF_PROVIDER=%s\n' "${provider_name}" -printf ' SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT=%s\n' "${deployer_email}" -printf ' staging SIMULATION_RUNTIME_SERVICE_ACCOUNT=%s\n' "${staging_runtime_email}" -printf ' production SIMULATION_RUNTIME_SERVICE_ACCOUNT=%s\n' "${production_runtime_email}" +printf ' SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=%s\n' "${project_id}" +printf ' SIMULATION_ENTRYPOINT_GCP_REGION=%s\n' "${region}" +printf ' SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY=%s\n' "${repository}" +printf ' SIMULATION_ENTRYPOINT_GCP_WIF_PROVIDER=%s\n' "${provider_name}" +printf ' SIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT=%s\n' "${deployer_email}" +printf ' staging SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT=%s\n' "${staging_runtime_email}" +printf ' production SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT=%s\n' "${production_runtime_email}" printf '\nAdd one secret version to each empty Secret Manager secret before deploying.\n' diff --git a/.github/scripts/cloud-run-simulation-api-smoke.sh b/.github/scripts/cloud-run-simulation-entrypoint-smoke.sh similarity index 89% rename from .github/scripts/cloud-run-simulation-api-smoke.sh rename to .github/scripts/cloud-run-simulation-entrypoint-smoke.sh index 23b600d3d..d79bc3663 100644 --- a/.github/scripts/cloud-run-simulation-api-smoke.sh +++ b/.github/scripts/cloud-run-simulation-entrypoint-smoke.sh @@ -2,7 +2,7 @@ set -euo pipefail -base_url="${1:?Simulation API base URL is required}" +base_url="${1:?Simulation Entrypoint base URL is required}" base_url="${base_url%/}" curl --fail --silent --show-error "${base_url}/health" | diff --git a/.github/scripts/configure-cloud-run-simulation-api-domains.sh b/.github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh similarity index 60% rename from .github/scripts/configure-cloud-run-simulation-api-domains.sh rename to .github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh index 9fd38b40f..77aa0e6af 100644 --- a/.github/scripts/configure-cloud-run-simulation-api-domains.sh +++ b/.github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh @@ -3,11 +3,11 @@ set -euo pipefail gcloud_bin="${GCLOUD_BIN:-gcloud}" -project_id="${SIMULATION_GCP_PROJECT_ID:?SIMULATION_GCP_PROJECT_ID is required}" -region="${SIMULATION_GCP_REGION:-us-central1}" -production_domain="${SIMULATION_PRODUCTION_DOMAIN:-simulation.api.policyengine.org}" -staging_domain="${SIMULATION_STAGING_DOMAIN:-staging.simulation.api.policyengine.org}" -dry_run="${SIMULATION_DOMAINS_DRY_RUN:-0}" +project_id="${SIMULATION_ENTRYPOINT_GCP_PROJECT_ID:?SIMULATION_ENTRYPOINT_GCP_PROJECT_ID is required}" +region="${SIMULATION_ENTRYPOINT_GCP_REGION:-us-central1}" +production_domain="${SIMULATION_ENTRYPOINT_PRODUCTION_DOMAIN:-simulation.api.policyengine.org}" +staging_domain="${SIMULATION_ENTRYPOINT_STAGING_DOMAIN:-staging.simulation.api.policyengine.org}" +dry_run="${SIMULATION_ENTRYPOINT_DOMAINS_DRY_RUN:-0}" run() { if [ "${dry_run}" = "1" ]; then @@ -42,8 +42,8 @@ ensure_mapping() { fi } -ensure_mapping policyengine-simulation-api-staging "${staging_domain}" -ensure_mapping policyengine-simulation-api "${production_domain}" +ensure_mapping policyengine-simulation-entrypoint-staging "${staging_domain}" +ensure_mapping policyengine-simulation-entrypoint "${production_domain}" printf '\nInspect the mappings for the DNS records that must be published:\n' printf ' %s\n' "${staging_domain}" "${production_domain}" diff --git a/.github/scripts/set-cloud-run-simulation-api-revision.sh b/.github/scripts/set-cloud-run-simulation-entrypoint-revision.sh similarity index 67% rename from .github/scripts/set-cloud-run-simulation-api-revision.sh rename to .github/scripts/set-cloud-run-simulation-entrypoint-revision.sh index 608a5d4a4..c277e5f16 100755 --- a/.github/scripts/set-cloud-run-simulation-api-revision.sh +++ b/.github/scripts/set-cloud-run-simulation-entrypoint-revision.sh @@ -6,21 +6,21 @@ set -euo pipefail gcloud_bin="${GCLOUD_BIN:-gcloud}" -project_id="${SIMULATION_GCP_PROJECT_ID:?SIMULATION_GCP_PROJECT_ID is required}" -region="${SIMULATION_GCP_REGION:-us-central1}" -environment="${SIMULATION_DEPLOYMENT_ENVIRONMENT:?SIMULATION_DEPLOYMENT_ENVIRONMENT is required}" -revision="${SIMULATION_TARGET_REVISION:?SIMULATION_TARGET_REVISION is required}" -dry_run="${SIMULATION_TRAFFIC_DRY_RUN:-0}" +project_id="${SIMULATION_ENTRYPOINT_GCP_PROJECT_ID:?SIMULATION_ENTRYPOINT_GCP_PROJECT_ID is required}" +region="${SIMULATION_ENTRYPOINT_GCP_REGION:-us-central1}" +environment="${SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT:?SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT is required}" +revision="${SIMULATION_ENTRYPOINT_TARGET_REVISION:?SIMULATION_ENTRYPOINT_TARGET_REVISION is required}" +dry_run="${SIMULATION_ENTRYPOINT_TRAFFIC_DRY_RUN:-0}" case "${environment}" in staging) - service="policyengine-simulation-api-staging" + service="policyengine-simulation-entrypoint-staging" ;; production) - service="policyengine-simulation-api" + service="policyengine-simulation-entrypoint" ;; *) - printf 'SIMULATION_DEPLOYMENT_ENVIRONMENT must be staging or production\n' >&2 + printf 'SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT must be staging or production\n' >&2 exit 2 ;; esac diff --git a/.github/workflows/cloud-run-simulation-api.yml b/.github/workflows/cloud-run-simulation-entrypoint.yml similarity index 59% rename from .github/workflows/cloud-run-simulation-api.yml rename to .github/workflows/cloud-run-simulation-entrypoint.yml index a9ed93f2d..b918ceaf2 100644 --- a/.github/workflows/cloud-run-simulation-api.yml +++ b/.github/workflows/cloud-run-simulation-entrypoint.yml @@ -1,15 +1,15 @@ -name: Deploy Simulation API to Cloud Run +name: Deploy Simulation Entrypoint to Cloud Run on: push: branches: [main] paths: - - "projects/policyengine-simulation-api/**" + - "projects/policyengine-simulation-entrypoint/**" - "libs/policyengine-fastapi/**" - "libs/policyengine-simulation-contract/**" - "libs/policyengine-simulation-observability/**" - - ".github/scripts/cloud-run-simulation-api-smoke.sh" - - ".github/workflows/cloud-run-simulation-api.yml" + - ".github/scripts/cloud-run-simulation-entrypoint-smoke.sh" + - ".github/workflows/cloud-run-simulation-entrypoint.yml" workflow_dispatch: permissions: @@ -17,15 +17,15 @@ permissions: id-token: write concurrency: - group: cloud-run-simulation-api + group: cloud-run-simulation-entrypoint cancel-in-progress: false env: - PROJECT_DIR: projects/policyengine-simulation-api - REGION: ${{ vars.SIMULATION_GCP_REGION || 'us-central1' }} - PROJECT_ID: ${{ vars.SIMULATION_GCP_PROJECT_ID }} - REPOSITORY: ${{ vars.SIMULATION_ARTIFACT_REPOSITORY || 'policyengine-simulation-api' }} - IMAGE_NAME: policyengine-simulation-api + PROJECT_DIR: projects/policyengine-simulation-entrypoint + REGION: ${{ vars.SIMULATION_ENTRYPOINT_GCP_REGION || 'us-central1' }} + PROJECT_ID: ${{ vars.SIMULATION_ENTRYPOINT_GCP_PROJECT_ID }} + REPOSITORY: ${{ vars.SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY || 'policyengine-simulation-entrypoint' }} + IMAGE_NAME: policyengine-simulation-entrypoint jobs: test: @@ -50,8 +50,8 @@ jobs: - uses: actions/checkout@v6 - uses: google-github-actions/auth@v2 with: - workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} - service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 - run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet - id: image @@ -70,8 +70,8 @@ jobs: - uses: actions/checkout@v6 - uses: google-github-actions/auth@v2 with: - workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} - service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 - id: metadata run: echo "tag=s${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" @@ -80,16 +80,16 @@ jobs: IMAGE: ${{ needs.build.outputs.image }} TAG: ${{ steps.metadata.outputs.tag }} run: | - gcloud run deploy policyengine-simulation-api-staging \ + gcloud run deploy policyengine-simulation-entrypoint-staging \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --image "${IMAGE}" \ --tag "${TAG}" \ --no-traffic \ --allow-unauthenticated \ - --service-account "${{ vars.SIMULATION_RUNTIME_SERVICE_ACCOUNT }}" \ - --set-env-vars "^@^APP_ENVIRONMENT=staging@SIMULATION_API_PUBLIC_URL=https://staging.simulation.api.policyengine.org@SIMULATION_API_AUTH_REQUIRED=1@SIMULATION_API_AUTH_ISSUER=${{ secrets.SIMULATION_API_AUTH_ISSUER }}@SIMULATION_API_AUTH_AUDIENCE=${{ secrets.SIMULATION_API_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ - --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-api-old-gateway-client-secret-staging:latest" \ + --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ + --set-env-vars "^@^APP_ENVIRONMENT=staging@SIMULATION_ENTRYPOINT_PUBLIC_URL=https://staging.simulation.api.policyengine.org@SIMULATION_ENTRYPOINT_AUTH_REQUIRED=1@SIMULATION_ENTRYPOINT_AUTH_ISSUER=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}@SIMULATION_ENTRYPOINT_AUTH_AUDIENCE=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entrypoint-old-gateway-client-secret-staging:latest" \ --cpu 1 \ --memory 512Mi \ --concurrency 80 \ @@ -100,13 +100,13 @@ jobs: env: TAG: ${{ steps.metadata.outputs.tag }} run: | - url="$(gcloud run services describe policyengine-simulation-api-staging \ + url="$(gcloud run services describe policyengine-simulation-entrypoint-staging \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --format=json | jq -r --arg tag "${TAG}" '.status.traffic[] | select(.tag == $tag) | .url')" test -n "${url}" echo "url=${url}" >> "$GITHUB_OUTPUT" - - run: bash .github/scripts/cloud-run-simulation-api-smoke.sh "${{ steps.url.outputs.url }}" + - run: bash .github/scripts/cloud-run-simulation-entrypoint-smoke.sh "${{ steps.url.outputs.url }}" deploy-production-candidate: needs: [build, deploy-staging] @@ -118,8 +118,8 @@ jobs: - uses: actions/checkout@v6 - uses: google-github-actions/auth@v2 with: - workload_identity_provider: ${{ secrets.SIMULATION_GCP_WIF_PROVIDER }} - service_account: ${{ secrets.SIMULATION_GCP_DEPLOY_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 - id: metadata run: echo "tag=p${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" @@ -128,16 +128,16 @@ jobs: IMAGE: ${{ needs.build.outputs.image }} TAG: ${{ steps.metadata.outputs.tag }} run: | - gcloud run deploy policyengine-simulation-api \ + gcloud run deploy policyengine-simulation-entrypoint \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --image "${IMAGE}" \ --tag "${TAG}" \ --no-traffic \ --allow-unauthenticated \ - --service-account "${{ vars.SIMULATION_RUNTIME_SERVICE_ACCOUNT }}" \ - --set-env-vars "^@^APP_ENVIRONMENT=production@SIMULATION_API_PUBLIC_URL=https://simulation.api.policyengine.org@SIMULATION_API_AUTH_REQUIRED=1@SIMULATION_API_AUTH_ISSUER=${{ secrets.SIMULATION_API_AUTH_ISSUER }}@SIMULATION_API_AUTH_AUDIENCE=${{ secrets.SIMULATION_API_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ - --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-api-old-gateway-client-secret-production:latest" \ + --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ + --set-env-vars "^@^APP_ENVIRONMENT=production@SIMULATION_ENTRYPOINT_PUBLIC_URL=https://simulation.api.policyengine.org@SIMULATION_ENTRYPOINT_AUTH_REQUIRED=1@SIMULATION_ENTRYPOINT_AUTH_ISSUER=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}@SIMULATION_ENTRYPOINT_AUTH_AUDIENCE=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entrypoint-old-gateway-client-secret-production:latest" \ --cpu 1 \ --memory 512Mi \ --concurrency 80 \ @@ -148,9 +148,9 @@ jobs: env: TAG: ${{ steps.metadata.outputs.tag }} run: | - url="$(gcloud run services describe policyengine-simulation-api \ + url="$(gcloud run services describe policyengine-simulation-entrypoint \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --format=json | jq -r --arg tag "${TAG}" '.status.traffic[] | select(.tag == $tag) | .url')" test -n "${url}" - bash .github/scripts/cloud-run-simulation-api-smoke.sh "${url}" + bash .github/scripts/cloud-run-simulation-entrypoint-smoke.sh "${url}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 40122376b..b3f78fe18 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,7 +15,7 @@ jobs: # Modal image installs with uv_sync(frozen=True). project: - projects/policyengine-simulation-executor - - projects/policyengine-simulation-api + - projects/policyengine-simulation-entrypoint - projects/policyengine-simulation-gateway - libs/policyengine-simulation-contract - libs/policyengine-simulation-observability @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - service: [simulation-api, simulation-executor] + service: [simulation-entrypoint, simulation-executor] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/publish-clients.yml b/.github/workflows/publish-clients.yml index 967668d72..8241d675e 100644 --- a/.github/workflows/publish-clients.yml +++ b/.github/workflows/publish-clients.yml @@ -2,7 +2,7 @@ name: Publish API clients on: workflow_run: - workflows: ["Deploy Simulation API to Cloud Run"] + workflows: ["Deploy Simulation Entrypoint to Cloud Run"] types: - completed branches: @@ -31,9 +31,9 @@ jobs: run: | ./scripts/generate-clients.sh - - name: Build simulation API client + - name: Build simulation API client from the entrypoint contract run: | - cd projects/policyengine-simulation-api/artifacts/clients/python + cd projects/policyengine-simulation-entrypoint/artifacts/clients/python # Update version DATE=$(date +%Y%m%d) RUN_NUMBER="${{ github.run_number }}" @@ -45,4 +45,4 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI }} - packages-dir: projects/policyengine-simulation-api/artifacts/clients/python/dist/ + packages-dir: projects/policyengine-simulation-entrypoint/artifacts/clients/python/dist/ diff --git a/Makefile b/Makefile index b9e7e28da..ab4dced9c 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ publish-clients: generate-clients test: @echo "Running unit tests for all projects and libs..." @for proj in projects/policyengine-simulation-executor \ - projects/policyengine-simulation-api \ + projects/policyengine-simulation-entrypoint \ projects/policyengine-simulation-gateway \ libs/policyengine-simulation-contract \ libs/policyengine-simulation-observability; do \ diff --git a/projects/policyengine-apis-integ/pyproject.toml b/projects/policyengine-apis-integ/pyproject.toml index c19a67099..8166ae00c 100644 --- a/projects/policyengine-apis-integ/pyproject.toml +++ b/projects/policyengine-apis-integ/pyproject.toml @@ -27,7 +27,7 @@ markers = [ ] [tool.uv.sources] -policyengine_api_simulation_client = { path = "../policyengine-simulation-api/artifacts/clients/python" } +policyengine_api_simulation_client = { path = "../policyengine-simulation-entrypoint/artifacts/clients/python" } [tool.pyright] #The generated clients do not do the "public export" convention diff --git a/projects/policyengine-apis-integ/uv.lock b/projects/policyengine-apis-integ/uv.lock index 2e7c22088..9a564881b 100644 --- a/projects/policyengine-apis-integ/uv.lock +++ b/projects/policyengine-apis-integ/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "backoff", marker = "extra == 'test'", specifier = ">=2.2.1" }, { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, - { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-api/artifacts/clients/python" }, + { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-entrypoint/artifacts/clients/python" }, { name = "pydantic-settings", specifier = ">=2.8.1,<3.0.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", specifier = ">=8.3.4,<9.0.0" }, @@ -263,7 +263,7 @@ test = [{ name = "backoff", specifier = ">=2.2.1" }] [[package]] name = "policyengine-api-simulation-client" version = "1.0.0" -source = { directory = "../policyengine-simulation-api/artifacts/clients/python" } +source = { directory = "../policyengine-simulation-entrypoint/artifacts/clients/python" } dependencies = [ { name = "attrs" }, { name = "httpx" }, diff --git a/projects/policyengine-simulation-api/Dockerfile b/projects/policyengine-simulation-api/Dockerfile deleted file mode 100644 index d241eb500..000000000 --- a/projects/policyengine-simulation-api/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM --platform=$BUILDPLATFORM python:3.13-slim AS runtime - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - UV_PROJECT_ENVIRONMENT=/opt/venv \ - PORT=8080 - -WORKDIR /app - -COPY LICENSE ./LICENSE -COPY libs ./libs/ -COPY projects/policyengine-simulation-api ./projects/policyengine-simulation-api/ - -WORKDIR /app/projects/policyengine-simulation-api -RUN uv sync --frozen --no-dev --no-editable - -RUN addgroup --system simulation-api \ - && adduser --system --ingroup simulation-api simulation-api - -USER simulation-api - -EXPOSE 8080 - -CMD ["/opt/venv/bin/uvicorn", "policyengine_simulation_api.app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py b/projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py deleted file mode 100644 index 8ba3c84c2..000000000 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""PolicyEngine Cloud Run Simulation API.""" - -from policyengine_simulation_api.app import create_app - -__all__ = ["create_app"] diff --git a/projects/policyengine-simulation-api/DEPLOYMENT.md b/projects/policyengine-simulation-entrypoint/DEPLOYMENT.md similarity index 55% rename from projects/policyengine-simulation-api/DEPLOYMENT.md rename to projects/policyengine-simulation-entrypoint/DEPLOYMENT.md index a33e2392b..1b5cedbc2 100644 --- a/projects/policyengine-simulation-api/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entrypoint/DEPLOYMENT.md @@ -8,11 +8,11 @@ by the dedicated GitHub Actions workflow. Authenticate as an organization principal that can create projects, attach billing, and administer IAM. Then run: - SIMULATION_GCP_PROJECT_ID=policyengine-simulation-api \ - SIMULATION_GCP_BILLING_ACCOUNT=BILLING_ACCOUNT_ID \ - bash .github/scripts/bootstrap-cloud-run-simulation-api.sh + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entrypoint \ + SIMULATION_ENTRYPOINT_GCP_BILLING_ACCOUNT=BILLING_ACCOUNT_ID \ + bash .github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh -Set SIMULATION_GCP_FOLDER_ID or SIMULATION_GCP_ORGANIZATION_ID when project +Set SIMULATION_ENTRYPOINT_GCP_FOLDER_ID or SIMULATION_ENTRYPOINT_GCP_ORGANIZATION_ID when project placement is not inherited automatically. The script is idempotent. It enables APIs and creates the Artifact Registry @@ -20,23 +20,23 @@ repository, staging/production runtime identities, GitHub deployer identity, least-privilege IAM bindings, empty Secret Manager secrets, and the dedicated GitHub workload identity pool/provider. -Use SIMULATION_BOOTSTRAP_DRY_RUN=1 to print the commands without changing GCP. +Use SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN=1 to print the commands without changing GCP. ## Secrets and GitHub environments Add one version to each secret created by the bootstrap: -- simulation-api-old-gateway-client-secret-staging -- simulation-api-old-gateway-client-secret-production +- simulation-entrypoint-old-gateway-client-secret-staging +- simulation-entrypoint-old-gateway-client-secret-production Configure the printed project/WIF values as repository variables or secrets. -Configure SIMULATION_RUNTIME_SERVICE_ACCOUNT separately in the staging and +Configure SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT separately in the staging and production GitHub environments. Each environment also supplies its own old gateway URL/client ID and the shared Auth0 issuer/audiences. ## Deploy candidates -The Deploy Simulation API to Cloud Run workflow: +The Deploy Simulation Entrypoint to Cloud Run workflow: 1. tests and builds one immutable image; 2. deploys and smoke-tests a tagged no-traffic staging revision; @@ -45,23 +45,23 @@ The Deploy Simulation API to Cloud Run workflow: The workflow never changes a service's traffic. After reviewing its smoke evidence, an authorized operator promotes an exact known-good revision manually: - SIMULATION_GCP_PROJECT_ID=policyengine-simulation-api \ - SIMULATION_DEPLOYMENT_ENVIRONMENT=staging \ - SIMULATION_TARGET_REVISION=REVISION_NAME \ - bash .github/scripts/set-cloud-run-simulation-api-revision.sh + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entrypoint \ + SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT=staging \ + SIMULATION_ENTRYPOINT_TARGET_REVISION=REVISION_NAME \ + bash .github/scripts/set-cloud-run-simulation-entrypoint-revision.sh -Use `SIMULATION_DEPLOYMENT_ENVIRONMENT=production` for production. Rollback uses +Use `SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT=production` for production. Rollback uses the same command with the prior known-good revision. The script verifies that the revision is Ready and belongs to the selected service before assigning it -100 percent. Use `SIMULATION_TRAFFIC_DRY_RUN=1` to inspect the command without +100 percent. Use `SIMULATION_ENTRYPOINT_TRAFFIC_DRY_RUN=1` to inspect the command without changing traffic. After the first successful deployment creates both services, configure the domain mappings idempotently: - SIMULATION_GCP_PROJECT_ID=policyengine-simulation-api \ - bash .github/scripts/configure-cloud-run-simulation-api-domains.sh + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entrypoint \ + bash .github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh Inspect the mappings and publish the returned DNS records for staging.simulation.api.policyengine.org and simulation.api.policyengine.org. -Use SIMULATION_DOMAINS_DRY_RUN=1 to preview this phase. +Use SIMULATION_ENTRYPOINT_DOMAINS_DRY_RUN=1 to preview this phase. diff --git a/projects/policyengine-simulation-entrypoint/Dockerfile b/projects/policyengine-simulation-entrypoint/Dockerfile new file mode 100644 index 000000000..45009fb21 --- /dev/null +++ b/projects/policyengine-simulation-entrypoint/Dockerfile @@ -0,0 +1,26 @@ +FROM --platform=$BUILDPLATFORM python:3.13-slim AS runtime + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PORT=8080 + +WORKDIR /app + +COPY LICENSE ./LICENSE +COPY libs ./libs/ +COPY projects/policyengine-simulation-entrypoint ./projects/policyengine-simulation-entrypoint/ + +WORKDIR /app/projects/policyengine-simulation-entrypoint +RUN uv sync --frozen --no-dev --no-editable + +RUN addgroup --system simulation-entrypoint \ + && adduser --system --ingroup simulation-entrypoint simulation-entrypoint + +USER simulation-entrypoint + +EXPOSE 8080 + +CMD ["/opt/venv/bin/uvicorn", "policyengine_simulation_entrypoint.app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/projects/policyengine-simulation-api/Dockerfile.dockerignore b/projects/policyengine-simulation-entrypoint/Dockerfile.dockerignore similarity index 58% rename from projects/policyengine-simulation-api/Dockerfile.dockerignore rename to projects/policyengine-simulation-entrypoint/Dockerfile.dockerignore index 89eb521b3..4581b9896 100644 --- a/projects/policyengine-simulation-api/Dockerfile.dockerignore +++ b/projects/policyengine-simulation-entrypoint/Dockerfile.dockerignore @@ -6,10 +6,10 @@ **/*.pyc projects/* -!projects/policyengine-simulation-api/ -!projects/policyengine-simulation-api/** -projects/policyengine-simulation-api/artifacts -projects/policyengine-simulation-api/tests +!projects/policyengine-simulation-entrypoint/ +!projects/policyengine-simulation-entrypoint/** +projects/policyengine-simulation-entrypoint/artifacts +projects/policyengine-simulation-entrypoint/tests libs/* !libs/policyengine-simulation-contract/ diff --git a/projects/policyengine-simulation-api/README.md b/projects/policyengine-simulation-entrypoint/README.md similarity index 61% rename from projects/policyengine-simulation-api/README.md rename to projects/policyengine-simulation-entrypoint/README.md index 26ba057fd..2f6eb4dfc 100644 --- a/projects/policyengine-simulation-api/README.md +++ b/projects/policyengine-simulation-entrypoint/README.md @@ -1,6 +1,6 @@ -# policyengine-simulation-api +# policyengine-simulation-entrypoint -The permanent Cloud Run front door for PolicyEngine simulation submission and +The permanent Cloud Run entrypoint for PolicyEngine simulation submission and polling. During migration Stage 5 this service is a contract-compatible control-plane @@ -14,5 +14,5 @@ compute dispatch. uv sync --extra test uv run pytest tests/ -v -The production app is policyengine_simulation_api.app:app. Required runtime -configuration is documented in policyengine_simulation_api.config.Settings. +The production app is policyengine_simulation_entrypoint.app:app. Required runtime +configuration is documented in policyengine_simulation_entrypoint.config.Settings. diff --git a/projects/policyengine-simulation-api/openapi-python-client.yaml b/projects/policyengine-simulation-entrypoint/openapi-python-client.yaml similarity index 100% rename from projects/policyengine-simulation-api/openapi-python-client.yaml rename to projects/policyengine-simulation-entrypoint/openapi-python-client.yaml diff --git a/projects/policyengine-simulation-api/pyproject.toml b/projects/policyengine-simulation-entrypoint/pyproject.toml similarity index 92% rename from projects/policyengine-simulation-api/pyproject.toml rename to projects/policyengine-simulation-entrypoint/pyproject.toml index 24d0125d4..7cf2c606c 100644 --- a/projects/policyengine-simulation-api/pyproject.toml +++ b/projects/policyengine-simulation-entrypoint/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "policyengine-simulation-api" +name = "policyengine-simulation-entrypoint" version = "0.1.0" readme = "README.md" authors = [ @@ -23,7 +23,7 @@ dependencies = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/policyengine_simulation_api"] +packages = ["src/policyengine_simulation_entrypoint"] [tool.uv.sources] policyengine-simulation-contract = { path = "../../libs/policyengine-simulation-contract", editable = true } diff --git a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/__init__.py b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/__init__.py new file mode 100644 index 000000000..8ee908e84 --- /dev/null +++ b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/__init__.py @@ -0,0 +1,5 @@ +"""PolicyEngine Cloud Run Simulation Entrypoint.""" + +from policyengine_simulation_entrypoint.app import create_app + +__all__ = ["create_app"] diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/app.py similarity index 95% rename from projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py rename to projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/app.py index aff9d6779..4c92d7a96 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/app.py +++ b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/app.py @@ -1,4 +1,4 @@ -"""FastAPI application for the Cloud Run Simulation API.""" +"""FastAPI application for the Cloud Run Simulation Entrypoint.""" from __future__ import annotations @@ -27,8 +27,8 @@ init_simulation_observability, ) -from policyengine_simulation_api.auth import CallerAuthenticator -from policyengine_simulation_api.backend import ( +from policyengine_simulation_entrypoint.auth import CallerAuthenticator +from policyengine_simulation_entrypoint.backend import ( BackendAuthenticationError, BackendResponse, BackendTimeout, @@ -36,7 +36,7 @@ OldGatewayBackend, SimulationBackend, ) -from policyengine_simulation_api.config import Settings +from policyengine_simulation_entrypoint.config import Settings logger = logging.getLogger(__name__) @@ -95,7 +95,7 @@ async def lifespan(_: FastAPI): await runtime_backend.close() app = FastAPI( - title="PolicyEngine Simulation API", + title="PolicyEngine Simulation Entrypoint", description=("Authenticated simulation submission and polling control plane."), version="1.0.0", lifespan=lifespan, @@ -103,9 +103,9 @@ async def lifespan(_: FastAPI): configure_process_observability( platform="cloud_run", - service_role="simulation_api", + service_role="simulation_entrypoint", ) - init_simulation_observability(app, service_role="simulation_api") + init_simulation_observability(app, service_role="simulation_entrypoint") @app.middleware("http") async def request_context(request: Request, call_next): @@ -116,7 +116,7 @@ async def request_context(request: Request, call_next): elapsed_ms = round((time.monotonic() - started) * 1000, 2) response.headers["X-Request-ID"] = request_id logger.info( - "simulation_api_request", + "simulation_entrypoint_request", extra={ "request_id": request_id, "method": request.method, @@ -177,11 +177,11 @@ async def forward( ) if response_identifier_key and isinstance(response_identifier, str): attributes[response_identifier_key] = response_identifier - record_event("simulation_api_backend_response", **attributes) + record_event("simulation_entrypoint_backend_response", **attributes) return _response(result) except BackendTimeout: record_event( - "simulation_api_backend_timeout", + "simulation_entrypoint_backend_timeout", request_id=request.state.request_id, route=route, **event_identifiers, @@ -196,7 +196,7 @@ async def forward( ) except (BackendUnavailable, BackendAuthenticationError): record_event( - "simulation_api_backend_unavailable", + "simulation_entrypoint_backend_unavailable", request_id=request.state.request_id, route=route, **event_identifiers, diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/auth.py similarity index 85% rename from projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py rename to projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/auth.py index 466c3ba99..aba9a1006 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/auth.py +++ b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/auth.py @@ -1,4 +1,4 @@ -"""Inbound bearer authentication for protected Simulation API routes.""" +"""Inbound bearer authentication for protected Simulation Entrypoint routes.""" from __future__ import annotations @@ -10,7 +10,7 @@ import jwt from policyengine_observability import record_event -from policyengine_simulation_api.config import Settings +from policyengine_simulation_entrypoint.config import Settings _bearer = HTTPBearer(auto_error=False) @@ -30,7 +30,7 @@ def __call__( token: HTTPAuthorizationCredentials | None, ) -> dict[str, str]: if token is None: - record_event("simulation_api_auth_rejected", reason="missing_token") + record_event("simulation_entrypoint_auth_rejected", reason="missing_token") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) try: @@ -47,10 +47,10 @@ def __call__( except Exception as error: reason = type(error).__name__ logger.info( - "invalid_simulation_api_bearer_token", + "invalid_simulation_entrypoint_bearer_token", extra={"error_type": reason}, ) - record_event("simulation_api_auth_rejected", reason=reason) + record_event("simulation_entrypoint_auth_rejected", reason=reason) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from error diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/backend.py b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/backend.py similarity index 98% rename from projects/policyengine-simulation-api/src/policyengine_simulation_api/backend.py rename to projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/backend.py index 7ddc585e2..6418d9c9f 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/backend.py +++ b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/backend.py @@ -9,7 +9,7 @@ import httpx -from policyengine_simulation_api.config import Settings +from policyengine_simulation_entrypoint.config import Settings SAFE_RESPONSE_HEADERS = frozenset( @@ -60,7 +60,7 @@ async def request( class ClientCredentialsTokenProvider: - """Fetch and cache the Simulation API's old-gateway M2M token.""" + """Fetch and cache the Simulation Entrypoint's old-gateway M2M token.""" REFRESH_MARGIN_SECONDS = 60 diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/config.py b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/config.py similarity index 87% rename from projects/policyengine-simulation-api/src/policyengine_simulation_api/config.py rename to projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/config.py index 2d8508c69..c5b2c401d 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/config.py +++ b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/config.py @@ -1,4 +1,4 @@ -"""Runtime configuration for the Cloud Run Simulation API.""" +"""Runtime configuration for the Cloud Run Simulation Entrypoint.""" from __future__ import annotations @@ -45,13 +45,15 @@ class Settings: def from_env(cls) -> "Settings": return cls( environment=os.getenv("APP_ENVIRONMENT", "local").lower(), - public_url=os.getenv("SIMULATION_API_PUBLIC_URL", ""), + public_url=os.getenv("SIMULATION_ENTRYPOINT_PUBLIC_URL", ""), auth_required=_truthy( - os.getenv("SIMULATION_API_AUTH_REQUIRED"), + os.getenv("SIMULATION_ENTRYPOINT_AUTH_REQUIRED"), default=True, ), - auth_issuer=_normalized_issuer(os.getenv("SIMULATION_API_AUTH_ISSUER", "")), - auth_audience=os.getenv("SIMULATION_API_AUTH_AUDIENCE", ""), + auth_issuer=_normalized_issuer( + os.getenv("SIMULATION_ENTRYPOINT_AUTH_ISSUER", "") + ), + auth_audience=os.getenv("SIMULATION_ENTRYPOINT_AUTH_AUDIENCE", ""), old_gateway_url=os.getenv("OLD_GATEWAY_URL", "").rstrip("/"), old_gateway_auth_issuer=_normalized_issuer( os.getenv("OLD_GATEWAY_AUTH_ISSUER", "") @@ -81,7 +83,7 @@ def validate(self) -> None: if bool(self.auth_issuer) != bool(self.auth_audience): raise ConfigurationError( - "Set both SIMULATION_API_AUTH_ISSUER and SIMULATION_API_AUTH_AUDIENCE." + "Set both SIMULATION_ENTRYPOINT_AUTH_ISSUER and SIMULATION_ENTRYPOINT_AUTH_AUDIENCE." ) if self.auth_required and not self.auth_issuer: raise ConfigurationError( @@ -108,5 +110,5 @@ def validate(self) -> None: upstream_host = urlparse(self.old_gateway_url).hostname if public_host and upstream_host and public_host == upstream_host: raise ConfigurationError( - "OLD_GATEWAY_URL must not point to the Simulation API itself." + "OLD_GATEWAY_URL must not point to the Simulation Entrypoint itself." ) diff --git a/projects/policyengine-simulation-api/src/policyengine_simulation_api/generate_openapi.py b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/generate_openapi.py similarity index 76% rename from projects/policyengine-simulation-api/src/policyengine_simulation_api/generate_openapi.py rename to projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/generate_openapi.py index a666f927f..e4d52ee61 100644 --- a/projects/policyengine-simulation-api/src/policyengine_simulation_api/generate_openapi.py +++ b/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/generate_openapi.py @@ -1,11 +1,11 @@ -"""Generate the Cloud Run Simulation API OpenAPI document.""" +"""Generate the Cloud Run Simulation Entrypoint OpenAPI document.""" from __future__ import annotations import json from pathlib import Path -from policyengine_simulation_api.app import create_app +from policyengine_simulation_entrypoint.app import create_app def main() -> None: diff --git a/projects/policyengine-simulation-api/tests/conftest.py b/projects/policyengine-simulation-entrypoint/tests/conftest.py similarity index 86% rename from projects/policyengine-simulation-api/tests/conftest.py rename to projects/policyengine-simulation-entrypoint/tests/conftest.py index 155c2b6b2..2b057bc68 100644 --- a/projects/policyengine-simulation-api/tests/conftest.py +++ b/projects/policyengine-simulation-entrypoint/tests/conftest.py @@ -6,9 +6,9 @@ import pytest from fastapi.testclient import TestClient -from policyengine_simulation_api.app import create_app -from policyengine_simulation_api.backend import BackendResponse -from policyengine_simulation_api.config import Settings +from policyengine_simulation_entrypoint.app import create_app +from policyengine_simulation_entrypoint.backend import BackendResponse +from policyengine_simulation_entrypoint.config import Settings def make_settings(**overrides) -> Settings: @@ -17,11 +17,11 @@ def make_settings(**overrides) -> Settings: "public_url": "https://simulation.example.test", "auth_required": True, "auth_issuer": "https://issuer.example/", - "auth_audience": "simulation-api", + "auth_audience": "simulation-entrypoint", "old_gateway_url": "https://old-gateway.example.test", "old_gateway_auth_issuer": "https://issuer.example/", "old_gateway_auth_audience": "simulation-api", - "old_gateway_auth_client_id": "simulation-api-service", + "old_gateway_auth_client_id": "simulation-entrypoint-service", "old_gateway_auth_client_secret": "secret", } values.update(overrides) diff --git a/projects/policyengine-simulation-api/tests/test_app.py b/projects/policyengine-simulation-entrypoint/tests/test_app.py similarity index 95% rename from projects/policyengine-simulation-api/tests/test_app.py rename to projects/policyengine-simulation-entrypoint/tests/test_app.py index abfce6fb9..d1d50bbdc 100644 --- a/projects/policyengine-simulation-api/tests/test_app.py +++ b/projects/policyengine-simulation-entrypoint/tests/test_app.py @@ -5,9 +5,9 @@ import pytest -from policyengine_simulation_api import app as app_module -from policyengine_simulation_api.app import create_app -from policyengine_simulation_api.backend import ( +from policyengine_simulation_entrypoint import app as app_module +from policyengine_simulation_entrypoint.app import create_app +from policyengine_simulation_entrypoint.backend import ( BackendResponse, BackendTimeout, BackendUnavailable, @@ -100,7 +100,7 @@ def test_job_status_records_structured_backend_telemetry( client.get("/jobs/fc-123") name, attributes = events[-1] - assert name == "simulation_api_backend_response" + assert name == "simulation_entrypoint_backend_response" assert attributes["job_state"] == "running" assert attributes["status_code"] == 202 assert attributes["route"] == "/jobs/{job_id}" @@ -128,7 +128,7 @@ def test_request_log_templates_route_and_keeps_structured_job_id( request_record = next( record for record in caplog.records - if record.getMessage() == "simulation_api_request" + if record.getMessage() == "simulation_entrypoint_request" ) assert request_record.path == "/jobs/{job_id}" assert request_record.job_id == "fc-123" diff --git a/projects/policyengine-simulation-api/tests/test_auth.py b/projects/policyengine-simulation-entrypoint/tests/test_auth.py similarity index 94% rename from projects/policyengine-simulation-api/tests/test_auth.py rename to projects/policyengine-simulation-entrypoint/tests/test_auth.py index 2634f5cd2..b90335d12 100644 --- a/projects/policyengine-simulation-api/tests/test_auth.py +++ b/projects/policyengine-simulation-entrypoint/tests/test_auth.py @@ -8,8 +8,8 @@ from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient -from policyengine_simulation_api import auth as auth_module -from policyengine_simulation_api.app import create_app +from policyengine_simulation_entrypoint import auth as auth_module +from policyengine_simulation_entrypoint.app import create_app from conftest import FakeBackend, make_settings @@ -25,7 +25,7 @@ def _token(private_key, **overrides) -> str: claims = { "sub": "api-v1", "iss": "https://issuer.example/", - "aud": "simulation-api", + "aud": "simulation-entrypoint", "iat": now, "exp": now + timedelta(minutes=5), } @@ -36,7 +36,7 @@ def _token(private_key, **overrides) -> str: def _use_static_signing_key(monkeypatch, public_key) -> None: decoder = auth_module.JWTDecoder( issuer="https://issuer.example/", - audience="simulation-api", + audience="simulation-entrypoint", ) decoder.jwks_client = SimpleNamespace( get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) diff --git a/projects/policyengine-simulation-api/tests/test_backend.py b/projects/policyengine-simulation-entrypoint/tests/test_backend.py similarity index 98% rename from projects/policyengine-simulation-api/tests/test_backend.py rename to projects/policyengine-simulation-entrypoint/tests/test_backend.py index ded625df7..56051a357 100644 --- a/projects/policyengine-simulation-api/tests/test_backend.py +++ b/projects/policyengine-simulation-entrypoint/tests/test_backend.py @@ -5,7 +5,7 @@ import httpx import pytest -from policyengine_simulation_api.backend import ( +from policyengine_simulation_entrypoint.backend import ( BackendUnavailable, OldGatewayBackend, ) diff --git a/projects/policyengine-simulation-api/tests/test_config.py b/projects/policyengine-simulation-entrypoint/tests/test_config.py similarity index 91% rename from projects/policyengine-simulation-api/tests/test_config.py rename to projects/policyengine-simulation-entrypoint/tests/test_config.py index 6d7ee5e1e..620d12383 100644 --- a/projects/policyengine-simulation-api/tests/test_config.py +++ b/projects/policyengine-simulation-entrypoint/tests/test_config.py @@ -2,7 +2,7 @@ import pytest -from policyengine_simulation_api.config import ConfigurationError +from policyengine_simulation_entrypoint.config import ConfigurationError from conftest import make_settings diff --git a/projects/policyengine-simulation-api/tests/test_deployment_assets.py b/projects/policyengine-simulation-entrypoint/tests/test_deployment_assets.py similarity index 60% rename from projects/policyengine-simulation-api/tests/test_deployment_assets.py rename to projects/policyengine-simulation-entrypoint/tests/test_deployment_assets.py index b0522e8e6..01a57de33 100644 --- a/projects/policyengine-simulation-api/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entrypoint/tests/test_deployment_assets.py @@ -8,22 +8,30 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3] BOOTSTRAP_SCRIPT = ( - REPOSITORY_ROOT / ".github" / "scripts" / "bootstrap-cloud-run-simulation-api.sh" + REPOSITORY_ROOT + / ".github" + / "scripts" + / "bootstrap-cloud-run-simulation-entrypoint.sh" ) DOMAIN_SCRIPT = ( REPOSITORY_ROOT / ".github" / "scripts" - / "configure-cloud-run-simulation-api-domains.sh" + / "configure-cloud-run-simulation-entrypoint-domains.sh" ) TRAFFIC_SCRIPT = ( - REPOSITORY_ROOT / ".github" / "scripts" / "set-cloud-run-simulation-api-revision.sh" + REPOSITORY_ROOT + / ".github" + / "scripts" + / "set-cloud-run-simulation-entrypoint-revision.sh" +) +WORKFLOW = ( + REPOSITORY_ROOT / ".github" / "workflows" / "cloud-run-simulation-entrypoint.yml" ) -WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "cloud-run-simulation-api.yml" DOCKERIGNORE = ( REPOSITORY_ROOT / "projects" - / "policyengine-simulation-api" + / "policyengine-simulation-entrypoint" / "Dockerfile.dockerignore" ) @@ -37,8 +45,8 @@ def test_bootstrap_script_has_valid_shell_syntax(): def test_bootstrap_dry_run_is_self_contained(): environment = { **os.environ, - "SIMULATION_GCP_PROJECT_ID": "simulation-api-test", - "SIMULATION_BOOTSTRAP_DRY_RUN": "1", + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entrypoint-test", + "SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN": "1", } result = subprocess.run( @@ -49,8 +57,11 @@ def test_bootstrap_dry_run_is_self_contained(): env=environment, ) - assert "projects create simulation-api-test" in result.stdout - assert "artifacts repositories create policyengine-simulation-api" in result.stdout + assert "projects create simulation-entrypoint-test" in result.stdout + assert ( + "artifacts repositories create policyengine-simulation-entrypoint" + in result.stdout + ) assert "roles/serviceusage.serviceUsageConsumer" in result.stdout assert "Bootstrap complete" in result.stdout assert "000000000000" in result.stdout @@ -61,12 +72,15 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): assert "gcloud run deploy" in workflow assert "update-traffic" not in workflow - assert "set-cloud-run-simulation-api-revision.sh" not in workflow + assert "set-cloud-run-simulation-entrypoint-revision.sh" not in workflow assert "terraform" not in workflow.lower() assert not list( - (REPOSITORY_ROOT / "projects" / "policyengine-simulation-api" / "infra").glob( - "*.tf" - ) + ( + REPOSITORY_ROOT + / "projects" + / "policyengine-simulation-entrypoint" + / "infra" + ).glob("*.tf") ) @@ -86,15 +100,15 @@ def test_traffic_changes_are_operator_run_and_revision_validated(): text=True, env={ **os.environ, - "SIMULATION_GCP_PROJECT_ID": "simulation-api-test", - "SIMULATION_DEPLOYMENT_ENVIRONMENT": "production", - "SIMULATION_TARGET_REVISION": "policyengine-simulation-api-00001-abc", - "SIMULATION_TRAFFIC_DRY_RUN": "1", + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entrypoint-test", + "SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT": "production", + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entrypoint-00001-abc", + "SIMULATION_ENTRYPOINT_TRAFFIC_DRY_RUN": "1", }, ) - assert "policyengine-simulation-api-00001-abc=100" in result.stdout - assert "policyengine-simulation-api-staging" not in result.stdout + assert "policyengine-simulation-entrypoint-00001-abc=100" in result.stdout + assert "policyengine-simulation-entrypoint-staging" not in result.stdout def test_container_context_excludes_local_environments_and_unrelated_projects(): @@ -102,14 +116,17 @@ def test_container_context_excludes_local_environments_and_unrelated_projects(): assert "**/.venv" in ignore_rules assert "projects/*" in ignore_rules - assert "!projects/policyengine-simulation-api/**" in ignore_rules + assert "!projects/policyengine-simulation-entrypoint/**" in ignore_rules assert "libs/*" in ignore_rules def test_production_lock_excludes_modal_and_database_runtime_packages(): lockfile = tomllib.loads( ( - REPOSITORY_ROOT / "projects" / "policyengine-simulation-api" / "uv.lock" + REPOSITORY_ROOT + / "projects" + / "policyengine-simulation-entrypoint" + / "uv.lock" ).read_text(encoding="utf-8") ) resolved_packages = {package["name"] for package in lockfile["package"]} @@ -126,12 +143,12 @@ def test_domain_mapping_dry_run_targets_both_services(): text=True, env={ **os.environ, - "SIMULATION_GCP_PROJECT_ID": "simulation-api-test", - "SIMULATION_DOMAINS_DRY_RUN": "1", + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entrypoint-test", + "SIMULATION_ENTRYPOINT_DOMAINS_DRY_RUN": "1", }, ) - assert "policyengine-simulation-api-staging" in result.stdout + assert "policyengine-simulation-entrypoint-staging" in result.stdout assert "staging.simulation.api.policyengine.org" in result.stdout - assert "policyengine-simulation-api" in result.stdout + assert "policyengine-simulation-entrypoint" in result.stdout assert "simulation.api.policyengine.org" in result.stdout diff --git a/projects/policyengine-simulation-api/tests/test_openapi.py b/projects/policyengine-simulation-entrypoint/tests/test_openapi.py similarity index 97% rename from projects/policyengine-simulation-api/tests/test_openapi.py rename to projects/policyengine-simulation-entrypoint/tests/test_openapi.py index c54bf2f73..7cffedaf7 100644 --- a/projects/policyengine-simulation-api/tests/test_openapi.py +++ b/projects/policyengine-simulation-entrypoint/tests/test_openapi.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from policyengine_simulation_api.app import create_app +from policyengine_simulation_entrypoint.app import create_app EXPECTED_ROUTES = { diff --git a/projects/policyengine-simulation-api/uv.lock b/projects/policyengine-simulation-entrypoint/uv.lock similarity index 99% rename from projects/policyengine-simulation-api/uv.lock rename to projects/policyengine-simulation-entrypoint/uv.lock index 6b1b1f005..a15c43ccb 100644 --- a/projects/policyengine-simulation-api/uv.lock +++ b/projects/policyengine-simulation-entrypoint/uv.lock @@ -646,7 +646,7 @@ fastapi = [ ] [[package]] -name = "policyengine-simulation-api" +name = "policyengine-simulation-entrypoint" version = "0.1.0" source = { editable = "." } dependencies = [ diff --git a/projects/policyengine-simulation-executor/Dockerfile b/projects/policyengine-simulation-executor/Dockerfile index a5f4779dc..f8255bd2a 100644 --- a/projects/policyengine-simulation-executor/Dockerfile +++ b/projects/policyengine-simulation-executor/Dockerfile @@ -18,7 +18,7 @@ COPY libs ./libs/ FROM base AS final # Service-specific environment -ENV OT_SERVICE_NAME=policyengine_simulation_api \ +ENV OT_SERVICE_NAME=policyengine_simulation_executor \ OT_SERVICE_INSTANCE_ID=instance # Copy service-specific code @@ -31,5 +31,5 @@ RUN uv sync --frozen --no-dev EXPOSE 8080 -# Run with uvicorn (with 2 workers for simulation API) -CMD ["uv", "run", "--frozen", "--no-dev", "uvicorn", "policyengine_simulation_executor.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"] \ No newline at end of file +# Run the executor with two uvicorn workers. +CMD ["uv", "run", "--frozen", "--no-dev", "uvicorn", "policyengine_simulation_executor.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"] diff --git a/scripts/generate-clients.sh b/scripts/generate-clients.sh index d2112f526..4143c61b7 100755 --- a/scripts/generate-clients.sh +++ b/scripts/generate-clients.sh @@ -19,9 +19,9 @@ generate_client() { # Install build dependencies if not already installed uv sync --extra build - # The permanent Cloud Run Simulation API owns the public schema. + # The permanent Cloud Run Simulation Entrypoint owns the public schema. if [ "$SERVICE" = "simulation" ]; then - uv run python -m policyengine_simulation_api.generate_openapi + uv run python -m policyengine_simulation_entrypoint.generate_openapi else uv run python -m policyengine_api_${SERVICE//-/_}.generate_openapi fi @@ -60,8 +60,8 @@ generate_client() { cd ../.. } -# Generate the client from the permanent Cloud Run front door. -generate_client "simulation" "projects/policyengine-simulation-api" +# Generate the client from the permanent Cloud Run entrypoint. +generate_client "simulation" "projects/policyengine-simulation-entrypoint" echo "✅ Client generated successfully!" echo "" diff --git a/scripts/publish-clients.sh b/scripts/publish-clients.sh index 20c7060e7..acfbe9c0c 100755 --- a/scripts/publish-clients.sh +++ b/scripts/publish-clients.sh @@ -12,7 +12,7 @@ publish_client() { case "$SERVICE" in simulation) - CLIENT_DIR="projects/policyengine-simulation-api/artifacts/clients/python" + CLIENT_DIR="projects/policyengine-simulation-entrypoint/artifacts/clients/python" ;; *) echo "❌ Unsupported API client service: ${SERVICE}" From 9c27a6dfd2aa1f2f46bc9594749290880c89dd3e Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 20:12:20 +0300 Subject: [PATCH 09/11] fix: harden simulation entry deployment --- ...> bootstrap-cloud-run-simulation-entry.sh} | 52 ++++++--- ...sh => cloud-run-simulation-entry-smoke.sh} | 0 ...ure-cloud-run-simulation-entry-domains.sh} | 4 +- ...et-cloud-run-simulation-entry-revision.sh} | 4 +- ...int.yml => cloud-run-simulation-entry.yml} | 91 ++++++++++++--- .github/workflows/pr.yml | 4 +- .github/workflows/publish-clients.yml | 6 +- Makefile | 2 +- README.md | 19 +-- .../pyproject.toml | 15 ++- libs/policyengine-simulation-contract/uv.lock | 6 +- .../policyengine-apis-integ/pyproject.toml | 2 +- projects/policyengine-apis-integ/uv.lock | 4 +- .../DEPLOYMENT.md | 38 ++++-- .../policyengine-simulation-entry/Dockerfile | 26 +++++ .../Dockerfile.dockerignore | 23 ++++ .../README.md | 8 +- .../authenticated_tests/test_deployed_auth.py | 86 ++++++++++++++ .../openapi-python-client.yaml | 0 .../pyproject.toml | 4 +- .../__init__.py | 2 +- .../src/policyengine_simulation_entry}/app.py | 24 ++-- .../policyengine_simulation_entry}/auth.py | 8 +- .../policyengine_simulation_entry}/backend.py | 33 +++--- .../policyengine_simulation_entry}/config.py | 20 +++- .../generate_openapi.py | 2 +- .../tests/conftest.py | 14 ++- .../tests/test_app.py | 10 +- .../tests/test_auth.py | 8 +- .../tests/test_backend.py | 41 ++++++- .../tests/test_config.py | 51 +++++++++ .../tests/test_deployment_assets.py | 108 ++++++++++++------ .../tests/test_openapi.py | 2 +- .../uv.lock | 47 ++++---- .../Dockerfile | 26 ----- .../Dockerfile.dockerignore | 19 --- .../tests/test_config.py | 28 ----- .../pyproject.toml | 2 +- .../policyengine-simulation-executor/uv.lock | 14 ++- .../pyproject.toml | 2 +- .../policyengine-simulation-gateway/uv.lock | 12 +- scripts/generate-clients.sh | 4 +- scripts/publish-clients.sh | 2 +- 43 files changed, 603 insertions(+), 270 deletions(-) rename .github/scripts/{bootstrap-cloud-run-simulation-entrypoint.sh => bootstrap-cloud-run-simulation-entry.sh} (80%) rename .github/scripts/{cloud-run-simulation-entrypoint-smoke.sh => cloud-run-simulation-entry-smoke.sh} (100%) rename .github/scripts/{configure-cloud-run-simulation-entrypoint-domains.sh => configure-cloud-run-simulation-entry-domains.sh} (90%) rename .github/scripts/{set-cloud-run-simulation-entrypoint-revision.sh => set-cloud-run-simulation-entry-revision.sh} (94%) rename .github/workflows/{cloud-run-simulation-entrypoint.yml => cloud-run-simulation-entry.yml} (65%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/DEPLOYMENT.md (64%) create mode 100644 projects/policyengine-simulation-entry/Dockerfile create mode 100644 projects/policyengine-simulation-entry/Dockerfile.dockerignore rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/README.md (58%) create mode 100644 projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/openapi-python-client.yaml (100%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/pyproject.toml (92%) rename projects/{policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint => policyengine-simulation-entry/src/policyengine_simulation_entry}/__init__.py (56%) rename projects/{policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint => policyengine-simulation-entry/src/policyengine_simulation_entry}/app.py (95%) rename projects/{policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint => policyengine-simulation-entry/src/policyengine_simulation_entry}/auth.py (88%) rename projects/{policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint => policyengine-simulation-entry/src/policyengine_simulation_entry}/backend.py (88%) rename projects/{policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint => policyengine-simulation-entry/src/policyengine_simulation_entry}/config.py (82%) rename projects/{policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint => policyengine-simulation-entry/src/policyengine_simulation_entry}/generate_openapi.py (89%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/tests/conftest.py (82%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/tests/test_app.py (95%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/tests/test_auth.py (94%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/tests/test_backend.py (81%) create mode 100644 projects/policyengine-simulation-entry/tests/test_config.py rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/tests/test_deployment_assets.py (53%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/tests/test_openapi.py (97%) rename projects/{policyengine-simulation-entrypoint => policyengine-simulation-entry}/uv.lock (99%) delete mode 100644 projects/policyengine-simulation-entrypoint/Dockerfile delete mode 100644 projects/policyengine-simulation-entrypoint/Dockerfile.dockerignore delete mode 100644 projects/policyengine-simulation-entrypoint/tests/test_config.py diff --git a/.github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh b/.github/scripts/bootstrap-cloud-run-simulation-entry.sh similarity index 80% rename from .github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh rename to .github/scripts/bootstrap-cloud-run-simulation-entry.sh index a23cb742b..72fa8951a 100644 --- a/.github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh +++ b/.github/scripts/bootstrap-cloud-run-simulation-entry.sh @@ -6,17 +6,24 @@ gcloud_bin="${GCLOUD_BIN:-gcloud}" project_id="${SIMULATION_ENTRYPOINT_GCP_PROJECT_ID:?SIMULATION_ENTRYPOINT_GCP_PROJECT_ID is required}" billing_account="${SIMULATION_ENTRYPOINT_GCP_BILLING_ACCOUNT:-}" region="${SIMULATION_ENTRYPOINT_GCP_REGION:-us-central1}" -repository="${SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY:-policyengine-simulation-entrypoint}" +repository="${SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY:-policyengine-simulation-entry}" github_repository="${SIMULATION_ENTRYPOINT_GITHUB_REPOSITORY:-PolicyEngine/policyengine-sim-api}" -pool_id="${SIMULATION_ENTRYPOINT_WIF_POOL_ID:-simulation-entrypoint-github}" +pool_id="${SIMULATION_ENTRYPOINT_WIF_POOL_ID:-simulation-entry-github}" provider_id="${SIMULATION_ENTRYPOINT_WIF_PROVIDER_ID:-github}" dry_run="${SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN:-0}" - -deployer_account_id="sim-entrypoint-gh-deployer" -staging_runtime_account_id="sim-entrypoint-stg-runtime" -production_runtime_account_id="sim-entrypoint-prod-runtime" -staging_secret="simulation-entrypoint-old-gateway-client-secret-staging" -production_secret="simulation-entrypoint-old-gateway-client-secret-production" +workflow_ref="${github_repository}/.github/workflows/cloud-run-simulation-entry.yml@refs/heads/main" + +deployer_account_id="sim-entry-gh-deployer" +staging_runtime_account_id="sim-entry-stg-runtime" +production_runtime_account_id="sim-entry-prod-runtime" +staging_secret="simulation-entry-old-gateway-client-secret-staging" +production_secret="simulation-entry-old-gateway-client-secret-production" + +if [ "${#project_id}" -gt 30 ] || + ! [[ "${project_id}" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]]; then + printf 'SIMULATION_ENTRYPOINT_GCP_PROJECT_ID must be a valid 6-30 character GCP project ID\n' >&2 + exit 2 +fi run() { if [ "${dry_run}" = "1" ]; then @@ -38,7 +45,7 @@ exists() { if ! exists "${gcloud_bin}" projects describe "${project_id}"; then project_args=( projects create "${project_id}" - --name "PolicyEngine Simulation Entrypoint" + --name "PE Simulation Entrypoint" ) if [ -n "${SIMULATION_ENTRYPOINT_GCP_FOLDER_ID:-}" ]; then project_args+=(--folder "${SIMULATION_ENTRYPOINT_GCP_FOLDER_ID}") @@ -150,20 +157,29 @@ if ! exists "${gcloud_bin}" iam workload-identity-pools describe "${pool_id}" \ --display-name "Simulation Entrypoint GitHub Actions" fi -if ! exists "${gcloud_bin}" iam workload-identity-pools providers describe \ +provider_args=( + "${provider_id}" + --project "${project_id}" + --location global + --workload-identity-pool "${pool_id}" + --issuer-uri "https://token.actions.githubusercontent.com" + --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref,attribute.workflow_ref=assertion.workflow_ref" + --attribute-condition "assertion.repository == '${github_repository}' && assertion.ref == 'refs/heads/main' && assertion.workflow_ref == '${workflow_ref}'" +) + +if exists "${gcloud_bin}" iam workload-identity-pools providers describe \ "${provider_id}" \ --project "${project_id}" \ --location global \ --workload-identity-pool "${pool_id}"; then - run "${gcloud_bin}" iam workload-identity-pools providers create-oidc \ - "${provider_id}" \ - --project "${project_id}" \ - --location global \ - --workload-identity-pool "${pool_id}" \ + run "${gcloud_bin}" iam workload-identity-pools providers update-oidc \ + "${provider_args[@]}" \ --display-name "PolicyEngine simulation repository" \ - --issuer-uri "https://token.actions.githubusercontent.com" \ - --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \ - --attribute-condition "assertion.repository == '${github_repository}'" + --quiet +else + run "${gcloud_bin}" iam workload-identity-pools providers create-oidc \ + "${provider_args[@]}" \ + --display-name "PolicyEngine simulation repository" fi if [ "${dry_run}" = "1" ]; then diff --git a/.github/scripts/cloud-run-simulation-entrypoint-smoke.sh b/.github/scripts/cloud-run-simulation-entry-smoke.sh similarity index 100% rename from .github/scripts/cloud-run-simulation-entrypoint-smoke.sh rename to .github/scripts/cloud-run-simulation-entry-smoke.sh diff --git a/.github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh b/.github/scripts/configure-cloud-run-simulation-entry-domains.sh similarity index 90% rename from .github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh rename to .github/scripts/configure-cloud-run-simulation-entry-domains.sh index 77aa0e6af..beccfbfea 100644 --- a/.github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh +++ b/.github/scripts/configure-cloud-run-simulation-entry-domains.sh @@ -42,8 +42,8 @@ ensure_mapping() { fi } -ensure_mapping policyengine-simulation-entrypoint-staging "${staging_domain}" -ensure_mapping policyengine-simulation-entrypoint "${production_domain}" +ensure_mapping policyengine-simulation-entry-staging "${staging_domain}" +ensure_mapping policyengine-simulation-entry "${production_domain}" printf '\nInspect the mappings for the DNS records that must be published:\n' printf ' %s\n' "${staging_domain}" "${production_domain}" diff --git a/.github/scripts/set-cloud-run-simulation-entrypoint-revision.sh b/.github/scripts/set-cloud-run-simulation-entry-revision.sh similarity index 94% rename from .github/scripts/set-cloud-run-simulation-entrypoint-revision.sh rename to .github/scripts/set-cloud-run-simulation-entry-revision.sh index c277e5f16..a6b760f1b 100755 --- a/.github/scripts/set-cloud-run-simulation-entrypoint-revision.sh +++ b/.github/scripts/set-cloud-run-simulation-entry-revision.sh @@ -14,10 +14,10 @@ dry_run="${SIMULATION_ENTRYPOINT_TRAFFIC_DRY_RUN:-0}" case "${environment}" in staging) - service="policyengine-simulation-entrypoint-staging" + service="policyengine-simulation-entry-staging" ;; production) - service="policyengine-simulation-entrypoint" + service="policyengine-simulation-entry" ;; *) printf 'SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT must be staging or production\n' >&2 diff --git a/.github/workflows/cloud-run-simulation-entrypoint.yml b/.github/workflows/cloud-run-simulation-entry.yml similarity index 65% rename from .github/workflows/cloud-run-simulation-entrypoint.yml rename to .github/workflows/cloud-run-simulation-entry.yml index b918ceaf2..0624d36b3 100644 --- a/.github/workflows/cloud-run-simulation-entrypoint.yml +++ b/.github/workflows/cloud-run-simulation-entry.yml @@ -4,28 +4,27 @@ on: push: branches: [main] paths: - - "projects/policyengine-simulation-entrypoint/**" + - "projects/policyengine-simulation-entry/**" - "libs/policyengine-fastapi/**" - "libs/policyengine-simulation-contract/**" - "libs/policyengine-simulation-observability/**" - - ".github/scripts/cloud-run-simulation-entrypoint-smoke.sh" - - ".github/workflows/cloud-run-simulation-entrypoint.yml" + - ".github/scripts/cloud-run-simulation-entry-smoke.sh" + - ".github/workflows/cloud-run-simulation-entry.yml" workflow_dispatch: permissions: contents: read - id-token: write concurrency: - group: cloud-run-simulation-entrypoint + group: cloud-run-simulation-entry cancel-in-progress: false env: - PROJECT_DIR: projects/policyengine-simulation-entrypoint + PROJECT_DIR: projects/policyengine-simulation-entry REGION: ${{ vars.SIMULATION_ENTRYPOINT_GCP_REGION || 'us-central1' }} PROJECT_ID: ${{ vars.SIMULATION_ENTRYPOINT_GCP_PROJECT_ID }} - REPOSITORY: ${{ vars.SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY || 'policyengine-simulation-entrypoint' }} - IMAGE_NAME: policyengine-simulation-entrypoint + REPOSITORY: ${{ vars.SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY || 'policyengine-simulation-entry' }} + IMAGE_NAME: policyengine-simulation-entry jobs: test: @@ -44,6 +43,9 @@ jobs: build: needs: test runs-on: ubuntu-latest + permissions: + contents: read + id-token: write outputs: image: ${{ steps.image.outputs.uri }} steps: @@ -63,6 +65,9 @@ jobs: needs: build runs-on: ubuntu-latest environment: staging + permissions: + contents: read + id-token: write outputs: tag: ${{ steps.metadata.outputs.tag }} url: ${{ steps.url.outputs.url }} @@ -80,7 +85,7 @@ jobs: IMAGE: ${{ needs.build.outputs.image }} TAG: ${{ steps.metadata.outputs.tag }} run: | - gcloud run deploy policyengine-simulation-entrypoint-staging \ + gcloud run deploy policyengine-simulation-entry-staging \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --image "${IMAGE}" \ @@ -89,7 +94,7 @@ jobs: --allow-unauthenticated \ --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ --set-env-vars "^@^APP_ENVIRONMENT=staging@SIMULATION_ENTRYPOINT_PUBLIC_URL=https://staging.simulation.api.policyengine.org@SIMULATION_ENTRYPOINT_AUTH_REQUIRED=1@SIMULATION_ENTRYPOINT_AUTH_ISSUER=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}@SIMULATION_ENTRYPOINT_AUTH_AUDIENCE=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ - --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entrypoint-old-gateway-client-secret-staging:latest" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entry-old-gateway-client-secret-staging:latest" \ --cpu 1 \ --memory 512Mi \ --concurrency 80 \ @@ -100,20 +105,46 @@ jobs: env: TAG: ${{ steps.metadata.outputs.tag }} run: | - url="$(gcloud run services describe policyengine-simulation-entrypoint-staging \ + url="$(gcloud run services describe policyengine-simulation-entry-staging \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --format=json | jq -r --arg tag "${TAG}" '.status.traffic[] | select(.tag == $tag) | .url')" test -n "${url}" echo "url=${url}" >> "$GITHUB_OUTPUT" - - run: bash .github/scripts/cloud-run-simulation-entrypoint-smoke.sh "${{ steps.url.outputs.url }}" + - run: bash .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ steps.url.outputs.url }}" + + authenticated-test-staging: + name: Authenticated deployment test (staging) + needs: deploy-staging + runs-on: ubuntu-latest + environment: staging + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ needs.deploy-staging.outputs.url }} + SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v8.1.0 + - run: uv sync --extra test --frozen + working-directory: ${{ env.PROJECT_DIR }} + - run: uv run pytest authenticated_tests/ -v + working-directory: ${{ env.PROJECT_DIR }} deploy-production-candidate: - needs: [build, deploy-staging] + needs: [build, authenticated-test-staging] runs-on: ubuntu-latest environment: production + permissions: + contents: read + id-token: write outputs: tag: ${{ steps.metadata.outputs.tag }} + url: ${{ steps.url.outputs.url }} steps: - uses: actions/checkout@v6 - uses: google-github-actions/auth@v2 @@ -128,7 +159,7 @@ jobs: IMAGE: ${{ needs.build.outputs.image }} TAG: ${{ steps.metadata.outputs.tag }} run: | - gcloud run deploy policyengine-simulation-entrypoint \ + gcloud run deploy policyengine-simulation-entry \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --image "${IMAGE}" \ @@ -137,20 +168,44 @@ jobs: --allow-unauthenticated \ --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ --set-env-vars "^@^APP_ENVIRONMENT=production@SIMULATION_ENTRYPOINT_PUBLIC_URL=https://simulation.api.policyengine.org@SIMULATION_ENTRYPOINT_AUTH_REQUIRED=1@SIMULATION_ENTRYPOINT_AUTH_ISSUER=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}@SIMULATION_ENTRYPOINT_AUTH_AUDIENCE=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ - --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entrypoint-old-gateway-client-secret-production:latest" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entry-old-gateway-client-secret-production:latest" \ --cpu 1 \ --memory 512Mi \ --concurrency 80 \ --timeout 60 \ --min-instances 1 \ --max-instances 20 - - name: Resolve and smoke candidate + - id: url + name: Resolve candidate URL env: TAG: ${{ steps.metadata.outputs.tag }} run: | - url="$(gcloud run services describe policyengine-simulation-entrypoint \ + url="$(gcloud run services describe policyengine-simulation-entry \ --project "${PROJECT_ID}" \ --region "${REGION}" \ --format=json | jq -r --arg tag "${TAG}" '.status.traffic[] | select(.tag == $tag) | .url')" test -n "${url}" - bash .github/scripts/cloud-run-simulation-entrypoint-smoke.sh "${url}" + echo "url=${url}" >> "$GITHUB_OUTPUT" + - run: bash .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ steps.url.outputs.url }}" + + authenticated-test-production: + name: Authenticated deployment test (production) + needs: deploy-production-candidate + runs-on: ubuntu-latest + environment: production + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ needs.deploy-production-candidate.outputs.url }} + SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v8.1.0 + - run: uv sync --extra test --frozen + working-directory: ${{ env.PROJECT_DIR }} + - run: uv run pytest authenticated_tests/ -v + working-directory: ${{ env.PROJECT_DIR }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b3f78fe18..496fcb1f8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,7 +15,7 @@ jobs: # Modal image installs with uv_sync(frozen=True). project: - projects/policyengine-simulation-executor - - projects/policyengine-simulation-entrypoint + - projects/policyengine-simulation-entry - projects/policyengine-simulation-gateway - libs/policyengine-simulation-contract - libs/policyengine-simulation-observability @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - service: [simulation-entrypoint, simulation-executor] + service: [simulation-entry, simulation-executor] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/publish-clients.yml b/.github/workflows/publish-clients.yml index 8241d675e..d0b987387 100644 --- a/.github/workflows/publish-clients.yml +++ b/.github/workflows/publish-clients.yml @@ -18,6 +18,8 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} - name: Set up Python uses: actions/setup-python@v6 @@ -33,7 +35,7 @@ jobs: - name: Build simulation API client from the entrypoint contract run: | - cd projects/policyengine-simulation-entrypoint/artifacts/clients/python + cd projects/policyengine-simulation-entry/artifacts/clients/python # Update version DATE=$(date +%Y%m%d) RUN_NUMBER="${{ github.run_number }}" @@ -45,4 +47,4 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI }} - packages-dir: projects/policyengine-simulation-entrypoint/artifacts/clients/python/dist/ + packages-dir: projects/policyengine-simulation-entry/artifacts/clients/python/dist/ diff --git a/Makefile b/Makefile index ab4dced9c..4c14462a4 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ publish-clients: generate-clients test: @echo "Running unit tests for all projects and libs..." @for proj in projects/policyengine-simulation-executor \ - projects/policyengine-simulation-entrypoint \ + projects/policyengine-simulation-entry \ projects/policyengine-simulation-gateway \ libs/policyengine-simulation-contract \ libs/policyengine-simulation-observability; do \ diff --git a/README.md b/README.md index 6e8302c5d..9a3054344 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,18 @@ make generate-clients # regenerate the OpenAPI Python client ## Deployment -Deployment targets Modal and is automated through GitHub Actions — there is no -manual deploy step. On merge to `main`, the Modal deploy workflow -(`.github/workflows/modal-deploy.yml`): - -1. Deploys to the beta (staging) Modal environment and runs integration tests. -2. Deploys to the production Modal environment and runs integration tests. -3. Publishes the API client to PyPI (`.github/workflows/publish-clients.yml`). +Simulation workers and the old gateway continue to deploy to Modal. The +permanent Simulation Entrypoint deploys independently to Cloud Run as tagged +no-traffic candidates. On merge to `main`: + +1. `.github/workflows/modal-deploy.yml` deploys the existing gateway/workers. +2. `.github/workflows/cloud-run-simulation-entry.yml` tests and deploys + staging and production Entrypoint candidates, including separate live + authenticated checks. +3. A successful push-triggered Entrypoint deployment publishes the API client + from the exact deployed commit. + +Cloud Run traffic promotion and rollback remain manual operator actions. The stable gateway app is `policyengine-simulation-gateway`; executors deploy as versioned `policyengine-simulation-py{version}` apps. For Modal image and deploy diff --git a/libs/policyengine-simulation-contract/pyproject.toml b/libs/policyengine-simulation-contract/pyproject.toml index 40247c06c..6ed6b0f14 100644 --- a/libs/policyengine-simulation-contract/pyproject.toml +++ b/libs/policyengine-simulation-contract/pyproject.toml @@ -14,8 +14,19 @@ dependencies = [ ] [project.optional-dependencies] -test = [ "pytest>=8.3.4", "pytest-asyncio>=0.25.3", "pytest-cov>=6.1.1", "modal>=1.4,<2",] -build = [ "pyright>=1.1.401", "black>=25.1.0",] +modal = [ + "modal>=1.4,<2", +] +test = [ + "pytest>=8.3.4", + "pytest-asyncio>=0.25.3", + "pytest-cov>=6.1.1", + "modal>=1.4,<2", +] +build = [ + "pyright>=1.1.401", + "black>=25.1.0", +] [build-system] requires = ["hatchling >= 1.26"] diff --git a/libs/policyengine-simulation-contract/uv.lock b/libs/policyengine-simulation-contract/uv.lock index 5dee88f2e..693babea7 100644 --- a/libs/policyengine-simulation-contract/uv.lock +++ b/libs/policyengine-simulation-contract/uv.lock @@ -976,6 +976,9 @@ build = [ { name = "black" }, { name = "pyright" }, ] +modal = [ + { name = "modal" }, +] test = [ { name = "modal" }, { name = "pytest" }, @@ -986,6 +989,7 @@ test = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, @@ -994,7 +998,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, ] -provides-extras = ["test", "build"] +provides-extras = ["modal", "test", "build"] [[package]] name = "policyengine-simulation-observability" diff --git a/projects/policyengine-apis-integ/pyproject.toml b/projects/policyengine-apis-integ/pyproject.toml index 8166ae00c..f3cca65c2 100644 --- a/projects/policyengine-apis-integ/pyproject.toml +++ b/projects/policyengine-apis-integ/pyproject.toml @@ -27,7 +27,7 @@ markers = [ ] [tool.uv.sources] -policyengine_api_simulation_client = { path = "../policyengine-simulation-entrypoint/artifacts/clients/python" } +policyengine_api_simulation_client = { path = "../policyengine-simulation-entry/artifacts/clients/python" } [tool.pyright] #The generated clients do not do the "public export" convention diff --git a/projects/policyengine-apis-integ/uv.lock b/projects/policyengine-apis-integ/uv.lock index 9a564881b..d5cbf664c 100644 --- a/projects/policyengine-apis-integ/uv.lock +++ b/projects/policyengine-apis-integ/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "backoff", marker = "extra == 'test'", specifier = ">=2.2.1" }, { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, - { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-entrypoint/artifacts/clients/python" }, + { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-entry/artifacts/clients/python" }, { name = "pydantic-settings", specifier = ">=2.8.1,<3.0.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", specifier = ">=8.3.4,<9.0.0" }, @@ -263,7 +263,7 @@ test = [{ name = "backoff", specifier = ">=2.2.1" }] [[package]] name = "policyengine-api-simulation-client" version = "1.0.0" -source = { directory = "../policyengine-simulation-entrypoint/artifacts/clients/python" } +source = { directory = "../policyengine-simulation-entry/artifacts/clients/python" } dependencies = [ { name = "attrs" }, { name = "httpx" }, diff --git a/projects/policyengine-simulation-entrypoint/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md similarity index 64% rename from projects/policyengine-simulation-entrypoint/DEPLOYMENT.md rename to projects/policyengine-simulation-entry/DEPLOYMENT.md index 1b5cedbc2..cb46fe181 100644 --- a/projects/policyengine-simulation-entrypoint/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -8,9 +8,9 @@ by the dedicated GitHub Actions workflow. Authenticate as an organization principal that can create projects, attach billing, and administer IAM. Then run: - SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entrypoint \ + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entry \ SIMULATION_ENTRYPOINT_GCP_BILLING_ACCOUNT=BILLING_ACCOUNT_ID \ - bash .github/scripts/bootstrap-cloud-run-simulation-entrypoint.sh + bash .github/scripts/bootstrap-cloud-run-simulation-entry.sh Set SIMULATION_ENTRYPOINT_GCP_FOLDER_ID or SIMULATION_ENTRYPOINT_GCP_ORGANIZATION_ID when project placement is not inherited automatically. @@ -26,29 +26,43 @@ Use SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN=1 to print the commands without chan Add one version to each secret created by the bootstrap: -- simulation-entrypoint-old-gateway-client-secret-staging -- simulation-entrypoint-old-gateway-client-secret-production +- simulation-entry-old-gateway-client-secret-staging +- simulation-entry-old-gateway-client-secret-production Configure the printed project/WIF values as repository variables or secrets. Configure SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT separately in the staging and production GitHub environments. Each environment also supplies its own old gateway URL/client ID and the shared Auth0 issuer/audiences. +For the separate live authentication job, configure an M2M application that is +authorized for the Simulation Entrypoint audience and add these environment +secrets in both staging and production: + +- SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID +- SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET + +The bootstrap restricts workload identity federation to `main` executions of +`.github/workflows/cloud-run-simulation-entry.yml`. Re-running it reconciles an +existing provider to that condition. + ## Deploy candidates The Deploy Simulation Entrypoint to Cloud Run workflow: -1. tests and builds one immutable image; -2. deploys and smoke-tests a tagged no-traffic staging revision; -3. deploys and smoke-tests a tagged no-traffic production revision. +1. runs the normal unit test set and builds one immutable image; +2. deploys and public-smoke-tests a tagged no-traffic staging revision; +3. runs the separate authenticated test set against the staging candidate; +4. deploys and public-smoke-tests a tagged no-traffic production revision; +5. runs the separate authenticated test set against the production candidate. The workflow never changes a service's traffic. After reviewing its smoke -evidence, an authorized operator promotes an exact known-good revision manually: +and authenticated-test evidence, an authorized operator promotes an exact +known-good revision manually: - SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entrypoint \ + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entry \ SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT=staging \ SIMULATION_ENTRYPOINT_TARGET_REVISION=REVISION_NAME \ - bash .github/scripts/set-cloud-run-simulation-entrypoint-revision.sh + bash .github/scripts/set-cloud-run-simulation-entry-revision.sh Use `SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT=production` for production. Rollback uses the same command with the prior known-good revision. The script verifies that @@ -59,8 +73,8 @@ changing traffic. After the first successful deployment creates both services, configure the domain mappings idempotently: - SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entrypoint \ - bash .github/scripts/configure-cloud-run-simulation-entrypoint-domains.sh + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entry \ + bash .github/scripts/configure-cloud-run-simulation-entry-domains.sh Inspect the mappings and publish the returned DNS records for staging.simulation.api.policyengine.org and simulation.api.policyengine.org. diff --git a/projects/policyengine-simulation-entry/Dockerfile b/projects/policyengine-simulation-entry/Dockerfile new file mode 100644 index 000000000..2f500cd3d --- /dev/null +++ b/projects/policyengine-simulation-entry/Dockerfile @@ -0,0 +1,26 @@ +FROM --platform=$BUILDPLATFORM python:3.13-slim AS runtime + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PORT=8080 + +WORKDIR /app + +COPY LICENSE ./LICENSE +COPY libs ./libs/ +COPY projects/policyengine-simulation-entry ./projects/policyengine-simulation-entry/ + +WORKDIR /app/projects/policyengine-simulation-entry +RUN uv sync --frozen --no-dev --no-editable + +RUN addgroup --system simulation-entry \ + && adduser --system --ingroup simulation-entry simulation-entry + +USER simulation-entry + +EXPOSE 8080 + +CMD ["/opt/venv/bin/uvicorn", "policyengine_simulation_entry.app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/projects/policyengine-simulation-entry/Dockerfile.dockerignore b/projects/policyengine-simulation-entry/Dockerfile.dockerignore new file mode 100644 index 000000000..0740fe81c --- /dev/null +++ b/projects/policyengine-simulation-entry/Dockerfile.dockerignore @@ -0,0 +1,23 @@ +.git + +projects/* +!projects/policyengine-simulation-entry/ +!projects/policyengine-simulation-entry/** +projects/policyengine-simulation-entry/artifacts +projects/policyengine-simulation-entry/authenticated_tests +projects/policyengine-simulation-entry/tests + +libs/* +!libs/policyengine-simulation-contract/ +!libs/policyengine-simulation-contract/** +!libs/policyengine-simulation-observability/ +!libs/policyengine-simulation-observability/** +libs/**/tests + +# Keep these after the negated project/library rules so local build artifacts +# cannot be re-included by the broader allowlists above. +**/.venv +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +**/*.pyc diff --git a/projects/policyengine-simulation-entrypoint/README.md b/projects/policyengine-simulation-entry/README.md similarity index 58% rename from projects/policyengine-simulation-entrypoint/README.md rename to projects/policyengine-simulation-entry/README.md index 2f6eb4dfc..0a5a12a0a 100644 --- a/projects/policyengine-simulation-entrypoint/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -1,4 +1,4 @@ -# policyengine-simulation-entrypoint +# policyengine-simulation-entry The permanent Cloud Run entrypoint for PolicyEngine simulation submission and polling. @@ -14,5 +14,7 @@ compute dispatch. uv sync --extra test uv run pytest tests/ -v -The production app is policyengine_simulation_entrypoint.app:app. Required runtime -configuration is documented in policyengine_simulation_entrypoint.config.Settings. +The production app is policyengine_simulation_entry.app:app. Required runtime +configuration is documented in policyengine_simulation_entry.config.Settings. +Live caller/backend authentication checks are isolated in `authenticated_tests` +and run only against tagged Cloud Run candidates in the deployment workflow. diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py b/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py new file mode 100644 index 000000000..e2c8accd3 --- /dev/null +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py @@ -0,0 +1,86 @@ +"""Live authentication checks for a deployed Simulation Entrypoint candidate.""" + +from __future__ import annotations + +import os + +import httpx +import pytest + + +PROTECTED_PATH = "/simulate/economy/comparison" +PROBE_PAYLOAD = { + "country": "__authentication_probe__", + "scope": "macro", + "reform": {}, +} + + +def _required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"{name} is required for authenticated deployment tests.") + return value + + +@pytest.fixture(scope="session") +def base_url() -> str: + return _required("SIMULATION_ENTRYPOINT_TEST_BASE_URL").rstrip("/") + + +@pytest.fixture(scope="session") +def access_token() -> str: + issuer = _required("SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER").rstrip("/") + response = httpx.post( + f"{issuer}/oauth/token", + json={ + "client_id": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID"), + "client_secret": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET"), + "audience": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE"), + "grant_type": "client_credentials", + }, + timeout=30, + ) + response.raise_for_status() + token = response.json().get("access_token") + if not isinstance(token, str) or not token: + raise RuntimeError("Auth0 response omitted access_token.") + return token + + +def test_protected_route_rejects_missing_token(base_url: str): + response = httpx.post( + f"{base_url}{PROTECTED_PATH}", + json=PROBE_PAYLOAD, + timeout=30, + ) + + assert response.status_code == 403 + + +def test_protected_route_rejects_invalid_token(base_url: str): + response = httpx.post( + f"{base_url}{PROTECTED_PATH}", + json=PROBE_PAYLOAD, + headers={"Authorization": "Bearer not-a-valid-jwt"}, + timeout=30, + ) + + assert response.status_code == 403 + + +def test_valid_token_reaches_authenticated_old_gateway( + base_url: str, + access_token: str, +): + response = httpx.post( + f"{base_url}{PROTECTED_PATH}", + json=PROBE_PAYLOAD, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + # The deliberately unknown country is rejected before any simulation is + # submitted. Reaching this 400 proves both authentication hops succeeded. + assert response.status_code == 400 + assert response.headers["x-policyengine-simulation-backend"] == "old_gateway" diff --git a/projects/policyengine-simulation-entrypoint/openapi-python-client.yaml b/projects/policyengine-simulation-entry/openapi-python-client.yaml similarity index 100% rename from projects/policyengine-simulation-entrypoint/openapi-python-client.yaml rename to projects/policyengine-simulation-entry/openapi-python-client.yaml diff --git a/projects/policyengine-simulation-entrypoint/pyproject.toml b/projects/policyengine-simulation-entry/pyproject.toml similarity index 92% rename from projects/policyengine-simulation-entrypoint/pyproject.toml rename to projects/policyengine-simulation-entry/pyproject.toml index 7cf2c606c..568ba3849 100644 --- a/projects/policyengine-simulation-entrypoint/pyproject.toml +++ b/projects/policyengine-simulation-entry/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "policyengine-simulation-entrypoint" +name = "policyengine-simulation-entry" version = "0.1.0" readme = "README.md" authors = [ @@ -23,7 +23,7 @@ dependencies = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/policyengine_simulation_entrypoint"] +packages = ["src/policyengine_simulation_entry"] [tool.uv.sources] policyengine-simulation-contract = { path = "../../libs/policyengine-simulation-contract", editable = true } diff --git a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/__init__.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/__init__.py similarity index 56% rename from projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/__init__.py rename to projects/policyengine-simulation-entry/src/policyengine_simulation_entry/__init__.py index 8ee908e84..722f82b54 100644 --- a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/__init__.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/__init__.py @@ -1,5 +1,5 @@ """PolicyEngine Cloud Run Simulation Entrypoint.""" -from policyengine_simulation_entrypoint.app import create_app +from policyengine_simulation_entry.app import create_app __all__ = ["create_app"] diff --git a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py similarity index 95% rename from projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/app.py rename to projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index 4c92d7a96..3e85a8965 100644 --- a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -27,8 +27,8 @@ init_simulation_observability, ) -from policyengine_simulation_entrypoint.auth import CallerAuthenticator -from policyengine_simulation_entrypoint.backend import ( +from policyengine_simulation_entry.auth import CallerAuthenticator +from policyengine_simulation_entry.backend import ( BackendAuthenticationError, BackendResponse, BackendTimeout, @@ -36,7 +36,7 @@ OldGatewayBackend, SimulationBackend, ) -from policyengine_simulation_entrypoint.config import Settings +from policyengine_simulation_entry.config import Settings logger = logging.getLogger(__name__) @@ -103,9 +103,9 @@ async def lifespan(_: FastAPI): configure_process_observability( platform="cloud_run", - service_role="simulation_entrypoint", + service_role="simulation_entry", ) - init_simulation_observability(app, service_role="simulation_entrypoint") + init_simulation_observability(app, service_role="simulation_entry") @app.middleware("http") async def request_context(request: Request, call_next): @@ -116,7 +116,7 @@ async def request_context(request: Request, call_next): elapsed_ms = round((time.monotonic() - started) * 1000, 2) response.headers["X-Request-ID"] = request_id logger.info( - "simulation_entrypoint_request", + "simulation_entry_request", extra={ "request_id": request_id, "method": request.method, @@ -177,11 +177,11 @@ async def forward( ) if response_identifier_key and isinstance(response_identifier, str): attributes[response_identifier_key] = response_identifier - record_event("simulation_entrypoint_backend_response", **attributes) + record_event("simulation_entry_backend_response", **attributes) return _response(result) except BackendTimeout: record_event( - "simulation_entrypoint_backend_timeout", + "simulation_entry_backend_timeout", request_id=request.state.request_id, route=route, **event_identifiers, @@ -196,7 +196,7 @@ async def forward( ) except (BackendUnavailable, BackendAuthenticationError): record_event( - "simulation_entrypoint_backend_unavailable", + "simulation_entry_backend_unavailable", request_id=request.state.request_id, route=route, **event_identifiers, @@ -335,8 +335,9 @@ async def get_budget_window_job( summary="List Versions", description="List all available routing versions.", operation_id="list_versions_versions_get", + response_model=dict, ) - async def list_versions(request: Request) -> dict: + async def list_versions(request: Request) -> Response: return await forward(request, "GET", "/versions") @app.get( @@ -344,8 +345,9 @@ async def list_versions(request: Request) -> dict: summary="Get Country Versions", description="Get available versions for policyengine, US, or UK routing.", operation_id="get_country_versions_versions__kind__get", + response_model=dict, ) - async def get_country_versions(kind: str, request: Request) -> dict: + async def get_country_versions(kind: str, request: Request) -> Response: return await forward(request, "GET", f"/versions/{kind}") @app.get( diff --git a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/auth.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py similarity index 88% rename from projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/auth.py rename to projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py index aba9a1006..b7393d57d 100644 --- a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/auth.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py @@ -10,7 +10,7 @@ import jwt from policyengine_observability import record_event -from policyengine_simulation_entrypoint.config import Settings +from policyengine_simulation_entry.config import Settings _bearer = HTTPBearer(auto_error=False) @@ -30,7 +30,7 @@ def __call__( token: HTTPAuthorizationCredentials | None, ) -> dict[str, str]: if token is None: - record_event("simulation_entrypoint_auth_rejected", reason="missing_token") + record_event("simulation_entry_auth_rejected", reason="missing_token") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) try: @@ -47,10 +47,10 @@ def __call__( except Exception as error: reason = type(error).__name__ logger.info( - "invalid_simulation_entrypoint_bearer_token", + "invalid_simulation_entry_bearer_token", extra={"error_type": reason}, ) - record_event("simulation_entrypoint_auth_rejected", reason=reason) + record_event("simulation_entry_auth_rejected", reason=reason) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from error diff --git a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/backend.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py similarity index 88% rename from projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/backend.py rename to projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py index 6418d9c9f..1a7546e37 100644 --- a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/backend.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py @@ -9,7 +9,7 @@ import httpx -from policyengine_simulation_entrypoint.config import Settings +from policyengine_simulation_entry.config import Settings SAFE_RESPONSE_HEADERS = frozenset( @@ -177,11 +177,10 @@ async def request( ) -> BackendResponse: client, token_provider = self._runtime() method = method.upper() - max_attempts = 2 if method == "GET" else 1 - attempt = 0 + connection_retries_remaining = 1 if method == "GET" else 0 + auth_refreshes_remaining = 1 - while attempt < max_attempts: - attempt += 1 + while True: token = await token_provider.get_token() headers = {"Authorization": f"Bearer {token}"} if request_id: @@ -195,7 +194,8 @@ async def request( headers=headers, ) except (httpx.ConnectError, httpx.ConnectTimeout) as exc: - if method == "GET" and attempt < max_attempts: + if connection_retries_remaining: + connection_retries_remaining -= 1 continue raise BackendUnavailable("Old gateway is unavailable.") from exc except httpx.TimeoutException as exc: @@ -203,12 +203,17 @@ async def request( except httpx.RequestError as exc: raise BackendUnavailable("Old gateway is unavailable.") from exc - if response.status_code == 401 and attempt == 1: - token_provider.invalidate() - # A 401 is rejected before endpoint processing, so one - # credential-refresh replay is safe for POST as well as GET. - max_attempts = 2 - continue + if response.status_code in {401, 403}: + if auth_refreshes_remaining: + auth_refreshes_remaining -= 1 + token_provider.invalidate() + # The old gateway rejects invalid bearer credentials with + # 403 before endpoint processing. One credential-refresh + # replay is therefore safe for POST as well as GET. + continue + raise BackendAuthenticationError( + "Old gateway rejected refreshed service credentials." + ) return BackendResponse( status_code=response.status_code, @@ -219,7 +224,3 @@ async def request( if key.lower() in SAFE_RESPONSE_HEADERS }, ) - - raise BackendAuthenticationError( - "Old gateway rejected refreshed service credentials." - ) diff --git a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py similarity index 82% rename from projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/config.py rename to projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index c5b2c401d..74e26ee71 100644 --- a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/config.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -8,6 +8,7 @@ PRODUCTION_ENVIRONMENTS = frozenset({"main", "prod", "production"}) +MODAL_GATEWAY_HOST_SUFFIX = ".modal.run" class ConfigurationError(RuntimeError): @@ -106,9 +107,22 @@ def validate(self) -> None: if self.connect_timeout_seconds <= 0 or self.request_timeout_seconds <= 0: raise ConfigurationError("Old-gateway timeouts must be positive.") - public_host = urlparse(self.public_url).hostname - upstream_host = urlparse(self.old_gateway_url).hostname - if public_host and upstream_host and public_host == upstream_host: + if not self.public_url: + raise ConfigurationError("SIMULATION_ENTRYPOINT_PUBLIC_URL is required.") + + public = urlparse(self.public_url) + upstream = urlparse(self.old_gateway_url) + if public.scheme != "https" or not public.hostname: + raise ConfigurationError( + "SIMULATION_ENTRYPOINT_PUBLIC_URL must be an absolute HTTPS URL." + ) + if upstream.scheme != "https" or not upstream.hostname: + raise ConfigurationError("OLD_GATEWAY_URL must be an absolute HTTPS URL.") + if not upstream.hostname.endswith(MODAL_GATEWAY_HOST_SUFFIX): + raise ConfigurationError( + "OLD_GATEWAY_URL must point to the existing Modal gateway." + ) + if public.hostname == upstream.hostname: raise ConfigurationError( "OLD_GATEWAY_URL must not point to the Simulation Entrypoint itself." ) diff --git a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/generate_openapi.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/generate_openapi.py similarity index 89% rename from projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/generate_openapi.py rename to projects/policyengine-simulation-entry/src/policyengine_simulation_entry/generate_openapi.py index e4d52ee61..732ed5167 100644 --- a/projects/policyengine-simulation-entrypoint/src/policyengine_simulation_entrypoint/generate_openapi.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/generate_openapi.py @@ -5,7 +5,7 @@ import json from pathlib import Path -from policyengine_simulation_entrypoint.app import create_app +from policyengine_simulation_entry.app import create_app def main() -> None: diff --git a/projects/policyengine-simulation-entrypoint/tests/conftest.py b/projects/policyengine-simulation-entry/tests/conftest.py similarity index 82% rename from projects/policyengine-simulation-entrypoint/tests/conftest.py rename to projects/policyengine-simulation-entry/tests/conftest.py index 2b057bc68..e3121d6f0 100644 --- a/projects/policyengine-simulation-entrypoint/tests/conftest.py +++ b/projects/policyengine-simulation-entry/tests/conftest.py @@ -6,9 +6,9 @@ import pytest from fastapi.testclient import TestClient -from policyengine_simulation_entrypoint.app import create_app -from policyengine_simulation_entrypoint.backend import BackendResponse -from policyengine_simulation_entrypoint.config import Settings +from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.backend import BackendResponse +from policyengine_simulation_entry.config import Settings def make_settings(**overrides) -> Settings: @@ -17,11 +17,13 @@ def make_settings(**overrides) -> Settings: "public_url": "https://simulation.example.test", "auth_required": True, "auth_issuer": "https://issuer.example/", - "auth_audience": "simulation-entrypoint", - "old_gateway_url": "https://old-gateway.example.test", + "auth_audience": "simulation-entry", + "old_gateway_url": ( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" + ), "old_gateway_auth_issuer": "https://issuer.example/", "old_gateway_auth_audience": "simulation-api", - "old_gateway_auth_client_id": "simulation-entrypoint-service", + "old_gateway_auth_client_id": "simulation-entry-service", "old_gateway_auth_client_secret": "secret", } values.update(overrides) diff --git a/projects/policyengine-simulation-entrypoint/tests/test_app.py b/projects/policyengine-simulation-entry/tests/test_app.py similarity index 95% rename from projects/policyengine-simulation-entrypoint/tests/test_app.py rename to projects/policyengine-simulation-entry/tests/test_app.py index d1d50bbdc..abac4fc7f 100644 --- a/projects/policyengine-simulation-entrypoint/tests/test_app.py +++ b/projects/policyengine-simulation-entry/tests/test_app.py @@ -5,9 +5,9 @@ import pytest -from policyengine_simulation_entrypoint import app as app_module -from policyengine_simulation_entrypoint.app import create_app -from policyengine_simulation_entrypoint.backend import ( +from policyengine_simulation_entry import app as app_module +from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.backend import ( BackendResponse, BackendTimeout, BackendUnavailable, @@ -100,7 +100,7 @@ def test_job_status_records_structured_backend_telemetry( client.get("/jobs/fc-123") name, attributes = events[-1] - assert name == "simulation_entrypoint_backend_response" + assert name == "simulation_entry_backend_response" assert attributes["job_state"] == "running" assert attributes["status_code"] == 202 assert attributes["route"] == "/jobs/{job_id}" @@ -128,7 +128,7 @@ def test_request_log_templates_route_and_keeps_structured_job_id( request_record = next( record for record in caplog.records - if record.getMessage() == "simulation_entrypoint_request" + if record.getMessage() == "simulation_entry_request" ) assert request_record.path == "/jobs/{job_id}" assert request_record.job_id == "fc-123" diff --git a/projects/policyengine-simulation-entrypoint/tests/test_auth.py b/projects/policyengine-simulation-entry/tests/test_auth.py similarity index 94% rename from projects/policyengine-simulation-entrypoint/tests/test_auth.py rename to projects/policyengine-simulation-entry/tests/test_auth.py index b90335d12..c87553f6b 100644 --- a/projects/policyengine-simulation-entrypoint/tests/test_auth.py +++ b/projects/policyengine-simulation-entry/tests/test_auth.py @@ -8,8 +8,8 @@ from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient -from policyengine_simulation_entrypoint import auth as auth_module -from policyengine_simulation_entrypoint.app import create_app +from policyengine_simulation_entry import auth as auth_module +from policyengine_simulation_entry.app import create_app from conftest import FakeBackend, make_settings @@ -25,7 +25,7 @@ def _token(private_key, **overrides) -> str: claims = { "sub": "api-v1", "iss": "https://issuer.example/", - "aud": "simulation-entrypoint", + "aud": "simulation-entry", "iat": now, "exp": now + timedelta(minutes=5), } @@ -36,7 +36,7 @@ def _token(private_key, **overrides) -> str: def _use_static_signing_key(monkeypatch, public_key) -> None: decoder = auth_module.JWTDecoder( issuer="https://issuer.example/", - audience="simulation-entrypoint", + audience="simulation-entry", ) decoder.jwks_client = SimpleNamespace( get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) diff --git a/projects/policyengine-simulation-entrypoint/tests/test_backend.py b/projects/policyengine-simulation-entry/tests/test_backend.py similarity index 81% rename from projects/policyengine-simulation-entrypoint/tests/test_backend.py rename to projects/policyengine-simulation-entry/tests/test_backend.py index 56051a357..74219b09b 100644 --- a/projects/policyengine-simulation-entrypoint/tests/test_backend.py +++ b/projects/policyengine-simulation-entry/tests/test_backend.py @@ -5,7 +5,8 @@ import httpx import pytest -from policyengine_simulation_entrypoint.backend import ( +from policyengine_simulation_entry.backend import ( + BackendAuthenticationError, BackendUnavailable, OldGatewayBackend, ) @@ -61,7 +62,10 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_backend_refreshes_token_once_after_401(): +@pytest.mark.parametrize("rejection_status", [401, 403]) +async def test_backend_refreshes_token_once_after_gateway_auth_rejection( + rejection_status, +): token_fetches = 0 gateway_calls = 0 @@ -78,7 +82,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) gateway_calls += 1 if gateway_calls == 1: - return httpx.Response(401) + return httpx.Response(rejection_status) return httpx.Response(200, json={"status": "complete"}) backend = OldGatewayBackend( @@ -96,6 +100,37 @@ def handler(request: httpx.Request) -> httpx.Response: assert gateway_calls == 2 +@pytest.mark.asyncio +async def test_repeated_gateway_auth_rejection_is_backend_unavailable(): + token_fetches = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_fetches + if request.url.path == "/oauth/token": + token_fetches += 1 + return httpx.Response( + 200, + json={ + "access_token": f"token-{token_fetches}", + "expires_in": 3600, + }, + ) + return httpx.Response(403) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + with pytest.raises(BackendAuthenticationError): + await backend.request("POST", "/simulate/economy/comparison") + finally: + await backend.close() + + assert token_fetches == 2 + + @pytest.mark.asyncio async def test_short_lived_backend_token_is_never_cached_past_expiry(): token_fetches = 0 diff --git a/projects/policyengine-simulation-entry/tests/test_config.py b/projects/policyengine-simulation-entry/tests/test_config.py new file mode 100644 index 000000000..cff626dea --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_config.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest + +from policyengine_simulation_entry.config import ConfigurationError + +from conftest import make_settings + + +def test_production_refuses_disabled_auth(): + with pytest.raises(ConfigurationError, match="cannot be disabled"): + make_settings(environment="production", auth_required=False).validate() + + +def test_partial_caller_auth_is_rejected(): + with pytest.raises(ConfigurationError, match="Set both"): + make_settings(auth_audience="").validate() + + +def test_recursive_gateway_url_is_rejected(): + with pytest.raises(ConfigurationError, match="must not point"): + make_settings( + public_url=( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" + ), + old_gateway_url=( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" + ), + ).validate() + + +def test_public_url_is_required(): + with pytest.raises(ConfigurationError, match="PUBLIC_URL is required"): + make_settings(public_url="").validate() + + +@pytest.mark.parametrize( + "old_gateway_url", + [ + "https://policyengine-simulation-entry-abc-uc.a.run.app", + "http://policyengine--policyengine-simulation-gateway-web-app.modal.run", + "not-a-url", + ], +) +def test_old_gateway_must_be_the_https_modal_service(old_gateway_url): + with pytest.raises(ConfigurationError, match="OLD_GATEWAY_URL must"): + make_settings(old_gateway_url=old_gateway_url).validate() + + +def test_complete_configuration_is_valid(): + make_settings().validate() diff --git a/projects/policyengine-simulation-entrypoint/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py similarity index 53% rename from projects/policyengine-simulation-entrypoint/tests/test_deployment_assets.py rename to projects/policyengine-simulation-entry/tests/test_deployment_assets.py index 01a57de33..29b6fd54b 100644 --- a/projects/policyengine-simulation-entrypoint/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -8,30 +8,32 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3] BOOTSTRAP_SCRIPT = ( - REPOSITORY_ROOT - / ".github" - / "scripts" - / "bootstrap-cloud-run-simulation-entrypoint.sh" + REPOSITORY_ROOT / ".github" / "scripts" / "bootstrap-cloud-run-simulation-entry.sh" ) DOMAIN_SCRIPT = ( REPOSITORY_ROOT / ".github" / "scripts" - / "configure-cloud-run-simulation-entrypoint-domains.sh" + / "configure-cloud-run-simulation-entry-domains.sh" ) TRAFFIC_SCRIPT = ( REPOSITORY_ROOT / ".github" / "scripts" - / "set-cloud-run-simulation-entrypoint-revision.sh" + / "set-cloud-run-simulation-entry-revision.sh" ) -WORKFLOW = ( - REPOSITORY_ROOT / ".github" / "workflows" / "cloud-run-simulation-entrypoint.yml" +WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "cloud-run-simulation-entry.yml" +PUBLISH_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "publish-clients.yml" +AUTHENTICATED_TESTS = ( + REPOSITORY_ROOT + / "projects" + / "policyengine-simulation-entry" + / "authenticated_tests" ) DOCKERIGNORE = ( REPOSITORY_ROOT / "projects" - / "policyengine-simulation-entrypoint" + / "policyengine-simulation-entry" / "Dockerfile.dockerignore" ) @@ -45,7 +47,7 @@ def test_bootstrap_script_has_valid_shell_syntax(): def test_bootstrap_dry_run_is_self_contained(): environment = { **os.environ, - "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entrypoint-test", + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", "SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN": "1", } @@ -57,30 +59,62 @@ def test_bootstrap_dry_run_is_self_contained(): env=environment, ) - assert "projects create simulation-entrypoint-test" in result.stdout + assert "projects create simulation-entry-test" in result.stdout assert ( - "artifacts repositories create policyengine-simulation-entrypoint" - in result.stdout + "artifacts repositories create policyengine-simulation-entry" in result.stdout ) assert "roles/serviceusage.serviceUsageConsumer" in result.stdout assert "Bootstrap complete" in result.stdout assert "000000000000" in result.stdout +def test_bootstrap_rejects_invalid_project_id(): + result = subprocess.run( + ["bash", BOOTSTRAP_SCRIPT], + check=False, + capture_output=True, + text=True, + env={ + **os.environ, + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": ( + "policyengine-simulation-entry-too-long" + ), + "SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN": "1", + }, + ) + + assert result.returncode == 2 + assert "valid 6-30 character GCP project ID" in result.stderr + + +def test_workload_identity_is_limited_to_main_deployment_workflow(): + bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8") + + assert "assertion.ref == 'refs/heads/main'" in bootstrap + assert "assertion.workflow_ref ==" in bootstrap + assert ( + ".github/workflows/cloud-run-simulation-entry.yml@refs/heads/main" in bootstrap + ) + assert "providers update-oidc" in bootstrap + + def test_deployment_uses_gcloud_workflow_without_terraform(): workflow = WORKFLOW.read_text(encoding="utf-8") assert "gcloud run deploy" in workflow assert "update-traffic" not in workflow - assert "set-cloud-run-simulation-entrypoint-revision.sh" not in workflow + assert "set-cloud-run-simulation-entry-revision.sh" not in workflow assert "terraform" not in workflow.lower() + assert "authenticated-test-staging:" in workflow + assert "authenticated-test-production:" in workflow + assert "needs: deploy-staging" in workflow + assert "needs: deploy-production-candidate" in workflow + assert workflow.count("id-token: write") == 3 + assert AUTHENTICATED_TESTS.joinpath("test_deployed_auth.py").is_file() assert not list( - ( - REPOSITORY_ROOT - / "projects" - / "policyengine-simulation-entrypoint" - / "infra" - ).glob("*.tf") + (REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" / "infra").glob( + "*.tf" + ) ) @@ -100,15 +134,15 @@ def test_traffic_changes_are_operator_run_and_revision_validated(): text=True, env={ **os.environ, - "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entrypoint-test", + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", "SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT": "production", - "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entrypoint-00001-abc", + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00001-abc", "SIMULATION_ENTRYPOINT_TRAFFIC_DRY_RUN": "1", }, ) - assert "policyengine-simulation-entrypoint-00001-abc=100" in result.stdout - assert "policyengine-simulation-entrypoint-staging" not in result.stdout + assert "policyengine-simulation-entry-00001-abc=100" in result.stdout + assert "policyengine-simulation-entry-staging" not in result.stdout def test_container_context_excludes_local_environments_and_unrelated_projects(): @@ -116,17 +150,21 @@ def test_container_context_excludes_local_environments_and_unrelated_projects(): assert "**/.venv" in ignore_rules assert "projects/*" in ignore_rules - assert "!projects/policyengine-simulation-entrypoint/**" in ignore_rules + assert "!projects/policyengine-simulation-entry/**" in ignore_rules + assert "projects/policyengine-simulation-entry/authenticated_tests" in ignore_rules assert "libs/*" in ignore_rules + assert ignore_rules.index("**/.venv") > ignore_rules.index( + "!projects/policyengine-simulation-entry/**" + ) + assert ignore_rules.index("**/.venv") > ignore_rules.index( + "!libs/policyengine-simulation-contract/**" + ) def test_production_lock_excludes_modal_and_database_runtime_packages(): lockfile = tomllib.loads( ( - REPOSITORY_ROOT - / "projects" - / "policyengine-simulation-entrypoint" - / "uv.lock" + REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" / "uv.lock" ).read_text(encoding="utf-8") ) resolved_packages = {package["name"] for package in lockfile["package"]} @@ -143,12 +181,18 @@ def test_domain_mapping_dry_run_targets_both_services(): text=True, env={ **os.environ, - "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entrypoint-test", + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", "SIMULATION_ENTRYPOINT_DOMAINS_DRY_RUN": "1", }, ) - assert "policyengine-simulation-entrypoint-staging" in result.stdout + assert "policyengine-simulation-entry-staging" in result.stdout assert "staging.simulation.api.policyengine.org" in result.stdout - assert "policyengine-simulation-entrypoint" in result.stdout + assert "policyengine-simulation-entry" in result.stdout assert "simulation.api.policyengine.org" in result.stdout + + +def test_client_publication_checks_out_the_deployed_commit(): + workflow = PUBLISH_WORKFLOW.read_text(encoding="utf-8") + + assert "github.event.workflow_run.head_sha" in workflow diff --git a/projects/policyengine-simulation-entrypoint/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py similarity index 97% rename from projects/policyengine-simulation-entrypoint/tests/test_openapi.py rename to projects/policyengine-simulation-entry/tests/test_openapi.py index 7cffedaf7..285c1a888 100644 --- a/projects/policyengine-simulation-entrypoint/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from policyengine_simulation_entrypoint.app import create_app +from policyengine_simulation_entry.app import create_app EXPECTED_ROUTES = { diff --git a/projects/policyengine-simulation-entrypoint/uv.lock b/projects/policyengine-simulation-entry/uv.lock similarity index 99% rename from projects/policyengine-simulation-entrypoint/uv.lock rename to projects/policyengine-simulation-entry/uv.lock index a15c43ccb..d72a01a7f 100644 --- a/projects/policyengine-simulation-entrypoint/uv.lock +++ b/projects/policyengine-simulation-entry/uv.lock @@ -646,7 +646,30 @@ fastapi = [ ] [[package]] -name = "policyengine-simulation-entrypoint" +name = "policyengine-simulation-contract" +version = "0.1.0" +source = { editable = "../../libs/policyengine-simulation-contract" } +dependencies = [ + { name = "policyengine-simulation-observability" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, + { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["modal", "test", "build"] + +[[package]] +name = "policyengine-simulation-entry" version = "0.1.0" source = { editable = "." } dependencies = [ @@ -689,28 +712,6 @@ requires-dist = [ ] provides-extras = ["test", "build"] -[[package]] -name = "policyengine-simulation-contract" -version = "0.1.0" -source = { editable = "../../libs/policyengine-simulation-contract" } -dependencies = [ - { name = "policyengine-simulation-observability" }, - { name = "pydantic" }, -] - -[package.metadata] -requires-dist = [ - { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, - { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, - { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, - { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, - { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, -] -provides-extras = ["test", "build"] - [[package]] name = "policyengine-simulation-observability" version = "0.1.0" diff --git a/projects/policyengine-simulation-entrypoint/Dockerfile b/projects/policyengine-simulation-entrypoint/Dockerfile deleted file mode 100644 index 45009fb21..000000000 --- a/projects/policyengine-simulation-entrypoint/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM --platform=$BUILDPLATFORM python:3.13-slim AS runtime - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - UV_PROJECT_ENVIRONMENT=/opt/venv \ - PORT=8080 - -WORKDIR /app - -COPY LICENSE ./LICENSE -COPY libs ./libs/ -COPY projects/policyengine-simulation-entrypoint ./projects/policyengine-simulation-entrypoint/ - -WORKDIR /app/projects/policyengine-simulation-entrypoint -RUN uv sync --frozen --no-dev --no-editable - -RUN addgroup --system simulation-entrypoint \ - && adduser --system --ingroup simulation-entrypoint simulation-entrypoint - -USER simulation-entrypoint - -EXPOSE 8080 - -CMD ["/opt/venv/bin/uvicorn", "policyengine_simulation_entrypoint.app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/projects/policyengine-simulation-entrypoint/Dockerfile.dockerignore b/projects/policyengine-simulation-entrypoint/Dockerfile.dockerignore deleted file mode 100644 index 4581b9896..000000000 --- a/projects/policyengine-simulation-entrypoint/Dockerfile.dockerignore +++ /dev/null @@ -1,19 +0,0 @@ -.git -**/.venv -**/__pycache__ -**/.pytest_cache -**/.ruff_cache -**/*.pyc - -projects/* -!projects/policyengine-simulation-entrypoint/ -!projects/policyengine-simulation-entrypoint/** -projects/policyengine-simulation-entrypoint/artifacts -projects/policyengine-simulation-entrypoint/tests - -libs/* -!libs/policyengine-simulation-contract/ -!libs/policyengine-simulation-contract/** -!libs/policyengine-simulation-observability/ -!libs/policyengine-simulation-observability/** -libs/**/tests diff --git a/projects/policyengine-simulation-entrypoint/tests/test_config.py b/projects/policyengine-simulation-entrypoint/tests/test_config.py deleted file mode 100644 index 620d12383..000000000 --- a/projects/policyengine-simulation-entrypoint/tests/test_config.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -import pytest - -from policyengine_simulation_entrypoint.config import ConfigurationError - -from conftest import make_settings - - -def test_production_refuses_disabled_auth(): - with pytest.raises(ConfigurationError, match="cannot be disabled"): - make_settings(environment="production", auth_required=False).validate() - - -def test_partial_caller_auth_is_rejected(): - with pytest.raises(ConfigurationError, match="Set both"): - make_settings(auth_audience="").validate() - - -def test_recursive_gateway_url_is_rejected(): - with pytest.raises(ConfigurationError, match="must not point"): - make_settings( - old_gateway_url="https://simulation.example.test", - ).validate() - - -def test_complete_configuration_is_valid(): - make_settings().validate() diff --git a/projects/policyengine-simulation-executor/pyproject.toml b/projects/policyengine-simulation-executor/pyproject.toml index 507616d70..c8dff4a7a 100644 --- a/projects/policyengine-simulation-executor/pyproject.toml +++ b/projects/policyengine-simulation-executor/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "opentelemetry-instrumentation-fastapi (>=0.51b0,<0.52)", "policyengine-fastapi", "policyengine-simulation-observability", - "policyengine-simulation-contract", + "policyengine-simulation-contract[modal]", "policyengine==4.22.0", "policyengine-core==3.30.0", "policyengine-uk==2.89.2", diff --git a/projects/policyengine-simulation-executor/uv.lock b/projects/policyengine-simulation-executor/uv.lock index bd9a0406a..8f0f76fdc 100644 --- a/projects/policyengine-simulation-executor/uv.lock +++ b/projects/policyengine-simulation-executor/uv.lock @@ -1834,9 +1834,15 @@ dependencies = [ { name = "pydantic" }, ] +[package.optional-dependencies] +modal = [ + { name = "modal" }, +] + [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, @@ -1845,7 +1851,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, ] -provides-extras = ["test", "build"] +provides-extras = ["modal", "test", "build"] [[package]] name = "policyengine-simulation-executor" @@ -1860,7 +1866,7 @@ dependencies = [ { name = "policyengine-core" }, { name = "policyengine-fastapi" }, { name = "policyengine-observability", extra = ["fastapi"] }, - { name = "policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extra = ["modal"] }, { name = "policyengine-simulation-observability" }, { name = "policyengine-uk" }, { name = "policyengine-us" }, @@ -1906,7 +1912,7 @@ requires-dist = [ { name = "policyengine-core", specifier = "==3.30.0" }, { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, { name = "policyengine-observability", extras = ["fastapi"], specifier = ">=1.3.0,<2" }, - { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "policyengine-uk", specifier = "==2.89.2" }, { name = "policyengine-us", specifier = "==1.764.6" }, @@ -1969,7 +1975,7 @@ provides-extras = ["test", "build"] [package.metadata.requires-dev] dev = [ { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, - { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, ] diff --git a/projects/policyengine-simulation-gateway/pyproject.toml b/projects/policyengine-simulation-gateway/pyproject.toml index dbe323f0f..ba54553a3 100644 --- a/projects/policyengine-simulation-gateway/pyproject.toml +++ b/projects/policyengine-simulation-gateway/pyproject.toml @@ -42,7 +42,7 @@ packages = ["src/policyengine_simulation_gateway"] [dependency-groups] dev = [ "policyengine-fastapi", - "policyengine-simulation-contract", + "policyengine-simulation-contract[modal]", "policyengine-simulation-observability", ] diff --git a/projects/policyengine-simulation-gateway/uv.lock b/projects/policyengine-simulation-gateway/uv.lock index 0b67dd2f3..3234a9920 100644 --- a/projects/policyengine-simulation-gateway/uv.lock +++ b/projects/policyengine-simulation-gateway/uv.lock @@ -1293,9 +1293,15 @@ dependencies = [ { name = "pydantic" }, ] +[package.optional-dependencies] +modal = [ + { name = "modal" }, +] + [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, @@ -1304,7 +1310,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, ] -provides-extras = ["test", "build"] +provides-extras = ["modal", "test", "build"] [[package]] name = "policyengine-simulation-gateway" @@ -1337,7 +1343,7 @@ test = [ [package.dev-dependencies] dev = [ { name = "policyengine-fastapi" }, - { name = "policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extra = ["modal"] }, { name = "policyengine-simulation-observability" }, ] @@ -1364,7 +1370,7 @@ provides-extras = ["test", "build"] [package.metadata.requires-dev] dev = [ { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, - { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, ] diff --git a/scripts/generate-clients.sh b/scripts/generate-clients.sh index 4143c61b7..3130a6364 100755 --- a/scripts/generate-clients.sh +++ b/scripts/generate-clients.sh @@ -21,7 +21,7 @@ generate_client() { # The permanent Cloud Run Simulation Entrypoint owns the public schema. if [ "$SERVICE" = "simulation" ]; then - uv run python -m policyengine_simulation_entrypoint.generate_openapi + uv run python -m policyengine_simulation_entry.generate_openapi else uv run python -m policyengine_api_${SERVICE//-/_}.generate_openapi fi @@ -61,7 +61,7 @@ generate_client() { } # Generate the client from the permanent Cloud Run entrypoint. -generate_client "simulation" "projects/policyengine-simulation-entrypoint" +generate_client "simulation" "projects/policyengine-simulation-entry" echo "✅ Client generated successfully!" echo "" diff --git a/scripts/publish-clients.sh b/scripts/publish-clients.sh index acfbe9c0c..1fda73086 100755 --- a/scripts/publish-clients.sh +++ b/scripts/publish-clients.sh @@ -12,7 +12,7 @@ publish_client() { case "$SERVICE" in simulation) - CLIENT_DIR="projects/policyengine-simulation-entrypoint/artifacts/clients/python" + CLIENT_DIR="projects/policyengine-simulation-entry/artifacts/clients/python" ;; *) echo "❌ Unsupported API client service: ${SERVICE}" From 9f04347f03acee7435a36f3b13f168811131a84f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 23:26:54 +0300 Subject: [PATCH 10/11] refactor: publish explicit simulation API schemas --- .../gateway_models.py | 87 +- .../json_types.py | 8 + .../macro_output.py | 207 ++++ .../tests/test_gateway_models.py | 51 +- .../src/policyengine_simulation_entry/app.py | 88 +- .../src/policyengine_simulation_entry/auth.py | 19 +- .../policyengine_simulation_entry/backend.py | 23 +- .../policyengine_simulation_entry/schemas.py | 68 ++ .../tests/conftest.py | 30 +- .../tests/test_app.py | 11 +- .../tests/test_auth.py | 29 + .../tests/test_backend.py | 27 + .../tests/test_openapi.py | 49 +- .../simulation_macro_output.py | 225 ++--- .../simulation_output_labor.py | 6 +- .../tests/test_simulation_api_contracts.py | 22 +- .../tests/test_simulation_output_builder.py | 65 +- .../fixtures/gateway_endpoints.py | 58 +- .../endpoints.py | 54 +- .../generate_openapi.py | 17 +- .../tests/golden/openapi.json | 924 +++++++++++++++++- 21 files changed, 1693 insertions(+), 375 deletions(-) create mode 100644 libs/policyengine-simulation-contract/src/policyengine_simulation_contract/json_types.py create mode 100644 libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py index 4762619e4..9ba41c34a 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py @@ -3,10 +3,19 @@ """ import json -from typing import Any, ClassVar, Literal, Optional - -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - +from typing import ClassVar, Literal, Optional + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + RootModel, + field_validator, + model_validator, +) + +from policyengine_simulation_contract.json_types import JsonObject +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput from policyengine_simulation_observability.telemetry import TelemetryEnvelope @@ -23,6 +32,10 @@ INTERNAL_PASSTHROUGH_FIELDS = frozenset({"_metadata", "_runtime_bundle"}) +class PolicyParameterChanges(RootModel[JsonObject]): + """Policy parameter names mapped to their dated values.""" + + def _move_internal_telemetry_alias(value): if not isinstance(value, dict): return value @@ -90,9 +103,8 @@ class GatewayRequestBase(BaseModel): scope: Optional[str] = None data: Optional[str] = None time_period: Optional[str] = None - reform: Optional[dict[str, Any]] = None - baseline: Optional[dict[str, Any]] = None - region: Optional[str] = None + reform: Optional[PolicyParameterChanges] = None + baseline: Optional[PolicyParameterChanges] = None region_group: Optional[list[str]] = None title: Optional[str] = None include_cliffs: Optional[bool] = None @@ -123,18 +135,20 @@ def strip_internal_passthrough_fields(cls, value): def enforce_max_payload_size(cls, value): return _enforce_max_payload_size(value) + +class SimulationRequest(GatewayRequestBase): + """Request model for simulation submission.""" + + region: Optional[str] = None + @model_validator(mode="after") - def region_xor_region_group(self): + def region_xor_region_group(self) -> "SimulationRequest": """A request scopes to a single region OR a region group, never both.""" if self.region is not None and self.region_group is not None: raise ValueError("Provide `region` or `region_group`, not both.") return self -class SimulationRequest(GatewayRequestBase): - """Request model for simulation submission.""" - - class PolicyEngineBundle(BaseModel): """Resolved runtime provenance returned by the gateway.""" @@ -148,7 +162,7 @@ class JobSubmitResponse(BaseModel): """Response model for job submission.""" job_id: str - status: str + status: Literal["submitted"] poll_url: str country: str version: str @@ -160,8 +174,8 @@ class JobSubmitResponse(BaseModel): class JobStatusResponse(BaseModel): """Response model for job status polling.""" - status: str - result: Optional[dict] = None + status: Literal["running", "complete", "failed"] + result: Optional[SingleYearMacroOutput] = None error: Optional[str] = None resolved_app_name: Optional[str] = None policyengine_bundle: Optional[PolicyEngineBundle] = None @@ -191,6 +205,8 @@ def validate_start_year(cls, value: str) -> str: @model_validator(mode="after") def validate_end_year(self) -> "BudgetWindowBatchRequest": + if self.region_group is not None: + raise ValueError("Provide `region` or `region_group`, not both.") end_year = int(self.start_year) + self.window_size - 1 if end_year > self.MAX_END_YEAR: raise ValueError( @@ -241,7 +257,14 @@ class BatchChildJobStatus(BaseModel): """Per-year child simulation job tracking.""" job_id: str - status: str + status: Literal[ + "pending", + "queued", + "running", + "complete", + "failed", + "cancelled", + ] error: Optional[str] = None @@ -249,7 +272,7 @@ class BudgetWindowBatchSubmitResponse(BaseModel): """Response model for budget-window batch submission.""" batch_job_id: str - status: str + status: Literal["submitted"] poll_url: str country: str version: str @@ -261,7 +284,7 @@ class BudgetWindowBatchSubmitResponse(BaseModel): class BudgetWindowBatchStatusResponse(BaseModel): """Response model for budget-window batch polling.""" - status: str + status: Literal["submitted", "running", "complete", "failed"] progress: Optional[int] = None completed_years: list[str] = Field(default_factory=list) running_years: list[str] = Field(default_factory=list) @@ -279,7 +302,7 @@ class BudgetWindowBatchState(BaseModel): """Internal state persisted for a budget-window parent batch job.""" batch_job_id: str - status: str + status: Literal["submitted", "running", "complete", "failed"] country: str region: str version: str @@ -289,7 +312,7 @@ class BudgetWindowBatchState(BaseModel): start_year: str window_size: int max_parallel: int - request_payload: dict[str, Any] = Field(default_factory=dict) + request_payload: JsonObject = Field(default_factory=dict) years: list[str] = Field(default_factory=list) queued_years: list[str] = Field(default_factory=list) running_years: list[str] = Field(default_factory=list) @@ -316,3 +339,27 @@ class PingResponse(BaseModel): """Response model for ping endpoint.""" incremented: int + + +class VersionMap(RootModel[dict[str, str]]): + """Version alias or exact version mapped to a deployed worker app.""" + + +class VersionsResponse(BaseModel): + """All supported simulation routing version maps.""" + + policyengine: VersionMap + us: VersionMap + uk: VersionMap + + +class HealthResponse(BaseModel): + """Local process health response.""" + + status: Literal["healthy"] = "healthy" + + +class ReadinessResponse(BaseModel): + """Dependency readiness response.""" + + status: Literal["ready", "not_ready"] diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/json_types.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/json_types.py new file mode 100644 index 000000000..1f6668bc6 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/json_types.py @@ -0,0 +1,8 @@ +"""JSON value types shared by simulation API contracts.""" + +from __future__ import annotations + +from pydantic import JsonValue + + +type JsonObject = dict[str, JsonValue] diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py new file mode 100644 index 000000000..229131bb8 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py @@ -0,0 +1,207 @@ +"""Public schemas for completed single-year macro simulation results.""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, RootModel + + +T = TypeVar("T") + + +class MacroOutputModel(BaseModel): + """Base model for closed macro-output objects.""" + + model_config = ConfigDict(extra="forbid") + + +class MacroRootModel(RootModel[T], Generic[T]): + """Base model for named map and list output schemas.""" + + +class BudgetaryImpact(MacroOutputModel): + tax_revenue_impact: float + state_tax_revenue_impact: float + benefit_spending_impact: float + budgetary_impact: float + households: float + baseline_net_income: float + + +BudgetaryOutput = BudgetaryImpact + + +class DetailedBudgetProgramOutput(MacroOutputModel): + baseline: float + reform: float + difference: float + + +class DetailedBudgetOutput(MacroRootModel[dict[str, DetailedBudgetProgramOutput]]): + """Program name mapped to its baseline and reform totals.""" + + +class DecileOutput(MacroOutputModel): + average: dict[str, float] + relative: dict[str, float] + + +class IntraDecileOutput(MacroOutputModel): + deciles: dict[str, list[float]] + all: dict[str, float] + + +class BaselineReformValue(MacroOutputModel): + baseline: float + reform: float + + +class AgePovertyOutput(MacroOutputModel): + child: BaselineReformValue + adult: BaselineReformValue + senior: BaselineReformValue + all: BaselineReformValue + + +class GenderPovertyOutput(MacroOutputModel): + male: BaselineReformValue + female: BaselineReformValue + + +class RacePovertyOutput(MacroOutputModel): + white: BaselineReformValue + black: BaselineReformValue + hispanic: BaselineReformValue + other: BaselineReformValue + + +class PovertyOutput(MacroOutputModel): + poverty: AgePovertyOutput + deep_poverty: AgePovertyOutput + + +class PovertyByGenderOutput(MacroOutputModel): + poverty: GenderPovertyOutput + deep_poverty: GenderPovertyOutput + + +class PovertyByRaceOutput(MacroOutputModel): + poverty: RacePovertyOutput + + +class PovertyModuleOutputs(MacroOutputModel): + poverty: PovertyOutput + poverty_by_gender: PovertyByGenderOutput + poverty_by_race: PovertyByRaceOutput | None + + +class InequalityOutput(MacroOutputModel): + gini: BaselineReformValue + top_10_pct_share: BaselineReformValue + top_1_pct_share: BaselineReformValue + + +class LaborSupplyRelativeResponse(MacroOutputModel): + income: float + substitution: float + + +class LaborSupplyDecileMetric(MacroOutputModel): + income: dict[int, float] + substitution: dict[int, float] + + +class LaborSupplyDecileOutput(MacroOutputModel): + average: LaborSupplyDecileMetric + relative: LaborSupplyDecileMetric + + +class LaborSupplyHoursOutput(MacroOutputModel): + baseline: float + reform: float + change: float + income_effect: float + substitution_effect: float + + +class LaborSupplyResponseOutput(MacroOutputModel): + substitution_lsr: float + income_lsr: float + relative_lsr: LaborSupplyRelativeResponse + total_change: float + revenue_change: float + decile: LaborSupplyDecileOutput + hours: LaborSupplyHoursOutput + + +class CliffImpactInSimulation(MacroOutputModel): + cliff_gap: float + cliff_share: float + + +class CliffImpactOutput(MacroOutputModel): + baseline: CliffImpactInSimulation + reform: CliffImpactInSimulation + + +class ConstituencyImpactRecord(MacroOutputModel): + constituency_code: str + constituency_name: str + x: int | None + y: int | None + average_household_income_change: float + relative_household_income_change: float + population: float + + +class LocalAuthorityImpactRecord(MacroOutputModel): + local_authority_code: str + local_authority_name: str + x: int | None + y: int | None + average_household_income_change: float + relative_household_income_change: float + population: float + + +class GeographicImpactOutput( + MacroRootModel[list[ConstituencyImpactRecord | LocalAuthorityImpactRecord]] +): + """UK constituency or local-authority impact records.""" + + +class CongressionalDistrictImpactRecord(MacroOutputModel): + district: str + average_household_income_change: float + relative_household_income_change: float + winner_percentage: float + loser_percentage: float + no_change_percentage: float + population: float + + +class CongressionalDistrictImpactOutput(MacroOutputModel): + districts: list[CongressionalDistrictImpactRecord] + + +class SingleYearMacroOutput(MacroOutputModel): + """Completed response returned by a single-year macro simulation.""" + + model_version: str + data_version: str + budget: BudgetaryImpact + detailed_budget: DetailedBudgetOutput + decile: DecileOutput + inequality: InequalityOutput + poverty: PovertyOutput + poverty_by_gender: PovertyByGenderOutput + poverty_by_race: PovertyByRaceOutput | None + intra_decile: IntraDecileOutput + wealth_decile: DecileOutput | None + intra_wealth_decile: IntraDecileOutput | None + labor_supply_response: LaborSupplyResponseOutput | None + constituency_impact: GeographicImpactOutput | None + local_authority_impact: GeographicImpactOutput | None + congressional_district_impact: CongressionalDistrictImpactOutput | None + cliff_impact: CliffImpactOutput | None = None diff --git a/libs/policyengine-simulation-contract/tests/test_gateway_models.py b/libs/policyengine-simulation-contract/tests/test_gateway_models.py index b9f9855f9..a6bd60dd3 100644 --- a/libs/policyengine-simulation-contract/tests/test_gateway_models.py +++ b/libs/policyengine-simulation-contract/tests/test_gateway_models.py @@ -13,12 +13,16 @@ BudgetWindowBatchSubmitResponse, BudgetWindowResult, BudgetWindowTotals, + HealthResponse, JobStatusResponse, JobSubmitResponse, MAX_GATEWAY_REQUEST_BYTES, PingRequest, PingResponse, + ReadinessResponse, SimulationRequest, + VersionMap, + VersionsResponse, ) @@ -265,7 +269,8 @@ def test_simulation_request_accepts_payload_just_below_256kb(self): # Must not raise — this is the just-under-cap happy path. request = SimulationRequest(**payload) assert request.country == "us" - assert request.reform == payload["reform"] + assert request.reform is not None + assert request.reform.root == payload["reform"] def test_simulation_request_rejects_payload_just_above_256kb(self): """The cap is strict: a payload that crosses 262_144 bytes by even a @@ -340,22 +345,17 @@ def test_job_submit_response_creates_with_all_fields(self): class TestJobStatusResponse: """Tests for JobStatusResponse model.""" - def test_job_status_response_complete_with_result(self): + def test_job_status_response_rejects_an_unstructured_result(self): """ - Given a completed job with result + Given a completed job with a result that does not match the macro schema When creating a JobStatusResponse - Then the model contains the result. + Then validation fails instead of silently accepting an opaque mapping. """ - # Given - result = {"budget": {"total": 1000000}} - - # When - response = JobStatusResponse(status="complete", result=result) - - # Then - assert response.status == "complete" - assert response.result == {"budget": {"total": 1000000}} - assert response.error is None + with pytest.raises(ValidationError): + JobStatusResponse( + status="complete", + result={"budget": {"total": 1000000}}, + ) def test_job_status_response_running_without_result(self): """ @@ -391,7 +391,6 @@ def test_job_status_response_failed_with_error(self): def test_job_status_response_accepts_bundle_metadata(self): response = JobStatusResponse( status="complete", - result={"budget": {"total": 1000000}}, resolved_app_name="policyengine-simulation-py3-9-0", policyengine_bundle={ "model_version": "1.459.0", @@ -408,6 +407,28 @@ def test_job_status_response_accepts_bundle_metadata(self): ) +class TestOperationalResponses: + def test_versions_response_has_named_maps(self): + response = VersionsResponse( + policyengine=VersionMap({"latest": "4.10.0"}), + us=VersionMap({"latest": "1.500.0"}), + uk=VersionMap({"latest": "2.66.0"}), + ) + + assert response.model_dump(mode="json") == { + "policyengine": {"latest": "4.10.0"}, + "us": {"latest": "1.500.0"}, + "uk": {"latest": "2.66.0"}, + } + + def test_health_and_readiness_statuses_are_constrained(self): + assert HealthResponse().status == "healthy" + assert ReadinessResponse(status="ready").status == "ready" + + with pytest.raises(ValidationError): + ReadinessResponse(status="unknown") + + class TestBudgetWindowBatchRequest: """Tests for budget-window batch request validation.""" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index 3e85a8965..1ece62a42 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -2,26 +2,32 @@ from __future__ import annotations -import json import logging import time import uuid +from collections.abc import Callable from contextlib import asynccontextmanager -from typing import Any, Callable, Mapping +from typing import Literal from fastapi import Depends, FastAPI, Request from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, TypeAdapter, ValidationError from policyengine_observability import record_event from policyengine_simulation_contract.gateway_models import ( BudgetWindowBatchRequest, BudgetWindowBatchStatusResponse, BudgetWindowBatchSubmitResponse, + HealthResponse, JobStatusResponse, JobSubmitResponse, PingRequest, PingResponse, + ReadinessResponse, SimulationRequest, + VersionMap, + VersionsResponse, ) +from policyengine_simulation_contract.json_types import JsonObject from policyengine_simulation_observability.observability import ( configure_process_observability, init_simulation_observability, @@ -37,16 +43,27 @@ SimulationBackend, ) from policyengine_simulation_entry.config import Settings +from policyengine_simulation_entry.schemas import ( + BackendTelemetryAttributes, + BackendTelemetryPayload, + CallerIdentity, + RequestIdentifiers, +) logger = logging.getLogger(__name__) BACKEND_RESPONSE_HEADER = { "X-PolicyEngine-Simulation-Backend": "old_gateway", } +_json_object_adapter = TypeAdapter(JsonObject) +type AuthenticationDependency = Callable[[], CallerIdentity | None] +type ResponseIdentifier = Literal["job_id", "batch_job_id"] -def _model_json(model: Any) -> Mapping[str, Any]: - return model.model_dump(mode="json", by_alias=True, exclude_none=True) +def _model_json(model: BaseModel) -> JsonObject: + return _json_object_adapter.validate_python( + model.model_dump(mode="json", by_alias=True, exclude_none=True) + ) def _response(result: BackendResponse) -> Response: @@ -65,19 +82,22 @@ def _route_template(request: Request) -> str: return getattr(request.scope.get("route"), "path", request.url.path) -def _request_identifiers(request: Request) -> dict[str, str]: - return { - key: value - for key in ("job_id", "batch_job_id") - if isinstance((value := request.path_params.get(key)), str) - } +def _request_identifiers(request: Request) -> RequestIdentifiers: + identifiers: RequestIdentifiers = {} + job_id = request.path_params.get("job_id") + batch_job_id = request.path_params.get("batch_job_id") + if isinstance(job_id, str): + identifiers["job_id"] = job_id + if isinstance(batch_job_id, str): + identifiers["batch_job_id"] = batch_job_id + return identifiers def create_app( *, settings: Settings | None = None, backend: SimulationBackend | None = None, - auth_dependency: Callable[..., Any] | None = None, + auth_dependency: AuthenticationDependency | None = None, ) -> FastAPI: """Build the app with injectable auth/backend seams for hermetic tests.""" @@ -133,14 +153,14 @@ async def forward( request: Request, method: str, path: str, - body: Mapping[str, Any] | None = None, + body: JsonObject | None = None, *, - identifiers: Mapping[str, str] | None = None, - response_identifier_key: str | None = None, + identifiers: RequestIdentifiers | None = None, + response_identifier_key: ResponseIdentifier | None = None, ) -> Response: backend_started = time.monotonic() route = _route_template(request) - event_identifiers = dict(identifiers or {}) + event_identifiers: RequestIdentifiers = identifiers or {} try: result = await runtime_backend.request( method, @@ -148,7 +168,7 @@ async def forward( json_body=body, request_id=request.state.request_id, ) - attributes: dict[str, Any] = { + attributes: BackendTelemetryAttributes = { "request_id": request.state.request_id, "route": route, "method": method, @@ -160,22 +180,22 @@ async def forward( **event_identifiers, } try: - response_payload = json.loads(result.content) - except (AttributeError, json.JSONDecodeError, UnicodeDecodeError): + response_payload = BackendTelemetryPayload.model_validate_json( + result.content + ) + except ValidationError: response_payload = None job_state = ( - response_payload.get("status") - if isinstance(response_payload, dict) - else None + response_payload.status if response_payload is not None else None ) - if isinstance(job_state, str): + if job_state is not None: attributes["job_state"] = job_state response_identifier = ( - response_payload.get(response_identifier_key) - if isinstance(response_payload, dict) and response_identifier_key + getattr(response_payload, response_identifier_key) + if response_payload is not None and response_identifier_key else None ) - if response_identifier_key and isinstance(response_identifier, str): + if response_identifier_key and response_identifier is not None: attributes[response_identifier_key] = response_identifier record_event("simulation_entry_backend_response", **attributes) return _response(result) @@ -335,7 +355,7 @@ async def get_budget_window_job( summary="List Versions", description="List all available routing versions.", operation_id="list_versions_versions_get", - response_model=dict, + response_model=VersionsResponse, ) async def list_versions(request: Request) -> Response: return await forward(request, "GET", "/versions") @@ -345,7 +365,7 @@ async def list_versions(request: Request) -> Response: summary="Get Country Versions", description="Get available versions for policyengine, US, or UK routing.", operation_id="get_country_versions_versions__kind__get", - response_model=dict, + response_model=VersionMap, ) async def get_country_versions(kind: str, request: Request) -> Response: return await forward(request, "GET", f"/versions/{kind}") @@ -354,15 +374,19 @@ async def get_country_versions(kind: str, request: Request) -> Response: "/health", description="Health check endpoint.", operation_id="health_health_get", + response_model=HealthResponse, ) - async def health() -> dict: - return {"status": "healthy"} + async def health() -> HealthResponse: + return HealthResponse() - @app.get("/ready") + @app.get("/ready", response_model=ReadinessResponse) async def ready() -> Response: if await runtime_backend.ready(): - return JSONResponse({"status": "ready"}) - return JSONResponse({"status": "not_ready"}, status_code=503) + return JSONResponse(ReadinessResponse(status="ready").model_dump()) + return JSONResponse( + ReadinessResponse(status="not_ready").model_dump(), + status_code=503, + ) @app.post( "/ping", diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py index b7393d57d..2d72d5f24 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py @@ -11,6 +11,7 @@ from policyengine_observability import record_event from policyengine_simulation_entry.config import Settings +from policyengine_simulation_entry.schemas import CallerIdentity _bearer = HTTPBearer(auto_error=False) @@ -28,7 +29,7 @@ def __init__(self, issuer: str, audience: str): def __call__( self, token: HTTPAuthorizationCredentials | None, - ) -> dict[str, str]: + ) -> CallerIdentity: if token is None: record_event("simulation_entry_auth_rejected", reason="missing_token") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) @@ -37,12 +38,14 @@ def __call__( signing_key = self.jwks_client.get_signing_key_from_jwt( token.credentials ).key - return jwt.decode( - token.credentials, - signing_key, - algorithms=["RS256"], - audience=self.audience, - issuer=self.issuer, + return CallerIdentity.model_validate( + jwt.decode( + token.credentials, + signing_key, + algorithms=["RS256"], + audience=self.audience, + issuer=self.issuer, + ) ) except Exception as error: reason = type(error).__name__ @@ -68,7 +71,7 @@ def __init__(self, settings: Settings): def __call__( self, token: HTTPAuthorizationCredentials | None = Depends(_bearer), - ) -> dict[str, str] | None: + ) -> CallerIdentity | None: if not self.settings.auth_required: return None return _decoder( diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py index 1a7546e37..67343e470 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py @@ -5,11 +5,14 @@ import asyncio import time from dataclasses import dataclass -from typing import Any, Mapping, Protocol +from typing import Protocol import httpx +from pydantic import ValidationError +from policyengine_simulation_contract.json_types import JsonObject from policyengine_simulation_entry.config import Settings +from policyengine_simulation_entry.schemas import BackendServiceToken SAFE_RESPONSE_HEADERS = frozenset( @@ -54,7 +57,7 @@ async def request( method: str, path: str, *, - json_body: Mapping[str, Any] | None = None, + json_body: JsonObject | None = None, request_id: str | None = None, ) -> BackendResponse: ... @@ -99,18 +102,12 @@ async def _fetch(self) -> None: }, ) response.raise_for_status() - payload = response.json() - token = payload.get("access_token") - expires_in = payload.get("expires_in") - if not token or expires_in is None: - raise BackendAuthenticationError( - "Auth0 response omitted access_token or expires_in." - ) - self._token = str(token) - self._expires_at = time.time() + max(int(expires_in), 1) + token = BackendServiceToken.model_validate(response.json()) + self._token = token.access_token + self._expires_at = time.time() + token.expires_in except BackendAuthenticationError: raise - except (httpx.HTTPError, TypeError, ValueError) as exc: + except (httpx.HTTPError, TypeError, ValueError, ValidationError) as exc: raise BackendAuthenticationError( "Unable to obtain old-gateway credentials." ) from exc @@ -172,7 +169,7 @@ async def request( method: str, path: str, *, - json_body: Mapping[str, Any] | None = None, + json_body: JsonObject | None = None, request_id: str | None = None, ) -> BackendResponse: client, token_provider = self._runtime() diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py new file mode 100644 index 000000000..cb353da3b --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py @@ -0,0 +1,68 @@ +"""Simulation Entry schemas that are not part of the public gateway contract.""" + +from __future__ import annotations + +from typing import Literal, TypedDict + +from pydantic import BaseModel, ConfigDict, Field + + +class CallerIdentity(BaseModel): + """Validated identity claims from an inbound Auth0 access token.""" + + subject: str | None = Field(default=None, alias="sub") + issuer: str = Field(alias="iss") + audience: str | list[str] = Field(alias="aud") + expires_at: int = Field(alias="exp") + issued_at: int | None = Field(default=None, alias="iat") + scope: str | None = None + permissions: list[str] | None = None + authorized_party: str | None = Field(default=None, alias="azp") + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + +class BackendServiceToken(BaseModel): + """Auth0 client-credentials response used for old-gateway calls.""" + + access_token: str = Field(min_length=1) + expires_in: int = Field(ge=1) + + +type BackendJobState = Literal[ + "submitted", + "pending", + "queued", + "running", + "complete", + "failed", + "cancelled", +] + + +class BackendTelemetryPayload(BaseModel): + """Response fields that are safe and useful for proxy telemetry.""" + + status: BackendJobState | None = None + job_id: str | None = None + batch_job_id: str | None = None + + +class RequestIdentifiers(TypedDict, total=False): + """Identifiers captured from a templated request route.""" + + job_id: str + batch_job_id: str + + +class BackendTelemetryAttributes(TypedDict, total=False): + """Structured fields emitted for one old-gateway response.""" + + request_id: str + route: str + method: str + status_code: int + elapsed_ms: float + job_id: str + batch_job_id: str + job_state: BackendJobState diff --git a/projects/policyengine-simulation-entry/tests/conftest.py b/projects/policyengine-simulation-entry/tests/conftest.py index e3121d6f0..47fb01a7e 100644 --- a/projects/policyengine-simulation-entry/tests/conftest.py +++ b/projects/policyengine-simulation-entry/tests/conftest.py @@ -1,10 +1,10 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any +from dataclasses import dataclass import pytest from fastapi.testclient import TestClient +from policyengine_simulation_contract.json_types import JsonObject from policyengine_simulation_entry.app import create_app from policyengine_simulation_entry.backend import BackendResponse @@ -30,9 +30,19 @@ def make_settings(**overrides) -> Settings: return Settings(**values) +@dataclass(frozen=True) +class BackendRequest: + """One request captured by the fake simulation backend.""" + + method: str + path: str + json_body: JsonObject | None + request_id: str | None + + class FakeBackend: def __init__(self): - self.requests: list[dict[str, Any]] = [] + self.requests: list[BackendRequest] = [] self.responses: dict[tuple[str, str], BackendResponse] = {} self.is_ready = True self.started = False @@ -51,16 +61,16 @@ async def request( method: str, path: str, *, - json_body: Mapping[str, Any] | None = None, + json_body: JsonObject | None = None, request_id: str | None = None, ) -> BackendResponse: self.requests.append( - { - "method": method, - "path": path, - "json_body": json_body, - "request_id": request_id, - } + BackendRequest( + method=method, + path=path, + json_body=json_body, + request_id=request_id, + ) ) return self.responses.get( (method, path), diff --git a/projects/policyengine-simulation-entry/tests/test_app.py b/projects/policyengine-simulation-entry/tests/test_app.py index abac4fc7f..16e206a1f 100644 --- a/projects/policyengine-simulation-entry/tests/test_app.py +++ b/projects/policyengine-simulation-entry/tests/test_app.py @@ -4,6 +4,7 @@ import logging import pytest +from policyengine_simulation_contract.json_types import JsonObject from policyengine_simulation_entry import app as app_module from policyengine_simulation_entry.app import create_app @@ -16,7 +17,7 @@ from conftest import FakeBackend, make_settings -def response(status: int, payload: dict) -> BackendResponse: +def response(status: int, payload: JsonObject) -> BackendResponse: return BackendResponse( status_code=status, content=json.dumps(payload).encode(), @@ -65,7 +66,7 @@ def test_comparison_submission_preserves_upstream_response(client, backend): assert result.status_code == 202 assert result.json() == payload assert result.headers["x-policyengine-simulation-backend"] == "old_gateway" - assert backend.requests[-1]["path"] == "/simulate/economy/comparison" + assert backend.requests[-1].path == "/simulate/economy/comparison" def test_job_status_preserves_id_and_status(client, backend): @@ -78,7 +79,7 @@ def test_job_status_preserves_id_and_status(client, backend): assert result.status_code == 202 assert result.json() == {"status": "running", "run_id": "run-1"} - assert backend.requests[-1]["path"] == "/jobs/fc-123" + assert backend.requests[-1].path == "/jobs/fc-123" def test_job_status_records_structured_backend_telemetry( @@ -164,7 +165,7 @@ def test_budget_window_routes_use_original_batch_id(client, backend): assert submitted.status_code == 202 assert submitted.json()["batch_job_id"] == "batch-123" assert polled.status_code == 200 - assert backend.requests[-1]["path"] == "/budget-window-jobs/batch-123" + assert backend.requests[-1].path == "/budget-window-jobs/batch-123" def test_versions_and_ping_are_public_proxy_routes(client, backend): @@ -182,7 +183,7 @@ def test_request_id_is_propagated_and_returned(client, backend): result = client.get("/versions", headers={"X-Request-ID": "request-123"}) assert result.headers["x-request-id"] == "request-123" - assert backend.requests[-1]["request_id"] == "request-123" + assert backend.requests[-1].request_id == "request-123" def test_request_validation_matches_shared_contract(client): diff --git a/projects/policyengine-simulation-entry/tests/test_auth.py b/projects/policyengine-simulation-entry/tests/test_auth.py index c87553f6b..22f8edfed 100644 --- a/projects/policyengine-simulation-entry/tests/test_auth.py +++ b/projects/policyengine-simulation-entry/tests/test_auth.py @@ -6,10 +6,12 @@ import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.security import HTTPAuthorizationCredentials from fastapi.testclient import TestClient from policyengine_simulation_entry import auth as auth_module from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.schemas import CallerIdentity from conftest import FakeBackend, make_settings @@ -88,6 +90,33 @@ def test_valid_api_v1_token_can_poll(monkeypatch, signing_keys): assert result.status_code == 200 +def test_decoder_returns_only_the_declared_caller_identity(signing_keys): + private_key, public_key = signing_keys + decoder = auth_module.JWTDecoder( + issuer="https://issuer.example/", + audience="simulation-entry", + ) + decoder.jwks_client = SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ) + + identity = decoder( + HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=_token( + private_key, + scope="simulate:read", + undocumented_claim="ignored", + ), + ) + ) + + assert isinstance(identity, CallerIdentity) + assert identity.subject == "api-v1" + assert identity.scope == "simulate:read" + assert "undocumented_claim" not in identity.model_dump() + + @pytest.mark.parametrize( "claim_overrides", [ diff --git a/projects/policyengine-simulation-entry/tests/test_backend.py b/projects/policyengine-simulation-entry/tests/test_backend.py index 74219b09b..3b66da246 100644 --- a/projects/policyengine-simulation-entry/tests/test_backend.py +++ b/projects/policyengine-simulation-entry/tests/test_backend.py @@ -14,6 +14,33 @@ from conftest import make_settings +@pytest.mark.asyncio +@pytest.mark.parametrize( + "token_payload", + [ + {}, + {"access_token": "", "expires_in": 3600}, + {"access_token": "service-token", "expires_in": 0}, + ], + ids=["missing-fields", "empty-token", "non-positive-expiry"], +) +async def test_backend_rejects_an_invalid_service_token_schema(token_payload): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/oauth/token" + return httpx.Response(200, json=token_payload) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + with pytest.raises(BackendAuthenticationError): + await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + @pytest.mark.asyncio async def test_backend_uses_own_token_and_preserves_safe_response_headers(): requests: list[httpx.Request] = [] diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index 285c1a888..9b1c16afb 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -3,10 +3,31 @@ from copy import deepcopy import json from pathlib import Path +from typing import Literal, TypedDict, cast from policyengine_simulation_entry.app import create_app +type HttpMethod = Literal["get", "post", "put", "patch", "delete"] + + +class OpenAPIOperation(TypedDict, total=False): + operationId: str + security: list[object] + + +type OpenAPIPathMap = dict[str, dict[str, OpenAPIOperation]] + + +class OpenAPIComponents(TypedDict): + schemas: dict[str, object] + + +class OpenAPIDocument(TypedDict): + paths: OpenAPIPathMap + components: OpenAPIComponents + + EXPECTED_ROUTES = { ("GET", "/budget-window-jobs/{batch_job_id}"), ("GET", "/health"), @@ -20,7 +41,7 @@ } -def methods(spec: dict) -> set[tuple[str, str]]: +def methods(spec: OpenAPIDocument) -> set[tuple[str, str]]: return { (method.upper(), path) for path, operations in spec["paths"].items() @@ -29,7 +50,7 @@ def methods(spec: dict) -> set[tuple[str, str]]: } -def operation_ids(spec: dict) -> dict[tuple[str, str], str]: +def operation_ids(spec: OpenAPIDocument) -> dict[tuple[str, str], str]: return { (method.upper(), path): operation["operationId"] for path, operations in spec["paths"].items() @@ -38,7 +59,7 @@ def operation_ids(spec: dict) -> dict[tuple[str, str], str]: } -def normalized_compatibility_paths(spec: dict) -> dict: +def normalized_compatibility_paths(spec: OpenAPIDocument) -> OpenAPIPathMap: """Remove the two intentional Cloud Run-only OpenAPI additions.""" paths = deepcopy(spec["paths"]) paths.pop("/ready", None) @@ -50,7 +71,7 @@ def normalized_compatibility_paths(spec: dict) -> dict: def test_route_table_is_frozen(): - assert methods(create_app().openapi()) == EXPECTED_ROUTES + assert methods(cast(OpenAPIDocument, create_app().openapi())) == EXPECTED_ROUTES def test_old_gateway_routes_are_all_present(): @@ -61,8 +82,11 @@ def test_old_gateway_routes_are_all_present(): / "golden" / "openapi.json" ) - gateway_spec = json.loads(gateway_spec_path.read_text()) - cloud_run_routes = methods(create_app().openapi()) + gateway_spec = cast( + OpenAPIDocument, + json.loads(gateway_spec_path.read_text()), + ) + cloud_run_routes = methods(cast(OpenAPIDocument, create_app().openapi())) assert methods(gateway_spec) <= cloud_run_routes @@ -75,15 +99,18 @@ def test_normalized_contract_matches_old_gateway(): / "golden" / "openapi.json" ) - gateway_spec = json.loads(gateway_spec_path.read_text()) - cloud_run_spec = create_app().openapi() + gateway_spec = cast( + OpenAPIDocument, + json.loads(gateway_spec_path.read_text()), + ) + cloud_run_spec = cast(OpenAPIDocument, create_app().openapi()) assert normalized_compatibility_paths( cloud_run_spec ) == normalized_compatibility_paths(gateway_spec) - assert ( - cloud_run_spec["components"]["schemas"] == gateway_spec["components"]["schemas"] - ) + cloud_run_schemas = deepcopy(cloud_run_spec["components"]["schemas"]) + cloud_run_schemas.pop("ReadinessResponse") + assert cloud_run_schemas == gateway_spec["components"]["schemas"] gateway_operation_ids = operation_ids(gateway_spec) cloud_run_operation_ids = operation_ids(cloud_run_spec) assert { diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py index 43685e575..cfcd2e367 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py @@ -1,158 +1,67 @@ -"""Internal schemas for the simulation API single-year macro output. - -These models define the legacy dictionary contract the simulation API returns -without exposing that schema through the gateway OpenAPI surface. The gateway -still treats job results as unstructured dictionaries for older callers. -""" - -from __future__ import annotations - -from typing import Any, Generic, TypeVar - -from pydantic import BaseModel, ConfigDict, RootModel - -T = TypeVar("T") - - -class MacroOutputModel(BaseModel): - """Base model for internal macro output schemas.""" - - model_config = ConfigDict(extra="forbid") - - -class MacroRootModel(RootModel[T], Generic[T]): - """Base model for internal root schemas that dump to dict/list values.""" - - -class BudgetaryImpact(MacroOutputModel): - tax_revenue_impact: float - state_tax_revenue_impact: float - benefit_spending_impact: float - budgetary_impact: float - households: float - baseline_net_income: float - - -BudgetaryOutput = BudgetaryImpact - - -class DetailedBudgetProgramOutput(MacroOutputModel): - baseline: float - reform: float - difference: float - - -class DetailedBudgetOutput(MacroRootModel[dict[str, DetailedBudgetProgramOutput]]): - pass - - -class DecileOutput(MacroOutputModel): - average: dict[str, float] - relative: dict[str, float] - - -class IntraDecileOutput(MacroOutputModel): - deciles: dict[str, list[float]] - all: dict[str, float] - - -class BaselineReformValue(MacroOutputModel): - baseline: float - reform: float - - -class AgePovertyOutput(MacroOutputModel): - child: BaselineReformValue - adult: BaselineReformValue - senior: BaselineReformValue - all: BaselineReformValue - - -class GenderPovertyOutput(MacroOutputModel): - male: BaselineReformValue - female: BaselineReformValue - - -class RacePovertyOutput(MacroOutputModel): - white: BaselineReformValue - black: BaselineReformValue - hispanic: BaselineReformValue - other: BaselineReformValue - - -class PovertyOutput(MacroOutputModel): - poverty: AgePovertyOutput - deep_poverty: AgePovertyOutput - - -class PovertyByGenderOutput(MacroOutputModel): - poverty: GenderPovertyOutput - deep_poverty: GenderPovertyOutput - - -class PovertyByRaceOutput(MacroOutputModel): - poverty: RacePovertyOutput - - -class PovertyModuleOutputs(MacroOutputModel): - poverty: PovertyOutput - poverty_by_gender: PovertyByGenderOutput - poverty_by_race: PovertyByRaceOutput | None - - -class InequalityOutput(MacroOutputModel): - gini: BaselineReformValue - top_10_pct_share: BaselineReformValue - top_1_pct_share: BaselineReformValue - - -class LaborSupplyResponseOutput(MacroRootModel[dict[str, Any]]): - pass - - -class CliffImpactInSimulation(MacroOutputModel): - cliff_gap: float - cliff_share: float - - -class CliffImpactOutput(MacroOutputModel): - baseline: CliffImpactInSimulation - reform: CliffImpactInSimulation - - -class GeographicImpactOutput(MacroRootModel[list[dict[str, Any]]]): - pass - - -class CongressionalDistrictImpactRecord(MacroOutputModel): - district: str - average_household_income_change: float - relative_household_income_change: float - winner_percentage: float - loser_percentage: float - no_change_percentage: float - population: float - - -class CongressionalDistrictImpactOutput(MacroOutputModel): - districts: list[CongressionalDistrictImpactRecord] - - -class SingleYearMacroOutput(MacroOutputModel): - model_version: str - data_version: str - budget: BudgetaryImpact - detailed_budget: DetailedBudgetOutput - decile: DecileOutput - inequality: InequalityOutput - poverty: PovertyOutput - poverty_by_gender: PovertyByGenderOutput - poverty_by_race: PovertyByRaceOutput | None - intra_decile: IntraDecileOutput - wealth_decile: DecileOutput | None - intra_wealth_decile: IntraDecileOutput | None - labor_supply_response: LaborSupplyResponseOutput | None - constituency_impact: GeographicImpactOutput | None - local_authority_impact: GeographicImpactOutput | None - congressional_district_impact: CongressionalDistrictImpactOutput | None - cliff_impact: CliffImpactOutput | None = None +"""Compatibility imports for the shared single-year macro result schemas.""" + +from policyengine_simulation_contract.macro_output import ( + AgePovertyOutput, + BaselineReformValue, + BudgetaryImpact, + BudgetaryOutput, + CliffImpactInSimulation, + CliffImpactOutput, + CongressionalDistrictImpactOutput, + CongressionalDistrictImpactRecord, + ConstituencyImpactRecord, + DecileOutput, + DetailedBudgetOutput, + DetailedBudgetProgramOutput, + GenderPovertyOutput, + GeographicImpactOutput, + InequalityOutput, + IntraDecileOutput, + LaborSupplyDecileMetric, + LaborSupplyDecileOutput, + LaborSupplyHoursOutput, + LaborSupplyRelativeResponse, + LaborSupplyResponseOutput, + LocalAuthorityImpactRecord, + MacroOutputModel, + MacroRootModel, + PovertyByGenderOutput, + PovertyByRaceOutput, + PovertyModuleOutputs, + PovertyOutput, + RacePovertyOutput, + SingleYearMacroOutput, +) + +__all__ = [ + "AgePovertyOutput", + "BaselineReformValue", + "BudgetaryImpact", + "BudgetaryOutput", + "CliffImpactInSimulation", + "CliffImpactOutput", + "CongressionalDistrictImpactOutput", + "CongressionalDistrictImpactRecord", + "ConstituencyImpactRecord", + "DecileOutput", + "DetailedBudgetOutput", + "DetailedBudgetProgramOutput", + "GenderPovertyOutput", + "GeographicImpactOutput", + "InequalityOutput", + "IntraDecileOutput", + "LaborSupplyDecileMetric", + "LaborSupplyDecileOutput", + "LaborSupplyHoursOutput", + "LaborSupplyRelativeResponse", + "LaborSupplyResponseOutput", + "LocalAuthorityImpactRecord", + "MacroOutputModel", + "MacroRootModel", + "PovertyByGenderOutput", + "PovertyByRaceOutput", + "PovertyModuleOutputs", + "PovertyOutput", + "RacePovertyOutput", + "SingleYearMacroOutput", +] diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py index 545219395..447ba3e1f 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py @@ -15,4 +15,8 @@ def build_labor_supply_response(analysis: Any) -> LaborSupplyResponseOutput | No if isinstance(labor_supply_response, LaborSupplyResponseOutput): return labor_supply_response output = _output_model_dump(labor_supply_response) - return LaborSupplyResponseOutput(output) if isinstance(output, dict) else None + return ( + LaborSupplyResponseOutput.model_validate(output) + if isinstance(output, dict) + else None + ) diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py b/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py index a5f93583e..bd249e401 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py @@ -7,7 +7,9 @@ BudgetWindowTotals, JobStatusResponse, ) -from policyengine_simulation_executor.simulation_macro_output import SingleYearMacroOutput +from policyengine_simulation_executor.simulation_macro_output import ( + SingleYearMacroOutput, +) from fixtures.test_simulation_api_contracts import ( CURRENT_REQUIRED_BUDGET_KEYS, @@ -23,8 +25,9 @@ def test_job_status_result_preserves_current_single_year_macro_dict_contract(): ) assert response.result is not None - assert set(response.result) == CURRENT_SINGLE_YEAR_MACRO_KEYS - assert set(response.result["budget"]) == CURRENT_REQUIRED_BUDGET_KEYS + dumped_result = response.result.model_dump(mode="json") + assert set(dumped_result) == CURRENT_SINGLE_YEAR_MACRO_KEYS + assert set(dumped_result["budget"]) == CURRENT_REQUIRED_BUDGET_KEYS assert ( response.model_dump(mode="json")["result"] == CURRENT_SINGLE_YEAR_MACRO_RESULT ) @@ -40,23 +43,24 @@ def test_internal_single_year_macro_schema_serializes_current_public_contract(): assert output.model_dump(mode="json") == CURRENT_SINGLE_YEAR_MACRO_RESULT -def test_openapi_keeps_job_status_result_as_unstructured_dict(): +def test_openapi_publishes_the_single_year_macro_result_schema(): spec = create_openapi_app().openapi() schemas = spec["components"]["schemas"] - assert "SingleYearMacroOutput" not in schemas + assert "SingleYearMacroOutput" in schemas + assert set(schemas["SingleYearMacroOutput"]["properties"]) == ( + CURRENT_SINGLE_YEAR_MACRO_KEYS + ) result_schema = schemas["JobStatusResponse"]["properties"]["result"] assert result_schema == { "anyOf": [ { - "additionalProperties": True, - "type": "object", + "$ref": "#/components/schemas/SingleYearMacroOutput", }, { "type": "null", }, - ], - "title": "Result", + ] } diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py index 487043943..0ae46005f 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py @@ -47,13 +47,14 @@ BaselineReformValue, BudgetaryImpact, BudgetaryOutput, + ConstituencyImpactRecord, DecileOutput, DetailedBudgetOutput, CongressionalDistrictImpactOutput, GenderPovertyOutput, InequalityOutput, IntraDecileOutput, - LaborSupplyResponseOutput, + LocalAuthorityImpactRecord, PovertyByGenderOutput, PovertyModuleOutputs, PovertyOutput, @@ -189,6 +190,30 @@ def _congressional_district_output() -> CongressionalDistrictImpactOutput: ) +def _constituency_impact_record() -> ConstituencyImpactRecord: + return ConstituencyImpactRecord( + constituency_code="E14000530", + constituency_name="Birmingham, Ladywood", + x=None, + y=None, + average_household_income_change=10.0, + relative_household_income_change=0.01, + population=1000.0, + ) + + +def _local_authority_impact_record() -> LocalAuthorityImpactRecord: + return LocalAuthorityImpactRecord( + local_authority_code="E06000001", + local_authority_name="Hartlepool", + x=None, + y=None, + average_household_income_change=12.0, + relative_household_income_change=0.02, + population=900.0, + ) + + def _stub_policyengine_output_calls(monkeypatch, baseline, reform) -> None: def fake_poverty_module_function(name): def compute(simulation): @@ -236,7 +261,9 @@ def compute(simulation): SimulationOutputBuilder, "_build_uk_constituency_impact", lambda self: ( - self._build_geographic_impact_output([{"constituency_code": "E14000530"}]) + self._build_geographic_impact_output( + [_constituency_impact_record().model_dump(mode="json")] + ) if self.country == "uk" else None ), @@ -246,7 +273,7 @@ def compute(simulation): "_build_uk_local_authority_impact", lambda self: ( self._build_geographic_impact_output( - [{"local_authority_code": "E06000001"}] + [_local_authority_impact_record().model_dump(mode="json")] ) if self.country == "uk" else None @@ -626,7 +653,7 @@ def wrapper(*args, **kwargs): ) monkeypatch.setattr( "policyengine_simulation_executor.simulation_output_labor.build_labor_supply_response", - record("labor_supply", LaborSupplyResponseOutput({})), + record("labor_supply", None), ) monkeypatch.setattr( "policyengine_simulation_executor.simulation_output_cliff.build_cliff_impact", @@ -751,8 +778,8 @@ def test_builder_maps_uk_wealth_outputs_and_omits_us_only_race(monkeypatch): "relative": {"1": 0.03}, } assert output["intra_wealth_decile"]["deciles"]["Lose more than 5%"] == [0.1] - assert output["constituency_impact"] == [{"constituency_code": "E14000530"}] - assert output["local_authority_impact"] == [{"local_authority_code": "E06000001"}] + assert output["constituency_impact"][0]["constituency_code"] == "E14000530" + assert output["local_authority_impact"][0]["local_authority_code"] == "E06000001" def test_builder_calls_policyengine_economic_impact_analysis(): @@ -1334,7 +1361,7 @@ def fail_change_output_variable(*args, **kwargs): def test_uk_constituency_impact_uses_policyengine_output_function(monkeypatch): baseline = object() reform = object() - expected = [{"constituency_code": "E14000530"}] + expected = [_constituency_impact_record().model_dump(mode="json")] def fake_output_module_function(module_name, name): assert module_name == "constituency_impact" @@ -1352,12 +1379,11 @@ def compute(baseline_simulation, reform_simulation): fake_output_module_function, ) - assert ( - _simulation_output_builder("uk", baseline, reform) - ._build_uk_constituency_impact() - .root - == expected - ) + result = _simulation_output_builder( + "uk", baseline, reform + )._build_uk_constituency_impact() + assert result is not None + assert result.model_dump(mode="json") == expected assert ( _simulation_output_builder( "us", baseline, reform @@ -1369,7 +1395,7 @@ def compute(baseline_simulation, reform_simulation): def test_uk_local_authority_impact_uses_policyengine_output_function(monkeypatch): baseline = object() reform = object() - expected = [{"local_authority_code": "E06000001"}] + expected = [_local_authority_impact_record().model_dump(mode="json")] def fake_output_module_function(module_name, name): assert module_name == "local_authority_impact" @@ -1387,12 +1413,11 @@ def compute(baseline_simulation, reform_simulation): fake_output_module_function, ) - assert ( - _simulation_output_builder("uk", baseline, reform) - ._build_uk_local_authority_impact() - .root - == expected - ) + result = _simulation_output_builder( + "uk", baseline, reform + )._build_uk_local_authority_impact() + assert result is not None + assert result.model_dump(mode="json") == expected assert ( _simulation_output_builder( "us", baseline, reform diff --git a/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py b/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py index ef5eaa844..33840850e 100644 --- a/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py +++ b/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py @@ -3,6 +3,19 @@ from copy import deepcopy import pytest +from policyengine_simulation_contract.macro_output import ( + AgePovertyOutput, + BaselineReformValue, + BudgetaryImpact, + DecileOutput, + DetailedBudgetOutput, + GenderPovertyOutput, + InequalityOutput, + IntraDecileOutput, + PovertyByGenderOutput, + PovertyOutput, + SingleYearMacroOutput, +) TEST_APP_RELEASE_BUNDLE = { "app_name": "policyengine-simulation-py4-10-0", @@ -171,7 +184,50 @@ class MockFunctionCall: def __init__(self, object_id: str = "mock-job-id-123"): self.object_id = object_id - self.result = {"budget": {"total": 1000000}} + value = BaselineReformValue(baseline=0.0, reform=0.0) + age_poverty = AgePovertyOutput( + child=value, + adult=value, + senior=value, + all=value, + ) + gender_poverty = GenderPovertyOutput(male=value, female=value) + self.result = SingleYearMacroOutput( + model_version="1.500.0", + data_version="1.0.0", + budget=BudgetaryImpact( + tax_revenue_impact=0.0, + state_tax_revenue_impact=0.0, + benefit_spending_impact=0.0, + budgetary_impact=0.0, + households=0.0, + baseline_net_income=0.0, + ), + detailed_budget=DetailedBudgetOutput({}), + decile=DecileOutput(average={}, relative={}), + inequality=InequalityOutput( + gini=value, + top_10_pct_share=value, + top_1_pct_share=value, + ), + poverty=PovertyOutput( + poverty=age_poverty, + deep_poverty=age_poverty, + ), + poverty_by_gender=PovertyByGenderOutput( + poverty=gender_poverty, + deep_poverty=gender_poverty, + ), + poverty_by_race=None, + intra_decile=IntraDecileOutput(deciles={}, all={}), + wealth_decile=None, + intra_wealth_decile=None, + labor_supply_response=None, + constituency_impact=None, + local_authority_impact=None, + congressional_district_impact=None, + cliff_impact=None, + ).model_dump(mode="json") self.error = None self.running = False self.__class__.registry[object_id] = self diff --git a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py index b1a11e2e6..67bf347b7 100644 --- a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py +++ b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py @@ -4,7 +4,7 @@ import logging from dataclasses import dataclass -from typing import Optional +from typing import Optional, TypedDict import modal from fastapi import APIRouter, Depends, HTTPException @@ -29,12 +29,15 @@ BudgetWindowBatchRequest, BudgetWindowBatchStatusResponse, BudgetWindowBatchSubmitResponse, + HealthResponse, JobStatusResponse, JobSubmitResponse, PingRequest, PingResponse, PolicyEngineBundle, SimulationRequest, + VersionMap, + VersionsResponse, ) from policyengine_simulation_gateway.responses import ( batch_status_response, @@ -60,6 +63,13 @@ SUPPORTED_ROUTE_KINDS = ("policyengine", "us", "uk") +class VersionRoutingState(TypedDict, total=False): + """Routing-state fields required by the public version endpoints.""" + + latest: dict[str, str] + routes: dict[str, dict[str, str]] + + @dataclass(frozen=True) class RouteResolution: app_name: str @@ -924,15 +934,17 @@ async def get_budget_window_job_status(batch_job_id: str): return batch_status_response(response) -@router.get("/versions") -async def list_versions(): +@router.get("/versions", response_model=VersionsResponse) +async def list_versions() -> VersionsResponse: """List all available routing versions.""" with segment(SegmentName.ROUTE_RESOLUTION): state = _active_routing_state() if state: - return { - kind: _version_map_from_state(state, kind) for kind in SUPPORTED_ROUTE_KINDS - } + return VersionsResponse( + policyengine=_version_map_from_state(state, "policyengine"), + us=_version_map_from_state(state, "us"), + uk=_version_map_from_state(state, "uk"), + ) # ``Dict.from_name`` is a lazy handle; the RPCs fire during the # ``dict(...)`` iterations, so those are what the segment must cover. @@ -940,25 +952,25 @@ async def list_versions(): policyengine_dict = _optional_modal_dict(POLICYENGINE_VERSION_DICT_NAME) us_dict = modal.Dict.from_name("simulation-api-us-versions") uk_dict = modal.Dict.from_name("simulation-api-uk-versions") - return { - "policyengine": ( + return VersionsResponse( + policyengine=VersionMap( dict(policyengine_dict) if policyengine_dict is not None else {} ), - "us": dict(us_dict), - "uk": dict(uk_dict), - } + us=VersionMap(dict(us_dict)), + uk=VersionMap(dict(uk_dict)), + ) -def _version_map_from_state(state: dict, kind: str) -> dict: - versions = dict(_routing_state_routes(state, kind)) +def _version_map_from_state(state: VersionRoutingState, kind: str) -> VersionMap: + versions: dict[str, str] = dict(_routing_state_routes(state, kind)) latest = _routing_state_latest(state, kind) if latest is not None: versions["latest"] = latest - return versions + return VersionMap(versions) -@router.get("/versions/{kind}") -async def get_country_versions(kind: str): +@router.get("/versions/{kind}", response_model=VersionMap) +async def get_country_versions(kind: str) -> VersionMap: """Get available versions for policyengine, US, or UK routing.""" kind_lower = kind.lower() if kind_lower not in SUPPORTED_ROUTE_KINDS: @@ -975,17 +987,17 @@ async def get_country_versions(kind: str): if kind_lower == "policyengine": with segment(SegmentName.MODAL_DICT_READ): version_dict = _optional_modal_dict(POLICYENGINE_VERSION_DICT_NAME) - return dict(version_dict) if version_dict is not None else {} + return VersionMap(dict(version_dict) if version_dict is not None else {}) with segment(SegmentName.MODAL_DICT_READ): version_dict = modal.Dict.from_name(f"simulation-api-{kind_lower}-versions") - return dict(version_dict) + return VersionMap(dict(version_dict)) -@router.get("/health") -async def health(): +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: """Health check endpoint.""" - return {"status": "healthy"} + return HealthResponse() @router.post("/ping", response_model=PingResponse) diff --git a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py index 59cca2635..ae3181740 100644 --- a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py +++ b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py @@ -18,11 +18,14 @@ BudgetWindowBatchRequest, BudgetWindowBatchStatusResponse, BudgetWindowBatchSubmitResponse, + HealthResponse, JobStatusResponse, JobSubmitResponse, PingRequest, PingResponse, SimulationRequest, + VersionMap, + VersionsResponse, ) @@ -112,20 +115,20 @@ async def get_budget_window_job_status( """ raise NotImplementedError("Stub for OpenAPI generation") - @app.get("/versions") - async def list_versions() -> dict: + @app.get("/versions", response_model=VersionsResponse) + async def list_versions() -> VersionsResponse: """List all available routing versions.""" raise NotImplementedError("Stub for OpenAPI generation") - @app.get("/versions/{kind}") - async def get_country_versions(kind: str) -> dict: + @app.get("/versions/{kind}", response_model=VersionMap) + async def get_country_versions(kind: str) -> VersionMap: """Get available versions for policyengine, US, or UK routing.""" raise NotImplementedError("Stub for OpenAPI generation") - @app.get("/health") - async def health() -> dict: + @app.get("/health", response_model=HealthResponse) + async def health() -> HealthResponse: """Health check endpoint.""" - return {"status": "healthy"} + return HealthResponse() @app.post("/ping", response_model=PingResponse) async def ping(request: PingRequest) -> PingResponse: diff --git a/projects/policyengine-simulation-gateway/tests/golden/openapi.json b/projects/policyengine-simulation-gateway/tests/golden/openapi.json index 60fe1b929..606d51a3d 100644 --- a/projects/policyengine-simulation-gateway/tests/golden/openapi.json +++ b/projects/policyengine-simulation-gateway/tests/golden/openapi.json @@ -199,9 +199,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response List Versions Versions Get" + "$ref": "#/components/schemas/VersionsResponse" } } } @@ -231,9 +229,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Get Country Versions Versions Kind Get" + "$ref": "#/components/schemas/VersionMap" } } } @@ -262,9 +258,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Health Health Get" + "$ref": "#/components/schemas/HealthResponse" } } } @@ -314,6 +308,50 @@ }, "components": { "schemas": { + "AgePovertyOutput": { + "properties": { + "child": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "adult": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "senior": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "all": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "child", + "adult", + "senior", + "all" + ], + "title": "AgePovertyOutput" + }, + "BaselineReformValue": { + "properties": { + "baseline": { + "type": "number", + "title": "Baseline" + }, + "reform": { + "type": "number", + "title": "Reform" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform" + ], + "title": "BaselineReformValue" + }, "BatchChildJobStatus": { "properties": { "job_id": { @@ -322,6 +360,14 @@ }, "status": { "type": "string", + "enum": [ + "pending", + "queued", + "running", + "complete", + "failed", + "cancelled" + ], "title": "Status" }, "error": { @@ -457,30 +503,22 @@ "reform": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Reform" + ] }, "baseline": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Baseline" - }, - "region": { - "type": "string", - "title": "Region" + ] }, "region_group": { "anyOf": [ @@ -551,6 +589,10 @@ ], "title": "Segmented" }, + "region": { + "type": "string", + "title": "Region" + }, "start_year": { "type": "string", "title": "Start Year" @@ -590,6 +632,12 @@ "properties": { "status": { "type": "string", + "enum": [ + "submitted", + "running", + "complete", + "failed" + ], "title": "Status" }, "progress": { @@ -707,6 +755,7 @@ }, "status": { "type": "string", + "const": "submitted", "title": "Status" }, "poll_url": { @@ -834,6 +883,289 @@ "title": "BudgetWindowTotals", "description": "Aggregate totals for a completed budget-window response." }, + "BudgetaryImpact": { + "properties": { + "tax_revenue_impact": { + "type": "number", + "title": "Tax Revenue Impact" + }, + "state_tax_revenue_impact": { + "type": "number", + "title": "State Tax Revenue Impact" + }, + "benefit_spending_impact": { + "type": "number", + "title": "Benefit Spending Impact" + }, + "budgetary_impact": { + "type": "number", + "title": "Budgetary Impact" + }, + "households": { + "type": "number", + "title": "Households" + }, + "baseline_net_income": { + "type": "number", + "title": "Baseline Net Income" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "budgetary_impact", + "households", + "baseline_net_income" + ], + "title": "BudgetaryImpact" + }, + "CliffImpactInSimulation": { + "properties": { + "cliff_gap": { + "type": "number", + "title": "Cliff Gap" + }, + "cliff_share": { + "type": "number", + "title": "Cliff Share" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "cliff_gap", + "cliff_share" + ], + "title": "CliffImpactInSimulation" + }, + "CliffImpactOutput": { + "properties": { + "baseline": { + "$ref": "#/components/schemas/CliffImpactInSimulation" + }, + "reform": { + "$ref": "#/components/schemas/CliffImpactInSimulation" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform" + ], + "title": "CliffImpactOutput" + }, + "CongressionalDistrictImpactOutput": { + "properties": { + "districts": { + "items": { + "$ref": "#/components/schemas/CongressionalDistrictImpactRecord" + }, + "type": "array", + "title": "Districts" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "districts" + ], + "title": "CongressionalDistrictImpactOutput" + }, + "CongressionalDistrictImpactRecord": { + "properties": { + "district": { + "type": "string", + "title": "District" + }, + "average_household_income_change": { + "type": "number", + "title": "Average Household Income Change" + }, + "relative_household_income_change": { + "type": "number", + "title": "Relative Household Income Change" + }, + "winner_percentage": { + "type": "number", + "title": "Winner Percentage" + }, + "loser_percentage": { + "type": "number", + "title": "Loser Percentage" + }, + "no_change_percentage": { + "type": "number", + "title": "No Change Percentage" + }, + "population": { + "type": "number", + "title": "Population" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "district", + "average_household_income_change", + "relative_household_income_change", + "winner_percentage", + "loser_percentage", + "no_change_percentage", + "population" + ], + "title": "CongressionalDistrictImpactRecord" + }, + "ConstituencyImpactRecord": { + "properties": { + "constituency_code": { + "type": "string", + "title": "Constituency Code" + }, + "constituency_name": { + "type": "string", + "title": "Constituency Name" + }, + "x": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Y" + }, + "average_household_income_change": { + "type": "number", + "title": "Average Household Income Change" + }, + "relative_household_income_change": { + "type": "number", + "title": "Relative Household Income Change" + }, + "population": { + "type": "number", + "title": "Population" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "constituency_code", + "constituency_name", + "x", + "y", + "average_household_income_change", + "relative_household_income_change", + "population" + ], + "title": "ConstituencyImpactRecord" + }, + "DecileOutput": { + "properties": { + "average": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Average" + }, + "relative": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Relative" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "average", + "relative" + ], + "title": "DecileOutput" + }, + "DetailedBudgetOutput": { + "additionalProperties": { + "$ref": "#/components/schemas/DetailedBudgetProgramOutput" + }, + "type": "object", + "title": "DetailedBudgetOutput", + "description": "Program name mapped to its baseline and reform totals." + }, + "DetailedBudgetProgramOutput": { + "properties": { + "baseline": { + "type": "number", + "title": "Baseline" + }, + "reform": { + "type": "number", + "title": "Reform" + }, + "difference": { + "type": "number", + "title": "Difference" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform", + "difference" + ], + "title": "DetailedBudgetProgramOutput" + }, + "GenderPovertyOutput": { + "properties": { + "male": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "female": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "male", + "female" + ], + "title": "GenderPovertyOutput" + }, + "GeographicImpactOutput": { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConstituencyImpactRecord" + }, + { + "$ref": "#/components/schemas/LocalAuthorityImpactRecord" + } + ] + }, + "type": "array", + "title": "GeographicImpactOutput", + "description": "UK constituency or local-authority impact records." + }, "HTTPValidationError": { "properties": { "detail": { @@ -847,23 +1179,88 @@ "type": "object", "title": "HTTPValidationError" }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "const": "healthy", + "title": "Status", + "default": "healthy" + } + }, + "type": "object", + "title": "HealthResponse", + "description": "Local process health response." + }, + "InequalityOutput": { + "properties": { + "gini": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "top_10_pct_share": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "top_1_pct_share": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "title": "InequalityOutput" + }, + "IntraDecileOutput": { + "properties": { + "deciles": { + "additionalProperties": { + "items": { + "type": "number" + }, + "type": "array" + }, + "type": "object", + "title": "Deciles" + }, + "all": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "All" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "deciles", + "all" + ], + "title": "IntraDecileOutput" + }, "JobStatusResponse": { "properties": { "status": { "type": "string", + "enum": [ + "running", + "complete", + "failed" + ], "title": "Status" }, "result": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/SingleYearMacroOutput" }, { "type": "null" } - ], - "title": "Result" + ] }, "error": { "anyOf": [ @@ -924,6 +1321,7 @@ }, "status": { "type": "string", + "const": "submitted", "title": "Status" }, "poll_url": { @@ -970,6 +1368,207 @@ "title": "JobSubmitResponse", "description": "Response model for job submission." }, + "JsonObject": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + }, + "JsonValue": {}, + "LaborSupplyDecileMetric": { + "properties": { + "income": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Income" + }, + "substitution": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Substitution" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "income", + "substitution" + ], + "title": "LaborSupplyDecileMetric" + }, + "LaborSupplyDecileOutput": { + "properties": { + "average": { + "$ref": "#/components/schemas/LaborSupplyDecileMetric" + }, + "relative": { + "$ref": "#/components/schemas/LaborSupplyDecileMetric" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "average", + "relative" + ], + "title": "LaborSupplyDecileOutput" + }, + "LaborSupplyHoursOutput": { + "properties": { + "baseline": { + "type": "number", + "title": "Baseline" + }, + "reform": { + "type": "number", + "title": "Reform" + }, + "change": { + "type": "number", + "title": "Change" + }, + "income_effect": { + "type": "number", + "title": "Income Effect" + }, + "substitution_effect": { + "type": "number", + "title": "Substitution Effect" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "title": "LaborSupplyHoursOutput" + }, + "LaborSupplyRelativeResponse": { + "properties": { + "income": { + "type": "number", + "title": "Income" + }, + "substitution": { + "type": "number", + "title": "Substitution" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "income", + "substitution" + ], + "title": "LaborSupplyRelativeResponse" + }, + "LaborSupplyResponseOutput": { + "properties": { + "substitution_lsr": { + "type": "number", + "title": "Substitution Lsr" + }, + "income_lsr": { + "type": "number", + "title": "Income Lsr" + }, + "relative_lsr": { + "$ref": "#/components/schemas/LaborSupplyRelativeResponse" + }, + "total_change": { + "type": "number", + "title": "Total Change" + }, + "revenue_change": { + "type": "number", + "title": "Revenue Change" + }, + "decile": { + "$ref": "#/components/schemas/LaborSupplyDecileOutput" + }, + "hours": { + "$ref": "#/components/schemas/LaborSupplyHoursOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "title": "LaborSupplyResponseOutput" + }, + "LocalAuthorityImpactRecord": { + "properties": { + "local_authority_code": { + "type": "string", + "title": "Local Authority Code" + }, + "local_authority_name": { + "type": "string", + "title": "Local Authority Name" + }, + "x": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Y" + }, + "average_household_income_change": { + "type": "number", + "title": "Average Household Income Change" + }, + "relative_household_income_change": { + "type": "number", + "title": "Relative Household Income Change" + }, + "population": { + "type": "number", + "title": "Population" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "local_authority_code", + "local_authority_name", + "x", + "y", + "average_household_income_change", + "relative_household_income_change", + "population" + ], + "title": "LocalAuthorityImpactRecord" + }, "PingRequest": { "properties": { "value": { @@ -1045,6 +1644,83 @@ "title": "PolicyEngineBundle", "description": "Resolved runtime provenance returned by the gateway." }, + "PolicyParameterChanges": { + "$ref": "#/components/schemas/JsonObject", + "title": "PolicyParameterChanges", + "description": "Policy parameter names mapped to their dated values." + }, + "PovertyByGenderOutput": { + "properties": { + "poverty": { + "$ref": "#/components/schemas/GenderPovertyOutput" + }, + "deep_poverty": { + "$ref": "#/components/schemas/GenderPovertyOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "poverty", + "deep_poverty" + ], + "title": "PovertyByGenderOutput" + }, + "PovertyByRaceOutput": { + "properties": { + "poverty": { + "$ref": "#/components/schemas/RacePovertyOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "poverty" + ], + "title": "PovertyByRaceOutput" + }, + "PovertyOutput": { + "properties": { + "poverty": { + "$ref": "#/components/schemas/AgePovertyOutput" + }, + "deep_poverty": { + "$ref": "#/components/schemas/AgePovertyOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "poverty", + "deep_poverty" + ], + "title": "PovertyOutput" + }, + "RacePovertyOutput": { + "properties": { + "white": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "black": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "hispanic": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "other": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "white", + "black", + "hispanic", + "other" + ], + "title": "RacePovertyOutput" + }, "SimulationRequest": { "properties": { "country": { @@ -1119,37 +1795,22 @@ "reform": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Reform" + ] }, "baseline": { "anyOf": [ { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Baseline" - }, - "region": { - "anyOf": [ - { - "type": "string" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Region" + ] }, "region_group": { "anyOf": [ @@ -1219,6 +1880,17 @@ } ], "title": "Segmented" + }, + "region": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Region" } }, "additionalProperties": false, @@ -1229,6 +1901,141 @@ "title": "SimulationRequest", "description": "Request model for simulation submission." }, + "SingleYearMacroOutput": { + "properties": { + "model_version": { + "type": "string", + "title": "Model Version" + }, + "data_version": { + "type": "string", + "title": "Data Version" + }, + "budget": { + "$ref": "#/components/schemas/BudgetaryImpact" + }, + "detailed_budget": { + "$ref": "#/components/schemas/DetailedBudgetOutput" + }, + "decile": { + "$ref": "#/components/schemas/DecileOutput" + }, + "inequality": { + "$ref": "#/components/schemas/InequalityOutput" + }, + "poverty": { + "$ref": "#/components/schemas/PovertyOutput" + }, + "poverty_by_gender": { + "$ref": "#/components/schemas/PovertyByGenderOutput" + }, + "poverty_by_race": { + "anyOf": [ + { + "$ref": "#/components/schemas/PovertyByRaceOutput" + }, + { + "type": "null" + } + ] + }, + "intra_decile": { + "$ref": "#/components/schemas/IntraDecileOutput" + }, + "wealth_decile": { + "anyOf": [ + { + "$ref": "#/components/schemas/DecileOutput" + }, + { + "type": "null" + } + ] + }, + "intra_wealth_decile": { + "anyOf": [ + { + "$ref": "#/components/schemas/IntraDecileOutput" + }, + { + "type": "null" + } + ] + }, + "labor_supply_response": { + "anyOf": [ + { + "$ref": "#/components/schemas/LaborSupplyResponseOutput" + }, + { + "type": "null" + } + ] + }, + "constituency_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/GeographicImpactOutput" + }, + { + "type": "null" + } + ] + }, + "local_authority_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/GeographicImpactOutput" + }, + { + "type": "null" + } + ] + }, + "congressional_district_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/CongressionalDistrictImpactOutput" + }, + { + "type": "null" + } + ] + }, + "cliff_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/CliffImpactOutput" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "model_version", + "data_version", + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact", + "congressional_district_impact" + ], + "title": "SingleYearMacroOutput", + "description": "Completed response returned by a single-year macro simulation." + }, "TelemetryEnvelope": { "properties": { "run_id": { @@ -1377,6 +2184,35 @@ "type" ], "title": "ValidationError" + }, + "VersionMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "VersionMap", + "description": "Version alias or exact version mapped to a deployed worker app." + }, + "VersionsResponse": { + "properties": { + "policyengine": { + "$ref": "#/components/schemas/VersionMap" + }, + "us": { + "$ref": "#/components/schemas/VersionMap" + }, + "uk": { + "$ref": "#/components/schemas/VersionMap" + } + }, + "type": "object", + "required": [ + "policyengine", + "us", + "uk" + ], + "title": "VersionsResponse", + "description": "All supported simulation routing version maps." } } } From 4117005554a67af560889d64be2857363d27f284 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 23:47:23 +0300 Subject: [PATCH 11/11] fix: keep one-off infrastructure setup private --- .../bootstrap-cloud-run-simulation-entry.sh | 208 ------------------ .../workflows/cloud-run-simulation-entry.yml | 4 +- .../DEPLOYMENT.md | 99 +++------ .../tests/test_deployment_assets.py | 62 +----- 4 files changed, 37 insertions(+), 336 deletions(-) delete mode 100644 .github/scripts/bootstrap-cloud-run-simulation-entry.sh diff --git a/.github/scripts/bootstrap-cloud-run-simulation-entry.sh b/.github/scripts/bootstrap-cloud-run-simulation-entry.sh deleted file mode 100644 index 72fa8951a..000000000 --- a/.github/scripts/bootstrap-cloud-run-simulation-entry.sh +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -gcloud_bin="${GCLOUD_BIN:-gcloud}" -project_id="${SIMULATION_ENTRYPOINT_GCP_PROJECT_ID:?SIMULATION_ENTRYPOINT_GCP_PROJECT_ID is required}" -billing_account="${SIMULATION_ENTRYPOINT_GCP_BILLING_ACCOUNT:-}" -region="${SIMULATION_ENTRYPOINT_GCP_REGION:-us-central1}" -repository="${SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY:-policyengine-simulation-entry}" -github_repository="${SIMULATION_ENTRYPOINT_GITHUB_REPOSITORY:-PolicyEngine/policyengine-sim-api}" -pool_id="${SIMULATION_ENTRYPOINT_WIF_POOL_ID:-simulation-entry-github}" -provider_id="${SIMULATION_ENTRYPOINT_WIF_PROVIDER_ID:-github}" -dry_run="${SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN:-0}" -workflow_ref="${github_repository}/.github/workflows/cloud-run-simulation-entry.yml@refs/heads/main" - -deployer_account_id="sim-entry-gh-deployer" -staging_runtime_account_id="sim-entry-stg-runtime" -production_runtime_account_id="sim-entry-prod-runtime" -staging_secret="simulation-entry-old-gateway-client-secret-staging" -production_secret="simulation-entry-old-gateway-client-secret-production" - -if [ "${#project_id}" -gt 30 ] || - ! [[ "${project_id}" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]]; then - printf 'SIMULATION_ENTRYPOINT_GCP_PROJECT_ID must be a valid 6-30 character GCP project ID\n' >&2 - exit 2 -fi - -run() { - if [ "${dry_run}" = "1" ]; then - printf '+' - printf ' %q' "$@" - printf '\n' - return 0 - fi - "$@" -} - -exists() { - if [ "${dry_run}" = "1" ]; then - return 1 - fi - "$@" >/dev/null 2>&1 -} - -if ! exists "${gcloud_bin}" projects describe "${project_id}"; then - project_args=( - projects create "${project_id}" - --name "PE Simulation Entrypoint" - ) - if [ -n "${SIMULATION_ENTRYPOINT_GCP_FOLDER_ID:-}" ]; then - project_args+=(--folder "${SIMULATION_ENTRYPOINT_GCP_FOLDER_ID}") - elif [ -n "${SIMULATION_ENTRYPOINT_GCP_ORGANIZATION_ID:-}" ]; then - project_args+=(--organization "${SIMULATION_ENTRYPOINT_GCP_ORGANIZATION_ID}") - fi - run "${gcloud_bin}" "${project_args[@]}" -fi - -if [ -n "${billing_account}" ]; then - run "${gcloud_bin}" billing projects link "${project_id}" \ - --billing-account "${billing_account}" -fi - -run "${gcloud_bin}" services enable \ - artifactregistry.googleapis.com \ - cloudresourcemanager.googleapis.com \ - iam.googleapis.com \ - iamcredentials.googleapis.com \ - run.googleapis.com \ - secretmanager.googleapis.com \ - sts.googleapis.com \ - --project "${project_id}" - -if ! exists "${gcloud_bin}" artifacts repositories describe "${repository}" \ - --project "${project_id}" \ - --location "${region}"; then - run "${gcloud_bin}" artifacts repositories create "${repository}" \ - --project "${project_id}" \ - --location "${region}" \ - --repository-format docker \ - --description "Cloud Run images for the PolicyEngine Simulation Entrypoint" -fi - -ensure_service_account() { - local account_id="${1:?service account ID is required}" - local display_name="${2:?display name is required}" - local email="${account_id}@${project_id}.iam.gserviceaccount.com" - if ! exists "${gcloud_bin}" iam service-accounts describe "${email}" \ - --project "${project_id}"; then - run "${gcloud_bin}" iam service-accounts create "${account_id}" \ - --project "${project_id}" \ - --display-name "${display_name}" - fi -} - -ensure_service_account "${deployer_account_id}" \ - "Simulation Entrypoint GitHub deployer" -ensure_service_account "${staging_runtime_account_id}" \ - "Simulation Entrypoint staging runtime" -ensure_service_account "${production_runtime_account_id}" \ - "Simulation Entrypoint production runtime" - -deployer_email="${deployer_account_id}@${project_id}.iam.gserviceaccount.com" -staging_runtime_email="${staging_runtime_account_id}@${project_id}.iam.gserviceaccount.com" -production_runtime_email="${production_runtime_account_id}@${project_id}.iam.gserviceaccount.com" - -for role in \ - roles/artifactregistry.writer \ - roles/run.admin \ - roles/secretmanager.viewer \ - roles/serviceusage.serviceUsageConsumer; do - run "${gcloud_bin}" projects add-iam-policy-binding "${project_id}" \ - --member "serviceAccount:${deployer_email}" \ - --role "${role}" \ - --condition=None \ - --quiet -done - -for runtime_email in "${staging_runtime_email}" "${production_runtime_email}"; do - run "${gcloud_bin}" iam service-accounts add-iam-policy-binding \ - "${runtime_email}" \ - --project "${project_id}" \ - --member "serviceAccount:${deployer_email}" \ - --role roles/iam.serviceAccountUser \ - --quiet -done - -ensure_secret() { - local secret="${1:?secret name is required}" - if ! exists "${gcloud_bin}" secrets describe "${secret}" \ - --project "${project_id}"; then - run "${gcloud_bin}" secrets create "${secret}" \ - --project "${project_id}" \ - --replication-policy automatic - fi -} - -ensure_secret "${staging_secret}" -ensure_secret "${production_secret}" - -run "${gcloud_bin}" secrets add-iam-policy-binding "${staging_secret}" \ - --project "${project_id}" \ - --member "serviceAccount:${staging_runtime_email}" \ - --role roles/secretmanager.secretAccessor \ - --quiet -run "${gcloud_bin}" secrets add-iam-policy-binding "${production_secret}" \ - --project "${project_id}" \ - --member "serviceAccount:${production_runtime_email}" \ - --role roles/secretmanager.secretAccessor \ - --quiet - -if ! exists "${gcloud_bin}" iam workload-identity-pools describe "${pool_id}" \ - --project "${project_id}" \ - --location global; then - run "${gcloud_bin}" iam workload-identity-pools create "${pool_id}" \ - --project "${project_id}" \ - --location global \ - --display-name "Simulation Entrypoint GitHub Actions" -fi - -provider_args=( - "${provider_id}" - --project "${project_id}" - --location global - --workload-identity-pool "${pool_id}" - --issuer-uri "https://token.actions.githubusercontent.com" - --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref,attribute.workflow_ref=assertion.workflow_ref" - --attribute-condition "assertion.repository == '${github_repository}' && assertion.ref == 'refs/heads/main' && assertion.workflow_ref == '${workflow_ref}'" -) - -if exists "${gcloud_bin}" iam workload-identity-pools providers describe \ - "${provider_id}" \ - --project "${project_id}" \ - --location global \ - --workload-identity-pool "${pool_id}"; then - run "${gcloud_bin}" iam workload-identity-pools providers update-oidc \ - "${provider_args[@]}" \ - --display-name "PolicyEngine simulation repository" \ - --quiet -else - run "${gcloud_bin}" iam workload-identity-pools providers create-oidc \ - "${provider_args[@]}" \ - --display-name "PolicyEngine simulation repository" -fi - -if [ "${dry_run}" = "1" ]; then - project_number="000000000000" -else - project_number="$("${gcloud_bin}" projects describe "${project_id}" \ - --format 'value(projectNumber)')" -fi -run "${gcloud_bin}" iam service-accounts add-iam-policy-binding \ - "${deployer_email}" \ - --project "${project_id}" \ - --member "principalSet://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/attribute.repository/${github_repository}" \ - --role roles/iam.workloadIdentityUser \ - --quiet - -provider_name="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${provider_id}" - -printf '\nBootstrap complete. Configure these GitHub values:\n' -printf ' SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=%s\n' "${project_id}" -printf ' SIMULATION_ENTRYPOINT_GCP_REGION=%s\n' "${region}" -printf ' SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY=%s\n' "${repository}" -printf ' SIMULATION_ENTRYPOINT_GCP_WIF_PROVIDER=%s\n' "${provider_name}" -printf ' SIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT=%s\n' "${deployer_email}" -printf ' staging SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT=%s\n' "${staging_runtime_email}" -printf ' production SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT=%s\n' "${production_runtime_email}" -printf '\nAdd one secret version to each empty Secret Manager secret before deploying.\n' diff --git a/.github/workflows/cloud-run-simulation-entry.yml b/.github/workflows/cloud-run-simulation-entry.yml index 0624d36b3..20a98f533 100644 --- a/.github/workflows/cloud-run-simulation-entry.yml +++ b/.github/workflows/cloud-run-simulation-entry.yml @@ -94,7 +94,7 @@ jobs: --allow-unauthenticated \ --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ --set-env-vars "^@^APP_ENVIRONMENT=staging@SIMULATION_ENTRYPOINT_PUBLIC_URL=https://staging.simulation.api.policyengine.org@SIMULATION_ENTRYPOINT_AUTH_REQUIRED=1@SIMULATION_ENTRYPOINT_AUTH_ISSUER=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}@SIMULATION_ENTRYPOINT_AUTH_AUDIENCE=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ - --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entry-old-gateway-client-secret-staging:latest" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}:latest" \ --cpu 1 \ --memory 512Mi \ --concurrency 80 \ @@ -168,7 +168,7 @@ jobs: --allow-unauthenticated \ --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ --set-env-vars "^@^APP_ENVIRONMENT=production@SIMULATION_ENTRYPOINT_PUBLIC_URL=https://simulation.api.policyengine.org@SIMULATION_ENTRYPOINT_AUTH_REQUIRED=1@SIMULATION_ENTRYPOINT_AUTH_ISSUER=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}@SIMULATION_ENTRYPOINT_AUTH_AUDIENCE=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ - --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=simulation-entry-old-gateway-client-secret-production:latest" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}:latest" \ --cpu 1 \ --memory 512Mi \ --concurrency 80 \ diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index cb46fe181..39999e922 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -1,81 +1,46 @@ # Cloud Run deployment -Infrastructure is bootstrapped with gcloud and application revisions are owned -by the dedicated GitHub Actions workflow. +The Simulation Entry service is deployed to Cloud Run by a dedicated GitHub +Actions workflow. This document describes the deployment model only. Concrete +cloud projects, identities, IAM bindings, domains, and secret resources are +managed through private operator documentation. -## One-time bootstrap +## Infrastructure -Authenticate as an organization principal that can create projects, attach -billing, and administer IAM. Then run: +Initial cloud infrastructure is configured once by an authorized operator. It +is intentionally not provisioned by a script in this repository. - SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entry \ - SIMULATION_ENTRYPOINT_GCP_BILLING_ACCOUNT=BILLING_ACCOUNT_ID \ - bash .github/scripts/bootstrap-cloud-run-simulation-entry.sh +The deployment environment must provide: -Set SIMULATION_ENTRYPOINT_GCP_FOLDER_ID or SIMULATION_ENTRYPOINT_GCP_ORGANIZATION_ID when project -placement is not inherited automatically. +- an image registry and Cloud Run services; +- separate non-production and production runtime identities; +- GitHub workload identity federation with narrowly scoped trust; +- protected environment configuration and secret storage; and +- domain and traffic-management configuration. -The script is idempotent. It enables APIs and creates the Artifact Registry -repository, staging/production runtime identities, GitHub deployer identity, -least-privilege IAM bindings, empty Secret Manager secrets, and the dedicated -GitHub workload identity pool/provider. +Credential values belong in the cloud secret store. They must not be committed +to the repository or stored as plain GitHub variables. The deployment workflow +receives only the configuration needed to reference those protected resources. -Use SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN=1 to print the commands without changing GCP. +## Candidate deployment -## Secrets and GitHub environments +For each eligible revision, the workflow: -Add one version to each secret created by the bootstrap: +1. runs the normal test suite; +2. builds and publishes an immutable container image; +3. deploys a tagged, no-traffic non-production candidate; +4. runs public and authenticated checks against that candidate; +5. deploys a tagged, no-traffic production candidate; and +6. repeats the qualification checks against the production candidate. -- simulation-entry-old-gateway-client-secret-staging -- simulation-entry-old-gateway-client-secret-production +The workflow does not assign production traffic. -Configure the printed project/WIF values as repository variables or secrets. -Configure SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT separately in the staging and -production GitHub environments. Each environment also supplies its own old -gateway URL/client ID and the shared Auth0 issuer/audiences. +## Promotion and rollback -For the separate live authentication job, configure an M2M application that is -authorized for the Simulation Entrypoint audience and add these environment -secrets in both staging and production: +An authorized operator promotes an exact, qualified revision after reviewing +the deployment evidence. The operator verifies that the revision belongs to +the intended service and is ready before changing traffic. -- SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID -- SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET - -The bootstrap restricts workload identity federation to `main` executions of -`.github/workflows/cloud-run-simulation-entry.yml`. Re-running it reconciles an -existing provider to that condition. - -## Deploy candidates - -The Deploy Simulation Entrypoint to Cloud Run workflow: - -1. runs the normal unit test set and builds one immutable image; -2. deploys and public-smoke-tests a tagged no-traffic staging revision; -3. runs the separate authenticated test set against the staging candidate; -4. deploys and public-smoke-tests a tagged no-traffic production revision; -5. runs the separate authenticated test set against the production candidate. - -The workflow never changes a service's traffic. After reviewing its smoke -and authenticated-test evidence, an authorized operator promotes an exact -known-good revision manually: - - SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entry \ - SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT=staging \ - SIMULATION_ENTRYPOINT_TARGET_REVISION=REVISION_NAME \ - bash .github/scripts/set-cloud-run-simulation-entry-revision.sh - -Use `SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT=production` for production. Rollback uses -the same command with the prior known-good revision. The script verifies that -the revision is Ready and belongs to the selected service before assigning it -100 percent. Use `SIMULATION_ENTRYPOINT_TRAFFIC_DRY_RUN=1` to inspect the command without -changing traffic. - -After the first successful deployment creates both services, configure the -domain mappings idempotently: - - SIMULATION_ENTRYPOINT_GCP_PROJECT_ID=policyengine-simulation-entry \ - bash .github/scripts/configure-cloud-run-simulation-entry-domains.sh - -Inspect the mappings and publish the returned DNS records for -staging.simulation.api.policyengine.org and simulation.api.policyengine.org. -Use SIMULATION_ENTRYPOINT_DOMAINS_DRY_RUN=1 to preview this phase. +Rollback follows the same process, targeting the previous known-good revision. +Traffic changes, domain setup, and infrastructure administration are governed +by private operational runbooks rather than this public repository. diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index 29b6fd54b..8363ac01d 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -7,9 +7,6 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3] -BOOTSTRAP_SCRIPT = ( - REPOSITORY_ROOT / ".github" / "scripts" / "bootstrap-cloud-run-simulation-entry.sh" -) DOMAIN_SCRIPT = ( REPOSITORY_ROOT / ".github" @@ -38,66 +35,11 @@ ) -def test_bootstrap_script_has_valid_shell_syntax(): - subprocess.run(["bash", "-n", BOOTSTRAP_SCRIPT], check=True) +def test_operator_scripts_have_valid_shell_syntax(): subprocess.run(["bash", "-n", DOMAIN_SCRIPT], check=True) subprocess.run(["bash", "-n", TRAFFIC_SCRIPT], check=True) -def test_bootstrap_dry_run_is_self_contained(): - environment = { - **os.environ, - "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", - "SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN": "1", - } - - result = subprocess.run( - ["bash", BOOTSTRAP_SCRIPT], - check=True, - capture_output=True, - text=True, - env=environment, - ) - - assert "projects create simulation-entry-test" in result.stdout - assert ( - "artifacts repositories create policyengine-simulation-entry" in result.stdout - ) - assert "roles/serviceusage.serviceUsageConsumer" in result.stdout - assert "Bootstrap complete" in result.stdout - assert "000000000000" in result.stdout - - -def test_bootstrap_rejects_invalid_project_id(): - result = subprocess.run( - ["bash", BOOTSTRAP_SCRIPT], - check=False, - capture_output=True, - text=True, - env={ - **os.environ, - "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": ( - "policyengine-simulation-entry-too-long" - ), - "SIMULATION_ENTRYPOINT_BOOTSTRAP_DRY_RUN": "1", - }, - ) - - assert result.returncode == 2 - assert "valid 6-30 character GCP project ID" in result.stderr - - -def test_workload_identity_is_limited_to_main_deployment_workflow(): - bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8") - - assert "assertion.ref == 'refs/heads/main'" in bootstrap - assert "assertion.workflow_ref ==" in bootstrap - assert ( - ".github/workflows/cloud-run-simulation-entry.yml@refs/heads/main" in bootstrap - ) - assert "providers update-oidc" in bootstrap - - def test_deployment_uses_gcloud_workflow_without_terraform(): workflow = WORKFLOW.read_text(encoding="utf-8") @@ -110,6 +52,8 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): assert "needs: deploy-staging" in workflow assert "needs: deploy-production-candidate" in workflow assert workflow.count("id-token: write") == 3 + assert workflow.count("vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME") == 2 + assert "simulation-entry-old-gateway-client-secret" not in workflow assert AUTHENTICATED_TESTS.joinpath("test_deployed_auth.py").is_file() assert not list( (REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" / "infra").glob(