From 66d95f0ecd3da955e8d1003dca17cec80d5bc68d Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:49:51 +0300 Subject: [PATCH 01/15] 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 407482b33..53a20d318 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 0d1f540de77abfe39bf2c20e82bbf66467b007e2 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:57:35 +0300 Subject: [PATCH 02/15] 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 ea5e96657172a9b18426014faf41bbb1a3d11bfd Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:57:52 +0300 Subject: [PATCH 03/15] 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 260965331baa7c8319baf99014fe585b7b802aef Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 19:00:29 +0300 Subject: [PATCH 04/15] 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 9f3af15f515b10cdc2d48df742d1343dddbc555b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 00:59:36 +0300 Subject: [PATCH 05/15] 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 b24185aabd7baf63802e68ed64c70906db93b606 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 01:14:44 +0300 Subject: [PATCH 06/15] 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 8e1fde6031189a88507cc629252c98b83534c059 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 01:18:28 +0300 Subject: [PATCH 07/15] 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 d3e22a20114929cb6d0f8f4c934bf58246be502f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 17:28:36 +0300 Subject: [PATCH 08/15] 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 58fcd957d7a343beb9d7224062f277a9afb69ebe Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 20:12:20 +0300 Subject: [PATCH 09/15] 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 a85819f01..323a98972 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 cbd0af574..7b97f7b1a 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 53a20d318..11934a5f6 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" @@ -1861,7 +1867,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" }, @@ -1909,7 +1915,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" }, @@ -1973,7 +1979,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 3e99e353ab170b7147a501feadd40465a7d44119 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 23:26:54 +0300 Subject: [PATCH 10/15] 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 58170baf3..cc9225d93 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): @@ -234,7 +259,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 ), @@ -244,7 +271,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 @@ -709,7 +736,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", @@ -834,8 +861,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(): @@ -1420,7 +1447,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" @@ -1438,12 +1465,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 @@ -1455,7 +1481,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" @@ -1473,12 +1499,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 83ba1dd30c6179c911d3c50a426a01d43b32dda1 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 23:47:23 +0300 Subject: [PATCH 11/15] 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( From fe9412f29b19f14642073cc9c4f83362aa229385 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 27 Jul 2026 16:57:10 +0300 Subject: [PATCH 12/15] Unify simulation deployment pipeline --- .../cloud-run-simulation-entry-smoke.sh | 0 .github/scripts/modal-deploy-app.sh | 70 --- ...ry.sh => simulation-deployment-summary.sh} | 14 +- ...tests.sh => simulation-run-integ-tests.sh} | 38 +- .../workflows/cloud-run-simulation-entry.yml | 211 --------- .github/workflows/modal-deploy.reusable.yml | 227 ---------- .github/workflows/modal-deploy.yml | 100 ----- .github/workflows/publish-clients.yml | 2 +- .../workflows/simulation-deploy.reusable.yml | 413 ++++++++++++++++++ .github/workflows/simulation-deploy.yml | 79 ++++ README.md | 24 +- .../DEPLOYMENT.md | 27 +- .../tests/test_deployment_assets.py | 88 +++- .../fixtures/test_modal_scripts.py | 7 +- .../tests/test_modal_scripts.py | 238 ++++------ 15 files changed, 708 insertions(+), 830 deletions(-) mode change 100644 => 100755 .github/scripts/cloud-run-simulation-entry-smoke.sh delete mode 100755 .github/scripts/modal-deploy-app.sh rename .github/scripts/{modal-deployment-summary.sh => simulation-deployment-summary.sh} (57%) rename .github/scripts/{modal-run-integ-tests.sh => simulation-run-integ-tests.sh} (72%) delete mode 100644 .github/workflows/cloud-run-simulation-entry.yml delete mode 100644 .github/workflows/modal-deploy.reusable.yml delete mode 100644 .github/workflows/modal-deploy.yml create mode 100644 .github/workflows/simulation-deploy.reusable.yml create mode 100644 .github/workflows/simulation-deploy.yml diff --git a/.github/scripts/cloud-run-simulation-entry-smoke.sh b/.github/scripts/cloud-run-simulation-entry-smoke.sh old mode 100644 new mode 100755 diff --git a/.github/scripts/modal-deploy-app.sh b/.github/scripts/modal-deploy-app.sh deleted file mode 100755 index 1ba1da99b..000000000 --- a/.github/scripts/modal-deploy-app.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# Deploy simulation API to Modal -# Usage: ./modal-deploy-app.sh [force-latest] -# Required env vars: POLICYENGINE_VERSION, POLICYENGINE_CORE_VERSION, -# POLICYENGINE_US_VERSION, POLICYENGINE_UK_VERSION -# These should come from the bundled policyengine.py release manifest. -# -# Deploys two apps: -# 1. policyengine-simulation-gateway - Stable gateway with fixed URL -# 2. policyengine-simulation-py{X} - Versioned simulation app - -set -euo pipefail - -MODAL_ENV="${1:?Modal environment required}" -FORCE_LATEST="${2:-false}" - -# Generate versioned simulation app name (dots replaced with dashes for URL safety) -POLICYENGINE_VERSION_SAFE="${POLICYENGINE_VERSION//./-}" -SIMULATION_APP_NAME="policyengine-simulation-py${POLICYENGINE_VERSION_SAFE}" -UPDATE_REGISTRY_COMMAND=( - uv run python -m src.modal.utils.update_version_registry - --app-name "$SIMULATION_APP_NAME" - --policyengine-version "${POLICYENGINE_VERSION}" - --us-version "${POLICYENGINE_US_VERSION}" - --uk-version "${POLICYENGINE_UK_VERSION}" - --environment "$MODAL_ENV" -) -if [[ "$FORCE_LATEST" == "true" || "$FORCE_LATEST" == "1" ]]; then - UPDATE_REGISTRY_COMMAND+=(--force-latest) -fi - -echo "========================================" -echo "Deploying to Modal environment: $MODAL_ENV" -echo " policyengine.py version: ${POLICYENGINE_VERSION}" -echo " policyengine-core version: ${POLICYENGINE_CORE_VERSION}" -echo " US version: ${POLICYENGINE_US_VERSION}" -echo " UK version: ${POLICYENGINE_UK_VERSION}" -echo " Force latest: ${FORCE_LATEST}" -echo "========================================" - -# 1. Deploy the gateway app (stable URL) from its own project -echo "" -echo "Step 1: Deploying gateway app..." -echo " App name: policyengine-simulation-gateway" -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -( - cd "$REPO_ROOT/projects/policyengine-simulation-gateway" - uv run modal deploy --env="$MODAL_ENV" \ - src/policyengine_simulation_gateway/app.py -) - -# 2. Deploy the versioned simulation app -echo "" -echo "Step 2: Deploying versioned simulation app..." -echo " App name: ${SIMULATION_APP_NAME}" -export MODAL_APP_NAME="$SIMULATION_APP_NAME" -uv run modal deploy --env="$MODAL_ENV" src/modal/app.py - -# 3. Publish active routing state -echo "" -echo "Step 3: Publishing active routing state..." -"${UPDATE_REGISTRY_COMMAND[@]}" - -echo "" -echo "========================================" -echo "Deployment complete!" -echo " Gateway app: policyengine-simulation-gateway" -echo " Simulation app: $SIMULATION_APP_NAME" -echo " Routing state: simulation-api-routing-state[active]" -echo "========================================" diff --git a/.github/scripts/modal-deployment-summary.sh b/.github/scripts/simulation-deployment-summary.sh similarity index 57% rename from .github/scripts/modal-deployment-summary.sh rename to .github/scripts/simulation-deployment-summary.sh index f31e62a44..b148933a6 100755 --- a/.github/scripts/modal-deployment-summary.sh +++ b/.github/scripts/simulation-deployment-summary.sh @@ -1,6 +1,6 @@ #!/bin/bash # Generate deployment summary for GitHub Actions -# Usage: ./modal-deployment-summary.sh +# Usage: ./simulation-deployment-summary.sh set -euo pipefail @@ -10,13 +10,13 @@ PROD_RESULT="${3:-skipped}" PROD_URL="${4:-}" { - echo "## Modal Deployment Summary" + echo "## Simulation API Deployment Summary" echo "" case "$BETA_RESULT" in success) echo "✅ **Beta deployment**: Success" - [ -n "$BETA_URL" ] && echo " - URL: $BETA_URL" + [ -n "$BETA_URL" ] && echo " - Candidate URL: $BETA_URL" ;; skipped) echo "⏭️ **Beta deployment**: Skipped" @@ -30,14 +30,14 @@ PROD_URL="${4:-}" case "$PROD_RESULT" in success) - echo "✅ **Production deployment**: Success" - [ -n "$PROD_URL" ] && echo " - URL: $PROD_URL" + echo "✅ **Prod deployment**: Success" + [ -n "$PROD_URL" ] && echo " - Candidate URL: $PROD_URL" ;; skipped) - echo "⏭️ **Production deployment**: Skipped" + echo "⏭️ **Prod deployment**: Skipped" ;; *) - echo "❌ **Production deployment**: $PROD_RESULT" + echo "❌ **Prod deployment**: $PROD_RESULT" ;; esac } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/modal-run-integ-tests.sh b/.github/scripts/simulation-run-integ-tests.sh similarity index 72% rename from .github/scripts/modal-run-integ-tests.sh rename to .github/scripts/simulation-run-integ-tests.sh index 16ff0dae3..c4426b469 100755 --- a/.github/scripts/modal-run-integ-tests.sh +++ b/.github/scripts/simulation-run-integ-tests.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Run simulation integration tests -# Usage: ./modal-run-integ-tests.sh [us-version] [uk-version] +# Run integration tests through the deployed Simulation Entrypoint. +# Usage: ./simulation-run-integ-tests.sh [us-version] [uk-version] # Environment: beta runs all tests, prod excludes beta_only tests set -euo pipefail @@ -17,15 +17,15 @@ truthy() { esac } -GATEWAY_AUTH_VARS=( - GATEWAY_AUTH_ISSUER - GATEWAY_AUTH_AUDIENCE - GATEWAY_AUTH_CLIENT_ID - GATEWAY_AUTH_CLIENT_SECRET +TEST_AUTH_VARS=( + SIMULATION_TEST_AUTH_ISSUER + SIMULATION_TEST_AUTH_AUDIENCE + SIMULATION_TEST_AUTH_CLIENT_ID + SIMULATION_TEST_AUTH_CLIENT_SECRET ) present=() missing=() -for var in "${GATEWAY_AUTH_VARS[@]}"; do +for var in "${TEST_AUTH_VARS[@]}"; do if [ -n "${!var:-}" ]; then present+=("$var") else @@ -34,36 +34,36 @@ for var in "${GATEWAY_AUTH_VARS[@]}"; do done if [ ${#present[@]} -gt 0 ] && [ ${#missing[@]} -gt 0 ]; then - echo "Gateway auth integration-test config is partial." >&2 + echo "Simulation integration-test auth config is partial." >&2 echo " Present: ${present[*]-}" >&2 echo " Missing: ${missing[*]-}" >&2 exit 1 fi SHOULD_MINT_TOKEN=0 -if truthy "${GATEWAY_AUTH_REQUIRED:-}"; then +if truthy "${SIMULATION_TEST_AUTH_REQUIRED:-}"; then if [ ${#missing[@]} -gt 0 ]; then - echo "GATEWAY_AUTH_REQUIRED is enabled but integration-test auth secrets are missing." >&2 + echo "SIMULATION_TEST_AUTH_REQUIRED is enabled but auth secrets are missing." >&2 echo " Missing: ${missing[*]-}" >&2 exit 1 fi SHOULD_MINT_TOKEN=1 -elif [ ${#present[@]} -eq ${#GATEWAY_AUTH_VARS[@]} ]; then +elif [ ${#present[@]} -eq ${#TEST_AUTH_VARS[@]} ]; then SHOULD_MINT_TOKEN=1 fi ACCESS_TOKEN="" if [ "$SHOULD_MINT_TOKEN" -eq 1 ]; then - ISSUER="${GATEWAY_AUTH_ISSUER%/}" + ISSUER="${SIMULATION_TEST_AUTH_ISSUER%/}" TOKEN_URL="$ISSUER/oauth/token" # Build the token-request JSON with Python so that any ", \, or newline in # the client secret is encoded correctly (Auth0-generated secrets are random # strings that routinely contain characters that break a shell heredoc). TOKEN_REQUEST_JSON=$( - CLIENT_ID="$GATEWAY_AUTH_CLIENT_ID" \ - CLIENT_SECRET="$GATEWAY_AUTH_CLIENT_SECRET" \ - AUDIENCE="$GATEWAY_AUTH_AUDIENCE" \ + CLIENT_ID="$SIMULATION_TEST_AUTH_CLIENT_ID" \ + CLIENT_SECRET="$SIMULATION_TEST_AUTH_CLIENT_SECRET" \ + AUDIENCE="$SIMULATION_TEST_AUTH_AUDIENCE" \ python3 -c ' import json, os print(json.dumps({ @@ -100,12 +100,12 @@ print(token) fi cd projects/policyengine-apis-integ -uv sync --extra test +uv sync --extra test --frozen export simulation_integ_test_base_url="$BASE_URL" -if [ -n "${GATEWAY_AUTH_REQUIRED:-}" ]; then - export simulation_integ_test_gateway_auth_required="$GATEWAY_AUTH_REQUIRED" +if [ -n "${SIMULATION_TEST_AUTH_REQUIRED:-}" ]; then + export simulation_integ_test_gateway_auth_required="$SIMULATION_TEST_AUTH_REQUIRED" fi if [ -n "$ACCESS_TOKEN" ]; then diff --git a/.github/workflows/cloud-run-simulation-entry.yml b/.github/workflows/cloud-run-simulation-entry.yml deleted file mode 100644 index 20a98f533..000000000 --- a/.github/workflows/cloud-run-simulation-entry.yml +++ /dev/null @@ -1,211 +0,0 @@ -name: Deploy Simulation Entrypoint to Cloud Run - -on: - push: - branches: [main] - paths: - - "projects/policyengine-simulation-entry/**" - - "libs/policyengine-fastapi/**" - - "libs/policyengine-simulation-contract/**" - - "libs/policyengine-simulation-observability/**" - - ".github/scripts/cloud-run-simulation-entry-smoke.sh" - - ".github/workflows/cloud-run-simulation-entry.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: cloud-run-simulation-entry - cancel-in-progress: false - -env: - 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-entry' }} - IMAGE_NAME: policyengine-simulation-entry - -jobs: - test: - 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: - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - outputs: - image: ${{ steps.image.outputs.uri }} - steps: - - uses: actions/checkout@v6 - - uses: google-github-actions/auth@v2 - with: - 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 - 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: - 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 }} - steps: - - uses: actions/checkout@v6 - - uses: google-github-actions/auth@v2 - with: - 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" - - name: Deploy tagged staging candidate - env: - IMAGE: ${{ needs.build.outputs.image }} - TAG: ${{ steps.metadata.outputs.tag }} - run: | - gcloud run deploy policyengine-simulation-entry-staging \ - --project "${PROJECT_ID}" \ - --region "${REGION}" \ - --image "${IMAGE}" \ - --tag "${TAG}" \ - --no-traffic \ - --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=${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}: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-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-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, 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 - with: - 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" - - name: Deploy no-traffic production candidate - env: - IMAGE: ${{ needs.build.outputs.image }} - TAG: ${{ steps.metadata.outputs.tag }} - run: | - gcloud run deploy policyengine-simulation-entry \ - --project "${PROJECT_ID}" \ - --region "${REGION}" \ - --image "${IMAGE}" \ - --tag "${TAG}" \ - --no-traffic \ - --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=${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}:latest" \ - --cpu 1 \ - --memory 512Mi \ - --concurrency 80 \ - --timeout 60 \ - --min-instances 1 \ - --max-instances 20 - - id: url - name: Resolve candidate URL - env: - TAG: ${{ steps.metadata.outputs.tag }} - run: | - 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}" - 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/modal-deploy.reusable.yml b/.github/workflows/modal-deploy.reusable.yml deleted file mode 100644 index 1998b4def..000000000 --- a/.github/workflows/modal-deploy.reusable.yml +++ /dev/null @@ -1,227 +0,0 @@ -name: Reusable Modal deploy workflow - -on: - workflow_call: - inputs: - environment: - required: true - type: string - description: 'The environment to deploy to (e.g., beta, prod)' - modal_environment: - required: true - type: string - description: 'The Modal environment name (e.g., staging, main)' - force_latest: - required: false - default: false - type: boolean - description: 'Force latest pointers to this deployment' - force_recompute: - required: false - default: false - type: boolean - description: 'Recompute artifacts even when the store already has them' - outputs: - simulation_api_url: - description: 'The deployed simulation API URL' - value: ${{ jobs.deploy.outputs.simulation_api_url }} - policyengine_version: - description: 'The deployed policyengine.py package version' - value: ${{ jobs.deploy.outputs.policyengine_version }} - policyengine_core_version: - description: 'The deployed policyengine-core package version' - value: ${{ jobs.deploy.outputs.policyengine_core_version }} - us_version: - description: 'The deployed policyengine-us package version' - value: ${{ jobs.deploy.outputs.us_version }} - us_data_version: - description: 'The bundled US data release version' - value: ${{ jobs.deploy.outputs.us_data_version }} - uk_version: - description: 'The deployed policyengine-uk package version' - value: ${{ jobs.deploy.outputs.uk_version }} - uk_data_version: - description: 'The bundled UK data release version' - value: ${{ jobs.deploy.outputs.uk_data_version }} - -jobs: - precompute: - name: Precompute artifacts - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - timeout-minutes: 60 - outputs: - manifest_digest: ${{ steps.run-precompute.outputs.manifest_digest }} - - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Install dependencies - working-directory: projects/policyengine-simulation-executor - run: uv sync - - # Deliberately duplicates the deploy job's sync: precompute must never - # depend on a prior deploy having created the Modal secrets it uses. - - name: Sync Modal secrets from GitHub - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} - GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} - GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} - GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} - GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} - GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }} - GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} - run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.environment }}" - - - name: Run artifact precompute - id: run-precompute - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }} - run: ../../.github/scripts/modal-precompute.sh "${{ inputs.modal_environment }}" "${{ inputs.force_recompute }}" - - deploy: - name: Deploy to Modal - needs: [precompute] - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - outputs: - simulation_api_url: ${{ steps.get-url.outputs.simulation_api_url }} - policyengine_version: ${{ steps.versions.outputs.policyengine_version }} - policyengine_core_version: ${{ steps.versions.outputs.policyengine_core_version }} - us_version: ${{ steps.versions.outputs.us_version }} - us_data_version: ${{ steps.versions.outputs.us_data_version }} - uk_version: ${{ steps.versions.outputs.uk_version }} - uk_data_version: ${{ steps.versions.outputs.uk_data_version }} - - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Install dependencies - working-directory: projects/policyengine-simulation-executor - run: uv sync - - - name: Extract package versions - id: versions - run: .github/scripts/modal-extract-versions.sh projects/policyengine-simulation-executor - - - name: Sync Modal secrets from GitHub - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} - GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} - GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} - GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} - GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} - GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }} - GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} - run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.environment }}" - - - name: Deploy simulation API to Modal - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - POLICYENGINE_VERSION: ${{ steps.versions.outputs.policyengine_version }} - POLICYENGINE_CORE_VERSION: ${{ steps.versions.outputs.policyengine_core_version }} - POLICYENGINE_US_VERSION: ${{ steps.versions.outputs.us_version }} - POLICYENGINE_UK_VERSION: ${{ steps.versions.outputs.uk_version }} - # app.py resolves the deploy manifest from GCS on the runner at - # modal.is_local() time, so this step needs the digest, the store - # bucket, and GCP credentials alongside the version env vars. - POLICYENGINE_MANIFEST_DIGEST: ${{ needs.precompute.outputs.manifest_digest }} - POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }} - GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} - run: ../../.github/scripts/modal-deploy-app.sh "${{ inputs.modal_environment }}" "${{ inputs.force_latest }}" - - - name: Get deployed URL - id: get-url - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - run: ../../.github/scripts/modal-get-url.sh "${{ inputs.modal_environment }}" - - - name: Verify deployment health - run: .github/scripts/modal-health-check.sh "${{ steps.get-url.outputs.simulation_api_url }}" - - # After the health check, so the marker means deployed AND healthy. - # The marker is the artifact GC's liveness signal: a deploy that - # cannot record itself fails the job. - - name: Record deployment marker - working-directory: projects/policyengine-simulation-executor - env: - POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }} - GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} - POLICYENGINE_VERSION: ${{ steps.versions.outputs.policyengine_version }} - POLICYENGINE_US_VERSION: ${{ steps.versions.outputs.us_version }} - POLICYENGINE_UK_VERSION: ${{ steps.versions.outputs.uk_version }} - US_DATA_VERSION: ${{ steps.versions.outputs.us_data_version }} - UK_DATA_VERSION: ${{ steps.versions.outputs.uk_data_version }} - run: ../../.github/scripts/modal-record-deployment.sh "${{ inputs.environment }}" "${{ needs.precompute.outputs.manifest_digest }}" - - integ_test: - name: Run integration tests - needs: [deploy] - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Generate API clients - run: ./scripts/generate-clients.sh - - - name: Run simulation integration tests - env: - GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} - GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} - GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} - GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }} - GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} - run: .github/scripts/modal-run-integ-tests.sh "${{ inputs.environment }}" "${{ needs.deploy.outputs.simulation_api_url }}" "${{ needs.deploy.outputs.us_version }}" "${{ needs.deploy.outputs.uk_version }}" diff --git a/.github/workflows/modal-deploy.yml b/.github/workflows/modal-deploy.yml deleted file mode 100644 index d8c1fae96..000000000 --- a/.github/workflows/modal-deploy.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Deploy Simulation API to Modal - -on: - push: - branches: - - main - workflow_dispatch: - inputs: - skip_beta: - description: 'Skip beta deployment and deploy directly to prod' - required: false - default: false - type: boolean - force_latest: - description: 'Force latest pointers to this deployment, for intentional rollbacks' - required: false - default: false - type: boolean - force_recompute: - description: 'Recompute artifacts even when the store already has them (write-once: existing objects are never replaced)' - required: false - default: false - type: boolean - -concurrency: - group: modal-deploy-main - -jobs: - setup_environments: - name: Setup Modal environments - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Install dependencies - working-directory: projects/policyengine-simulation-executor - run: uv sync - - - name: Ensure Modal environments exist - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - run: ../../.github/scripts/modal-setup-environments.sh - - deploy_beta: - name: Deploy to beta - needs: [setup_environments] - if: ${{ !inputs.skip_beta }} - uses: ./.github/workflows/modal-deploy.reusable.yml - with: - environment: beta - modal_environment: staging - force_latest: ${{ inputs.force_latest || false }} - force_recompute: ${{ inputs.force_recompute || false }} - secrets: inherit - - deploy_prod: - name: Deploy to production - needs: [deploy_beta] - if: ${{ always() && (needs.deploy_beta.result == 'success' || inputs.skip_beta) }} - uses: ./.github/workflows/modal-deploy.reusable.yml - with: - environment: prod - modal_environment: main - force_latest: ${{ inputs.force_latest || false }} - force_recompute: ${{ inputs.force_recompute || false }} - secrets: inherit - - summary: - name: Deployment summary - needs: [deploy_beta, deploy_prod] - if: always() - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Report status - run: | - .github/scripts/modal-deployment-summary.sh \ - "${{ needs.deploy_beta.result }}" \ - "${{ needs.deploy_beta.outputs.simulation_api_url }}" \ - "${{ needs.deploy_prod.result }}" \ - "${{ needs.deploy_prod.outputs.simulation_api_url }}" diff --git a/.github/workflows/publish-clients.yml b/.github/workflows/publish-clients.yml index d0b987387..3cc3e8faa 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 Entrypoint to Cloud Run"] + workflows: ["Deploy Simulation API"] types: - completed branches: diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml new file mode 100644 index 000000000..7a51b9b3b --- /dev/null +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -0,0 +1,413 @@ +name: Reusable simulation deployment + +on: + workflow_call: + inputs: + release_environment: + required: true + type: string + description: "GitHub environment for Modal deployment secrets" + modal_environment: + required: true + type: string + description: "Modal environment name" + entrypoint_service: + required: true + type: string + description: "Cloud Run service name" + entrypoint_public_url: + required: true + type: string + description: "Public URL represented by this entrypoint" + entrypoint_revision_prefix: + required: true + type: string + description: "Prefix for the no-traffic Cloud Run revision tag" + entrypoint_min_instances: + required: true + type: number + entrypoint_max_instances: + required: true + type: number + force_latest: + required: false + default: false + type: boolean + description: "Force latest routing pointers for an intentional rollback" + force_recompute: + required: false + default: false + type: boolean + description: "Recompute artifacts even when the store already has them" + outputs: + simulation_api_url: + description: "Tagged Cloud Run candidate URL qualified by this deployment" + value: ${{ jobs.deploy_entrypoint.outputs.url }} + policyengine_version: + description: "Deployed policyengine.py package version" + value: ${{ jobs.prepare.outputs.policyengine_version }} + policyengine_core_version: + description: "Deployed policyengine-core package version" + value: ${{ jobs.prepare.outputs.policyengine_core_version }} + us_version: + description: "Deployed policyengine-us package version" + value: ${{ jobs.prepare.outputs.us_version }} + us_data_version: + description: "Bundled US data release version" + value: ${{ jobs.prepare.outputs.us_data_version }} + uk_version: + description: "Deployed policyengine-uk package version" + value: ${{ jobs.prepare.outputs.uk_version }} + uk_data_version: + description: "Bundled UK data release version" + value: ${{ jobs.prepare.outputs.uk_data_version }} + +permissions: + contents: read + +jobs: + prepare: + name: Prepare deployment inputs + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + outputs: + policyengine_version: ${{ steps.versions.outputs.policyengine_version }} + policyengine_core_version: ${{ steps.versions.outputs.policyengine_core_version }} + us_version: ${{ steps.versions.outputs.us_version }} + us_data_version: ${{ steps.versions.outputs.us_data_version }} + uk_version: ${{ steps.versions.outputs.uk_version }} + uk_data_version: ${{ steps.versions.outputs.uk_data_version }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install deployment dependencies + working-directory: projects/policyengine-simulation-executor + run: uv sync --frozen + + - name: Extract package versions + id: versions + run: .github/scripts/modal-extract-versions.sh projects/policyengine-simulation-executor + + - name: Synchronize Modal runtime secrets + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} + GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} + GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} + GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} + GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }} + GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} + run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.release_environment }}" + + deploy_entrypoint: + name: Deploy and unit test Cloud Run entrypoint + needs: prepare + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + permissions: + contents: read + id-token: write + env: + 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-entry' }} + IMAGE_NAME: policyengine-simulation-entry + outputs: + image: ${{ steps.image.outputs.uri }} + revision_tag: ${{ steps.metadata.outputs.tag }} + url: ${{ steps.url.outputs.url }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - uses: google-github-actions/auth@v2 + with: + 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 + + - name: Configure Artifact Registry authentication + run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet + + - name: Select immutable image + id: image + run: echo "uri=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Build and push entrypoint image + run: | + docker build \ + --file "${PROJECT_DIR}/Dockerfile" \ + --tag "${{ steps.image.outputs.uri }}" \ + . + docker push "${{ steps.image.outputs.uri }}" + + - name: Select no-traffic revision tag + id: metadata + run: echo "tag=${{ inputs.entrypoint_revision_prefix }}${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Deploy Cloud Run entrypoint candidate + env: + IMAGE: ${{ steps.image.outputs.uri }} + TAG: ${{ steps.metadata.outputs.tag }} + run: | + gcloud run deploy "${{ inputs.entrypoint_service }}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --image "${IMAGE}" \ + --tag "${TAG}" \ + --no-traffic \ + --allow-unauthenticated \ + --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ + --set-env-vars "^@^APP_ENVIRONMENT=${{ inputs.release_environment }}@SIMULATION_ENTRYPOINT_PUBLIC_URL=${{ inputs.entrypoint_public_url }}@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=${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}:latest" \ + --cpu 1 \ + --memory 512Mi \ + --concurrency 80 \ + --timeout 60 \ + --min-instances "${{ inputs.entrypoint_min_instances }}" \ + --max-instances "${{ inputs.entrypoint_max_instances }}" + + - name: Resolve tagged candidate URL + id: url + env: + TAG: ${{ steps.metadata.outputs.tag }} + run: | + url="$(gcloud run services describe "${{ inputs.entrypoint_service }}" \ + --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" + + - name: Install entrypoint unit-test dependencies + working-directory: ${{ env.PROJECT_DIR }} + run: uv sync --extra test --frozen + + - name: Run entrypoint unit tests + working-directory: ${{ env.PROJECT_DIR }} + run: uv run pytest tests/ -v + + deploy_gateway: + name: Deploy and unit test Modal gateway + needs: prepare + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + outputs: + url: ${{ steps.url.outputs.simulation_api_url }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install gateway dependencies + working-directory: projects/policyengine-simulation-gateway + run: uv sync --extra test --frozen + + - name: Deploy stable Modal gateway + working-directory: projects/policyengine-simulation-gateway + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + run: uv run modal deploy --env="${{ inputs.modal_environment }}" src/policyengine_simulation_gateway/app.py + + - name: Run gateway unit tests + working-directory: projects/policyengine-simulation-gateway + run: uv run pytest tests/ -v + + - name: Resolve gateway URL + id: url + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + run: ../../.github/scripts/modal-get-url.sh "${{ inputs.modal_environment }}" + + - name: Verify gateway health + run: .github/scripts/modal-health-check.sh "${{ steps.url.outputs.simulation_api_url }}" + + deploy_executor: + name: Deploy and unit test Modal executor + needs: prepare + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + outputs: + app_name: ${{ steps.app.outputs.name }} + manifest_digest: ${{ steps.precompute.outputs.manifest_digest }} + env: + POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + POLICYENGINE_CORE_VERSION: ${{ needs.prepare.outputs.policyengine_core_version }} + POLICYENGINE_US_VERSION: ${{ needs.prepare.outputs.us_version }} + POLICYENGINE_UK_VERSION: ${{ needs.prepare.outputs.uk_version }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install executor dependencies + working-directory: projects/policyengine-simulation-executor + run: uv sync --extra test --frozen + + - name: Run artifact precompute + id: precompute + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }} + run: ../../.github/scripts/modal-precompute.sh "${{ inputs.modal_environment }}" "${{ inputs.force_recompute }}" + + - name: Select versioned executor app + id: app + run: | + version_safe="${POLICYENGINE_VERSION//./-}" + echo "name=policyengine-simulation-py${version_safe}" >> "$GITHUB_OUTPUT" + + - name: Deploy versioned Modal executor + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + MODAL_APP_NAME: ${{ steps.app.outputs.name }} + POLICYENGINE_MANIFEST_DIGEST: ${{ steps.precompute.outputs.manifest_digest }} + POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }} + GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} + run: uv run modal deploy --env="${{ inputs.modal_environment }}" src/modal/app.py + + - name: Run executor unit tests + working-directory: projects/policyengine-simulation-executor + run: uv run pytest tests/ -v + + update_routing: + name: Publish routing after all services pass + needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install routing publisher dependencies + working-directory: projects/policyengine-simulation-executor + run: uv sync --frozen + + - name: Publish active routing state + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + APP_NAME: ${{ needs.deploy_executor.outputs.app_name }} + POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + POLICYENGINE_US_VERSION: ${{ needs.prepare.outputs.us_version }} + POLICYENGINE_UK_VERSION: ${{ needs.prepare.outputs.uk_version }} + FORCE_LATEST: ${{ inputs.force_latest }} + run: | + args=( + --app-name "${APP_NAME}" + --policyengine-version "${POLICYENGINE_VERSION}" + --us-version "${POLICYENGINE_US_VERSION}" + --uk-version "${POLICYENGINE_UK_VERSION}" + --environment "${{ inputs.modal_environment }}" + ) + if [[ "${FORCE_LATEST}" == "true" ]]; then + args+=(--force-latest) + fi + uv run python -m src.modal.utils.update_version_registry "${args[@]}" + + integration: + name: Run three-service integration tests + needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor, update_routing] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Verify the complete deployed request path + run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ needs.deploy_entrypoint.outputs.url }}" + + - name: Generate API clients + run: ./scripts/generate-clients.sh + + - name: Run integration tests through entrypoint, gateway, and executor + env: + SIMULATION_TEST_AUTH_REQUIRED: "1" + SIMULATION_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + run: .github/scripts/simulation-run-integ-tests.sh "${{ inputs.release_environment }}" "${{ needs.deploy_entrypoint.outputs.url }}" "${{ needs.prepare.outputs.us_version }}" "${{ needs.prepare.outputs.uk_version }}" + + - name: Record deployment marker + working-directory: projects/policyengine-simulation-executor + env: + POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }} + GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} + POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + POLICYENGINE_US_VERSION: ${{ needs.prepare.outputs.us_version }} + POLICYENGINE_UK_VERSION: ${{ needs.prepare.outputs.uk_version }} + US_DATA_VERSION: ${{ needs.prepare.outputs.us_data_version }} + UK_DATA_VERSION: ${{ needs.prepare.outputs.uk_data_version }} + run: ../../.github/scripts/modal-record-deployment.sh "${{ inputs.release_environment }}" "${{ needs.deploy_executor.outputs.manifest_digest }}" + + authenticated_test: + name: Run authenticated deployment tests + needs: [deploy_entrypoint, integration] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ needs.deploy_entrypoint.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 + + - name: Install authenticated-test dependencies + working-directory: projects/policyengine-simulation-entry + run: uv sync --extra test --frozen + + - name: Run authenticated deployment tests + working-directory: projects/policyengine-simulation-entry + run: uv run pytest authenticated_tests/ -v diff --git a/.github/workflows/simulation-deploy.yml b/.github/workflows/simulation-deploy.yml new file mode 100644 index 000000000..5646ee1d6 --- /dev/null +++ b/.github/workflows/simulation-deploy.yml @@ -0,0 +1,79 @@ +name: Deploy Simulation API + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + force_latest: + description: "Force latest routing pointers for an intentional rollback" + required: false + default: false + type: boolean + force_recompute: + description: "Recompute artifacts even when the store already has them" + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: simulation-deploy-main + cancel-in-progress: false + +jobs: + beta: + name: Complete beta deployment + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: beta + modal_environment: staging + entrypoint_service: policyengine-simulation-entry-staging + entrypoint_public_url: https://staging.simulation.api.policyengine.org + entrypoint_revision_prefix: s + entrypoint_min_instances: 0 + entrypoint_max_instances: 2 + force_latest: ${{ inputs.force_latest || false }} + force_recompute: ${{ inputs.force_recompute || false }} + secrets: inherit + + prod: + name: Complete prod deployment + needs: beta + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: prod + modal_environment: main + entrypoint_service: policyengine-simulation-entry + entrypoint_public_url: https://simulation.api.policyengine.org + entrypoint_revision_prefix: p + entrypoint_min_instances: 1 + entrypoint_max_instances: 20 + force_latest: ${{ inputs.force_latest || false }} + force_recompute: ${{ inputs.force_recompute || false }} + secrets: inherit + + summary: + name: Deployment summary + needs: [beta, prod] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Report promotion status + run: | + .github/scripts/simulation-deployment-summary.sh \ + "${{ needs.beta.result }}" \ + "${{ needs.beta.outputs.simulation_api_url }}" \ + "${{ needs.prod.result }}" \ + "${{ needs.prod.outputs.simulation_api_url }}" diff --git a/README.md b/README.md index 323a98972..66b6d1cef 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,21 @@ make generate-clients # regenerate the OpenAPI Python client ## Deployment 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. +permanent Simulation Entrypoint deploys to Cloud Run as tagged no-traffic +candidates. On merge to `main`, `.github/workflows/simulation-deploy.yml` +promotes one complete stack at a time: + +1. prepare the environment's deployment inputs and runtime secrets; +2. deploy and unit test the Entrypoint, Modal gateway, and Modal executor in + three parallel service jobs; +3. publish routing only after all three service jobs pass; +4. run generated-client and live authentication tests through the complete + three-service request path; and +5. begin the `prod` deployment only after every `beta` deployment and + qualification job succeeds. + +A successful push-triggered full-stack deployment publishes the API client from +the exact deployed commit. Cloud Run traffic promotion and rollback remain manual operator actions. diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 39999e922..77ecfde16 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -1,9 +1,9 @@ -# Cloud Run deployment +# Simulation deployment -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. +The Simulation Entry service is deployed to Cloud Run as one layer of the +simulation API deployment. This document describes the deployment model only. +Concrete cloud projects, identities, IAM bindings, domains, and secret +resources are managed through private operator documentation. ## Infrastructure @@ -13,7 +13,7 @@ is intentionally not provisioned by a script in this repository. The deployment environment must provide: - an image registry and Cloud Run services; -- separate non-production and production runtime identities; +- separate `beta` and `prod` runtime identities; - GitHub workload identity federation with narrowly scoped trust; - protected environment configuration and secret storage; and - domain and traffic-management configuration. @@ -24,14 +24,15 @@ receives only the configuration needed to reference those protected resources. ## Candidate deployment -For each eligible revision, the workflow: +For each eligible revision, the workflow first deploys `beta` +candidates for the Cloud Run entrypoint, Modal gateway, and versioned Modal +executor. Each deployment job runs its own service unit tests. Only after all +three jobs pass does the workflow publish routing and run integration and +authentication checks through the complete request path. -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. +The `prod` environment follows the same sequence automatically, but cannot +begin until every `beta` deployment, routing, integration, and authentication +job has succeeded. The workflow does not assign production traffic. diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index 8363ac01d..001be1edd 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -19,7 +19,16 @@ / "scripts" / "set-cloud-run-simulation-entry-revision.sh" ) -WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "cloud-run-simulation-entry.yml" +SMOKE_SCRIPT = ( + REPOSITORY_ROOT + / ".github" + / "scripts" + / "cloud-run-simulation-entry-smoke.sh" +) +DEPLOY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.yml" +REUSABLE_DEPLOY_WORKFLOW = ( + REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" +) PUBLISH_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "publish-clients.yml" AUTHENTICATED_TESTS = ( REPOSITORY_ROOT @@ -38,22 +47,46 @@ def test_operator_scripts_have_valid_shell_syntax(): subprocess.run(["bash", "-n", DOMAIN_SCRIPT], check=True) subprocess.run(["bash", "-n", TRAFFIC_SCRIPT], check=True) + subprocess.run(["bash", "-n", SMOKE_SCRIPT], check=True) + assert os.access(SMOKE_SCRIPT, os.X_OK) 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-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 workflow.count("vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME") == 2 - assert "simulation-entry-old-gateway-client-secret" not in workflow + deploy_workflow = DEPLOY_WORKFLOW.read_text(encoding="utf-8") + reusable_workflow = REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8") + + assert "gcloud run deploy" in reusable_workflow + assert "update-traffic" not in reusable_workflow + assert "set-cloud-run-simulation-entry-revision.sh" not in reusable_workflow + assert "terraform" not in reusable_workflow.lower() + assert "deploy_entrypoint:" in reusable_workflow + assert "deploy_gateway:" in reusable_workflow + assert "deploy_executor:" in reusable_workflow + assert "update_routing:" in reusable_workflow + assert "integration:" in reusable_workflow + assert "authenticated_test:" in reusable_workflow + assert ( + "needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor]" + in reusable_workflow + ) + assert "needs: beta" in deploy_workflow + assert deploy_workflow.index("beta:") < deploy_workflow.index("prod:") + assert "release_environment: beta" in deploy_workflow + assert "release_environment: prod" in deploy_workflow + assert "entrypoint_environment" not in deploy_workflow + assert "entrypoint_environment" not in reusable_workflow + assert ( + reusable_workflow.count( + "environment: ${{ inputs.release_environment }}" + ) + == 7 + ) + assert "APP_ENVIRONMENT=${{ inputs.release_environment }}" in reusable_workflow + assert "id-token: write" in reusable_workflow + assert ( + reusable_workflow.count("vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME") == 1 + ) + assert "simulation-entry-old-gateway-client-secret" not in reusable_workflow assert AUTHENTICATED_TESTS.joinpath("test_deployed_auth.py").is_file() assert not list( (REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" / "infra").glob( @@ -62,6 +95,33 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): ) +def test_full_stack_promotion_order_is_explicit(): + deploy_workflow = DEPLOY_WORKFLOW.read_text(encoding="utf-8") + reusable_workflow = REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8") + + for deployment_step, unit_test_step in ( + ("Deploy Cloud Run entrypoint candidate", "Run entrypoint unit tests"), + ("Deploy stable Modal gateway", "Run gateway unit tests"), + ("Deploy versioned Modal executor", "Run executor unit tests"), + ): + assert reusable_workflow.index(deployment_step) < reusable_workflow.index( + unit_test_step + ) + + routing_dependencies = ( + "needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor]" + ) + assert routing_dependencies in reusable_workflow + assert reusable_workflow.index("update_routing:") < reusable_workflow.index( + "integration:" + ) + assert reusable_workflow.index("integration:") < reusable_workflow.index( + "authenticated_test:" + ) + assert "needs: beta" in deploy_workflow + assert "skip_beta" not in deploy_workflow + + def test_traffic_changes_are_operator_run_and_revision_validated(): script = TRAFFIC_SCRIPT.read_text(encoding="utf-8") diff --git a/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py b/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py index 6a6bb311f..9e54267f9 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py @@ -28,5 +28,8 @@ def temp_github_step_summary(): @pytest.fixture def all_modal_scripts(): - """Return all ``modal-*.sh`` scripts in the repository.""" - return list(SCRIPTS_DIR.glob("modal-*.sh")) + """Return Modal and full-stack simulation deployment scripts.""" + return [ + *SCRIPTS_DIR.glob("modal-*.sh"), + *SCRIPTS_DIR.glob("simulation-*.sh"), + ] diff --git a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py index ee99fc338..32ab714ba 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py @@ -64,30 +64,30 @@ def test_extracts_versions_from_policyengine_bundle(self, temp_github_output): def test_deploy_workflow_passes_core_version_to_modal(self): """Deploy workflow should pass core version into the Modal app build.""" workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") assert "policyengine_core_version" in workflow assert ( - "POLICYENGINE_CORE_VERSION: ${{ steps.versions.outputs.policyengine_core_version }}" + "POLICYENGINE_CORE_VERSION: ${{ needs.prepare.outputs.policyengine_core_version }}" in workflow ) - def test_deploy_workflow_threads_force_latest_to_script(self): - """Manual rollback flag should reach the deploy script.""" + def test_deploy_workflow_threads_force_latest_to_routing_publisher(self): + """Manual rollback flag should reach the post-deployment routing job.""" deploy_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.yml" ).read_text(encoding="utf-8") reusable_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") assert "force_latest:" in deploy_workflow assert "force_latest: ${{ inputs.force_latest || false }}" in deploy_workflow assert "force_latest:" in reusable_workflow - assert ( - 'modal-deploy-app.sh "${{ inputs.modal_environment }}" "${{ inputs.force_latest }}"' - in reusable_workflow + assert "args+=(--force-latest)" in reusable_workflow + assert reusable_workflow.index("deploy_executor:") < reusable_workflow.index( + "update_routing:" ) @@ -128,10 +128,10 @@ def test_fails_on_unreachable_url(self): assert result.returncode != 0, "Should fail on unreachable URL" -class TestModalDeploymentSummary: - """Tests for modal-deployment-summary.sh""" +class TestSimulationDeploymentSummary: + """Tests for simulation-deployment-summary.sh""" - script = SCRIPTS_DIR / "modal-deployment-summary.sh" + script = SCRIPTS_DIR / "simulation-deployment-summary.sh" def test_script_exists(self): """Script file should exist.""" @@ -170,9 +170,9 @@ def test_generates_success_summary(self, temp_github_step_summary): with open(temp_github_step_summary) as f: summary = f.read() - assert "Modal Deployment Summary" in summary + assert "Simulation API Deployment Summary" in summary assert "Beta deployment" in summary - assert "Production deployment" in summary + assert "Prod deployment" in summary assert "https://beta.example.com" in summary assert "https://prod.example.com" in summary @@ -341,91 +341,6 @@ def test_creates_gateway_secret_with_normalized_issuer(self, tmp_path): assert "GATEWAY_AUTH_CLIENT_SECRET" not in calls -class TestModalDeployApp: - """Tests for modal-deploy-app.sh""" - - script = SCRIPTS_DIR / "modal-deploy-app.sh" - - def _run_with_fake_uv(self, tmp_path, *args): - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - log_path = tmp_path / "uv-calls.log" - uv_path = bin_dir / "uv" - uv_path.write_text( - '#!/bin/bash\nprintf \'%s\\n\' "$*" >> "$UV_FAKE_LOG"\n', - encoding="utf-8", - ) - uv_path.chmod(0o755) - - env = os.environ.copy() - env.update( - { - "PATH": f"{bin_dir}{os.pathsep}{env['PATH']}", - "UV_FAKE_LOG": str(log_path), - "POLICYENGINE_VERSION": "4.18.3", - "POLICYENGINE_CORE_VERSION": "3.27.1", - "POLICYENGINE_US_VERSION": "1.729.0", - "POLICYENGINE_UK_VERSION": "2.89.2", - } - ) - - result = subprocess.run( - ["bash", str(self.script), *args], - capture_output=True, - text=True, - env=env, - ) - calls = log_path.read_text(encoding="utf-8").splitlines() - return result, calls - - def test_script_exists(self): - """Script file should exist.""" - assert self.script.exists(), f"Script not found at {self.script}" - - def test_script_syntax(self): - """Script should have valid bash syntax.""" - result = subprocess.run( - ["bash", "-n", str(self.script)], - capture_output=True, - text=True, - ) - assert result.returncode == 0, f"Syntax error: {result.stderr}" - - def test_requires_modal_environment_argument(self): - """Should fail when no modal environment is provided.""" - result = subprocess.run( - ["bash", str(self.script)], - capture_output=True, - text=True, - ) - assert result.returncode != 0, "Should fail without modal environment" - - def test_defaults_to_not_forcing_latest(self, tmp_path): - result, calls = self._run_with_fake_uv(tmp_path, "main") - - assert result.returncode == 0, result.stderr - assert result.stdout.count("Force latest: false") == 1 - registry_call = next( - call for call in calls if "update_version_registry" in call - ) - assert "--force-latest" not in registry_call - # The gateway deploys from its own project (uv_sync image). - gateway_call = next( - call for call in calls if "policyengine_simulation_gateway/app.py" in call - ) - assert "modal deploy" in gateway_call - - def test_passes_force_latest_when_requested(self, tmp_path): - result, calls = self._run_with_fake_uv(tmp_path, "main", "true") - - assert result.returncode == 0, result.stderr - assert result.stdout.count("Force latest: true") == 1 - registry_call = next( - call for call in calls if "update_version_registry" in call - ) - assert registry_call.endswith("--force-latest") - - class TestModalPrecompute: """Tests for modal-precompute.sh""" @@ -576,32 +491,36 @@ def test_fails_when_no_digest_line_is_emitted(self, tmp_path): assert "manifest_digest=" not in github_out def test_deploy_workflow_runs_precompute_before_deploy(self): - """The reusable workflow must gate deploy on the precompute job.""" + """The executor job must precompute before deploying its Modal app.""" reusable_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") - assert "precompute:" in reusable_workflow - assert "needs: [precompute]" in reusable_workflow - assert 'modal-precompute.sh "${{ inputs.modal_environment }}"' in ( - reusable_workflow + executor_job = reusable_workflow[ + reusable_workflow.index("deploy_executor:") : reusable_workflow.index( + "update_routing:" + ) + ] + assert executor_job.index("Run artifact precompute") < executor_job.index( + "Deploy versioned Modal executor" ) + assert 'modal-precompute.sh "${{ inputs.modal_environment }}"' in executor_job assert ( "POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }}" - in reusable_workflow + in executor_job ) assert ( - "manifest_digest: ${{ steps.run-precompute.outputs.manifest_digest }}" - in reusable_workflow + "manifest_digest: ${{ steps.precompute.outputs.manifest_digest }}" + in executor_job ) def test_deploy_workflow_threads_force_recompute_to_script(self): """The manual recompute flag should reach the precompute script.""" deploy_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.yml" ).read_text(encoding="utf-8") reusable_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") assert "force_recompute:" in deploy_workflow @@ -618,17 +537,17 @@ def test_deploy_workflow_threads_manifest_and_store_credentials_to_deploy(self): """The deploy step resolves the manifest from GCS on the runner, so it needs the digest, the store bucket, and GCP credentials.""" reusable_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") deploy_step = reusable_workflow[ reusable_workflow.index( - "Deploy simulation API to Modal" - ) : reusable_workflow.index("Get deployed URL") + "Deploy versioned Modal executor" + ) : reusable_workflow.index("Run executor unit tests") ] assert ( "POLICYENGINE_MANIFEST_DIGEST: " - "${{ needs.precompute.outputs.manifest_digest }}" in deploy_step + "${{ steps.precompute.outputs.manifest_digest }}" in deploy_step ) assert ( "POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }}" @@ -646,19 +565,23 @@ def test_deploy_workflow_threads_manifest_and_store_credentials_to_deploy(self): == 3 ) - def test_precompute_job_syncs_its_own_secrets(self): - """Precompute must not depend on a prior deploy's secret sync.""" + def test_precompute_is_gated_by_the_shared_secret_sync(self): + """All three deploy jobs wait for the shared preparation job.""" reusable_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") sync_invocation = ( 'modal-sync-secrets.sh "${{ inputs.modal_environment }}" ' - '"${{ inputs.environment }}"' - ) - assert reusable_workflow.count(sync_invocation) == 2, ( - "Expected the precompute AND deploy jobs to each sync Modal secrets" + '"${{ inputs.release_environment }}"' ) + assert reusable_workflow.count(sync_invocation) == 1 + executor_job = reusable_workflow[ + reusable_workflow.index("deploy_executor:") : reusable_workflow.index( + "update_routing:" + ) + ] + assert "needs: prepare" in executor_job class TestModalRecordDeployment: @@ -742,20 +665,22 @@ def test_invokes_the_recorder_module(self, tmp_path): "--environment beta --manifest-digest digest-abc" ] - def test_deploy_workflow_records_marker_after_health_check(self): - """Both legs write deployed/.json once the deploy is healthy.""" + def test_deploy_workflow_records_marker_after_integration(self): + """Both legs write deployed/.json after the complete stack is healthy.""" reusable_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") invocation = ( - 'modal-record-deployment.sh "${{ inputs.environment }}" ' - '"${{ needs.precompute.outputs.manifest_digest }}"' + 'modal-record-deployment.sh "${{ inputs.release_environment }}" ' + '"${{ needs.deploy_executor.outputs.manifest_digest }}"' ) assert invocation in reusable_workflow - # The marker means deployed AND healthy: it must come after the - # health check step. - assert reusable_workflow.index("Verify deployment health") < ( + # The marker is the artifact GC liveness signal, so it must not be + # written until the routed three-service stack passes integration. + assert reusable_workflow.index( + "Run integration tests through entrypoint, gateway, and executor" + ) < ( reusable_workflow.index("modal-record-deployment.sh") ) @@ -765,19 +690,18 @@ def test_deploy_workflow_records_marker_after_health_check(self): marker_step = reusable_workflow[ reusable_workflow.index("Record deployment marker") : ] - marker_step = marker_step[: marker_step.index("integ_test:")] + marker_step = marker_step[: marker_step.index("authenticated_test:")] for env_line in ( "POLICYENGINE_ARTIFACT_BUCKET: ${{ vars.POLICYENGINE_ARTIFACT_BUCKET }}", "GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }}", - "POLICYENGINE_VERSION: ${{ steps.versions.outputs.policyengine_version }}", - "POLICYENGINE_US_VERSION: ${{ steps.versions.outputs.us_version }}", - "POLICYENGINE_UK_VERSION: ${{ steps.versions.outputs.uk_version }}", - "US_DATA_VERSION: ${{ steps.versions.outputs.us_data_version }}", - "UK_DATA_VERSION: ${{ steps.versions.outputs.uk_data_version }}", + "POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }}", + "POLICYENGINE_US_VERSION: ${{ needs.prepare.outputs.us_version }}", + "POLICYENGINE_UK_VERSION: ${{ needs.prepare.outputs.uk_version }}", + "US_DATA_VERSION: ${{ needs.prepare.outputs.us_data_version }}", + "UK_DATA_VERSION: ${{ needs.prepare.outputs.uk_data_version }}", ): assert env_line in marker_step, f"marker step is missing: {env_line}" - class TestModalGetUrl: """Tests for modal-get-url.sh""" @@ -825,10 +749,10 @@ def test_script_syntax(self): assert result.returncode == 0, f"Syntax error: {result.stderr}" -class TestModalRunIntegTests: - """Tests for modal-run-integ-tests.sh""" +class TestSimulationRunIntegTests: + """Tests for simulation-run-integ-tests.sh""" - script = SCRIPTS_DIR / "modal-run-integ-tests.sh" + script = SCRIPTS_DIR / "simulation-run-integ-tests.sh" def test_script_exists(self): """Script file should exist.""" @@ -861,13 +785,13 @@ def test_requires_base_url_argument(self): ) assert result.returncode != 0, "Should fail without base URL" - def test_fails_when_gateway_auth_config_is_partial(self): + def test_fails_when_auth_config_is_partial(self): """Should fail before running tests if token-mint config is partial.""" env = os.environ.copy() - env["GATEWAY_AUTH_ISSUER"] = "https://tenant.auth0.com/" - env.pop("GATEWAY_AUTH_AUDIENCE", None) - env.pop("GATEWAY_AUTH_CLIENT_ID", None) - env.pop("GATEWAY_AUTH_CLIENT_SECRET", None) + env["SIMULATION_TEST_AUTH_ISSUER"] = "https://tenant.auth0.com/" + env.pop("SIMULATION_TEST_AUTH_AUDIENCE", None) + env.pop("SIMULATION_TEST_AUTH_CLIENT_ID", None) + env.pop("SIMULATION_TEST_AUTH_CLIENT_SECRET", None) result = subprocess.run( ["bash", str(self.script), "beta", "https://example.com"], @@ -877,17 +801,17 @@ def test_fails_when_gateway_auth_config_is_partial(self): ) assert result.returncode != 0 - assert "Gateway auth integration-test config is partial." in result.stderr + assert "Simulation integration-test auth config is partial." in result.stderr - def test_fails_when_auth_required_but_gateway_auth_vars_missing(self): + def test_fails_when_auth_required_but_auth_vars_missing(self): """Required auth must not run tests unauthenticated.""" env = os.environ.copy() - env["GATEWAY_AUTH_REQUIRED"] = "1" + env["SIMULATION_TEST_AUTH_REQUIRED"] = "1" for key in ( - "GATEWAY_AUTH_ISSUER", - "GATEWAY_AUTH_AUDIENCE", - "GATEWAY_AUTH_CLIENT_ID", - "GATEWAY_AUTH_CLIENT_SECRET", + "SIMULATION_TEST_AUTH_ISSUER", + "SIMULATION_TEST_AUTH_AUDIENCE", + "SIMULATION_TEST_AUTH_CLIENT_ID", + "SIMULATION_TEST_AUTH_CLIENT_SECRET", ): env.pop(key, None) @@ -899,7 +823,7 @@ def test_fails_when_auth_required_but_gateway_auth_vars_missing(self): ) assert result.returncode != 0 - assert "GATEWAY_AUTH_REQUIRED is enabled" in result.stderr + assert "SIMULATION_TEST_AUTH_REQUIRED is enabled" in result.stderr def test_exports_us_and_uk_model_versions_to_integration_tests(self, tmp_path): """Deploy-extracted model versions should reach the pytest settings.""" @@ -920,11 +844,11 @@ def test_exports_us_and_uk_model_versions_to_integration_tests(self, tmp_path): env["PATH"] = f"{fake_bin}:{env['PATH']}" env["UV_CALLS_LOG"] = str(uv_calls_log) for key in ( - "GATEWAY_AUTH_REQUIRED", - "GATEWAY_AUTH_ISSUER", - "GATEWAY_AUTH_AUDIENCE", - "GATEWAY_AUTH_CLIENT_ID", - "GATEWAY_AUTH_CLIENT_SECRET", + "SIMULATION_TEST_AUTH_REQUIRED", + "SIMULATION_TEST_AUTH_ISSUER", + "SIMULATION_TEST_AUTH_AUDIENCE", + "SIMULATION_TEST_AUTH_CLIENT_ID", + "SIMULATION_TEST_AUTH_CLIENT_SECRET", ): env.pop(key, None) From f03c79edf616c51487a4aa94a180a13264612637 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 27 Jul 2026 18:46:06 +0300 Subject: [PATCH 13/15] Automate tested entrypoint promotion --- .../cloud-run-simulation-entry-smoke.sh | 30 ++- ...gure-cloud-run-simulation-entry-domains.sh | 49 ----- ...set-cloud-run-simulation-entry-revision.sh | 84 +++++--- .../scripts/simulation-deployment-summary.sh | 4 +- .../workflows/simulation-deploy.reusable.yml | 181 +++++++++++++++-- .github/workflows/simulation-deploy.yml | 6 +- README.md | 18 +- .../DEPLOYMENT.md | 50 +++-- .../policyengine-simulation-entry/README.md | 9 +- .../src/policyengine_simulation_entry/app.py | 4 + .../policyengine_simulation_entry/config.py | 16 +- .../tests/conftest.py | 2 +- .../tests/test_app.py | 4 + .../tests/test_config.py | 17 -- .../tests/test_deployment_assets.py | 185 +++++++++++++----- .../tests/test_modal_scripts.py | 7 +- 16 files changed, 466 insertions(+), 200 deletions(-) delete mode 100644 .github/scripts/configure-cloud-run-simulation-entry-domains.sh diff --git a/.github/scripts/cloud-run-simulation-entry-smoke.sh b/.github/scripts/cloud-run-simulation-entry-smoke.sh index d79bc3663..d25d937bb 100755 --- a/.github/scripts/cloud-run-simulation-entry-smoke.sh +++ b/.github/scripts/cloud-run-simulation-entry-smoke.sh @@ -4,9 +4,35 @@ set -euo pipefail base_url="${1:?Simulation Entrypoint base URL is required}" base_url="${base_url%/}" +expected_revision="${2:-}" + +health_headers="$(mktemp)" +health_body="$(mktemp)" +trap 'rm -f "${health_headers}" "${health_body}"' EXIT + +curl --fail --silent --show-error \ + --dump-header "${health_headers}" \ + --output "${health_body}" \ + "${base_url}/health" +jq -e '.status == "healthy"' "${health_body}" >/dev/null + +if [ -n "${expected_revision}" ]; then + actual_revision="$( + awk ' + tolower($1) == "x-policyengine-simulation-revision:" { + gsub("\r", "", $2) + print $2 + } + ' "${health_headers}" | + tail -n 1 + )" + if [ "${actual_revision}" != "${expected_revision}" ]; then + printf 'Expected revision %s at %s, received %s\n' \ + "${expected_revision}" "${base_url}" "${actual_revision:-no revision header}" >&2 + exit 1 + fi +fi -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 diff --git a/.github/scripts/configure-cloud-run-simulation-entry-domains.sh b/.github/scripts/configure-cloud-run-simulation-entry-domains.sh deleted file mode 100644 index beccfbfea..000000000 --- a/.github/scripts/configure-cloud-run-simulation-entry-domains.sh +++ /dev/null @@ -1,49 +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}" -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 - 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-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-entry-revision.sh b/.github/scripts/set-cloud-run-simulation-entry-revision.sh index a6b760f1b..39abee16e 100755 --- a/.github/scripts/set-cloud-run-simulation-entry-revision.sh +++ b/.github/scripts/set-cloud-run-simulation-entry-revision.sh @@ -1,41 +1,53 @@ #!/usr/bin/env bash -# Operator-run production/staging command. GitHub Actions intentionally never -# invokes this script or changes Cloud Run traffic. +# Assign all stable Cloud Run service traffic to one exact, ready revision. +# The expected-current guard prevents this workflow from overwriting a traffic +# change made after its candidate deployment. The same command supports +# rollback by swapping the target and expected-current revisions. set -euo pipefail gcloud_bin="${GCLOUD_BIN:-gcloud}" 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-entry-staging" - ;; - production) - service="policyengine-simulation-entry" - ;; - *) - printf 'SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT must be staging or production\n' >&2 +service="${SIMULATION_ENTRYPOINT_SERVICE:?SIMULATION_ENTRYPOINT_SERVICE is required}" +target_revision="${SIMULATION_ENTRYPOINT_TARGET_REVISION:?SIMULATION_ENTRYPOINT_TARGET_REVISION is required}" +expected_current_revision="${SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION:?SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION is required}" + +for revision in "${target_revision}" "${expected_current_revision}"; do + if [ "${revision}" = "LATEST" ] || [ "${revision}" = "latest" ]; then + printf 'Traffic revisions must be exact; LATEST is not allowed\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 +done + +active_revision() { + jq -er ' + [ + .status.traffic[]? + | select((.percent // 0) == 100) + | .revisionName + | select(type == "string" and length > 0) + ] + | if length == 1 then .[0] + else error("service must have exactly one revision at 100 percent") + end + ' +} + +service_json="$("${gcloud_bin}" run services describe "${service}" \ + --project "${project_id}" \ + --region "${region}" \ + --format=json)" +current_revision="$(active_revision <<<"${service_json}")" + +if [ "${current_revision}" != "${expected_current_revision}" ]; then + printf 'Stable traffic changed after deployment: expected %s, found %s\n' \ + "${expected_current_revision}" "${current_revision}" >&2 + exit 2 fi -revision_json="$("${gcloud_bin}" run revisions describe "${revision}" \ +revision_json="$("${gcloud_bin}" run revisions describe "${target_revision}" \ --project "${project_id}" \ --region "${region}" \ --format=json)" @@ -45,18 +57,30 @@ revision_service="$(jq -r \ if [ "${revision_service}" != "${service}" ]; then printf 'Revision %s belongs to %s, not %s\n' \ - "${revision}" "${revision_service:-an unknown service}" "${service}" >&2 + "${target_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 + printf 'Revision %s is not Ready\n' "${target_revision}" >&2 exit 2 fi "${gcloud_bin}" run services update-traffic "${service}" \ --project "${project_id}" \ --region "${region}" \ - --to-revisions "${revision}=100" + --to-revisions "${target_revision}=100" + +updated_service_json="$("${gcloud_bin}" run services describe "${service}" \ + --project "${project_id}" \ + --region "${region}" \ + --format=json)" +updated_revision="$(active_revision <<<"${updated_service_json}")" + +if [ "${updated_revision}" != "${target_revision}" ]; then + printf 'Traffic update did not activate %s; found %s\n' \ + "${target_revision}" "${updated_revision}" >&2 + exit 2 +fi diff --git a/.github/scripts/simulation-deployment-summary.sh b/.github/scripts/simulation-deployment-summary.sh index b148933a6..a332e259a 100755 --- a/.github/scripts/simulation-deployment-summary.sh +++ b/.github/scripts/simulation-deployment-summary.sh @@ -1,6 +1,6 @@ #!/bin/bash # Generate deployment summary for GitHub Actions -# Usage: ./simulation-deployment-summary.sh +# Usage: ./simulation-deployment-summary.sh set -euo pipefail @@ -31,7 +31,7 @@ PROD_URL="${4:-}" case "$PROD_RESULT" in success) echo "✅ **Prod deployment**: Success" - [ -n "$PROD_URL" ] && echo " - Candidate URL: $PROD_URL" + [ -n "$PROD_URL" ] && echo " - Stable URL: $PROD_URL" ;; skipped) echo "⏭️ **Prod deployment**: Skipped" diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 7a51b9b3b..510a8f7e8 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -15,10 +15,6 @@ on: required: true type: string description: "Cloud Run service name" - entrypoint_public_url: - required: true - type: string - description: "Public URL represented by this entrypoint" entrypoint_revision_prefix: required: true type: string @@ -29,6 +25,10 @@ on: entrypoint_max_instances: required: true type: number + promote_entrypoint: + required: true + type: boolean + description: "Assign stable service traffic after all environment tests pass" force_latest: required: false default: false @@ -42,7 +42,16 @@ on: outputs: simulation_api_url: description: "Tagged Cloud Run candidate URL qualified by this deployment" - value: ${{ jobs.deploy_entrypoint.outputs.url }} + value: ${{ jobs.deploy_entrypoint.outputs.candidate_url }} + stable_url: + description: "Stable Cloud Run service URL" + value: ${{ jobs.deploy_entrypoint.outputs.stable_url }} + entrypoint_revision: + description: "Exact Cloud Run revision qualified by this deployment" + value: ${{ jobs.deploy_entrypoint.outputs.revision }} + entrypoint_image: + description: "Image reference resolved on the deployed Cloud Run revision" + value: ${{ jobs.deploy_entrypoint.outputs.image }} policyengine_version: description: "Deployed policyengine.py package version" value: ${{ jobs.prepare.outputs.policyengine_version }} @@ -124,9 +133,12 @@ jobs: REPOSITORY: ${{ vars.SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY || 'policyengine-simulation-entry' }} IMAGE_NAME: policyengine-simulation-entry outputs: - image: ${{ steps.image.outputs.uri }} + image: ${{ steps.candidate.outputs.image }} revision_tag: ${{ steps.metadata.outputs.tag }} - url: ${{ steps.url.outputs.url }} + revision: ${{ steps.candidate.outputs.revision }} + candidate_url: ${{ steps.candidate.outputs.url }} + stable_url: ${{ steps.previous.outputs.stable_url }} + previous_revision: ${{ steps.previous.outputs.revision }} steps: - uses: actions/checkout@v6 @@ -146,6 +158,30 @@ jobs: - name: Configure Artifact Registry authentication run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet + - name: Capture stable service state before deployment + id: previous + run: | + service_json="$(gcloud run services describe "${{ inputs.entrypoint_service }}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format=json)" + stable_url="$(jq -er '.status.url | select(type == "string" and length > 0)' <<<"${service_json}")" + revision="$(jq -er ' + [ + .status.traffic[]? + | select((.percent // 0) == 100) + | .revisionName + | select(type == "string" and length > 0) + ] + | if length == 1 then .[0] + else error("service must have exactly one revision at 100 percent") + end + ' <<<"${service_json}")" + { + echo "stable_url=${stable_url}" + echo "revision=${revision}" + } >> "$GITHUB_OUTPUT" + - name: Select immutable image id: image run: echo "uri=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" @@ -175,7 +211,7 @@ jobs: --no-traffic \ --allow-unauthenticated \ --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ - --set-env-vars "^@^APP_ENVIRONMENT=${{ inputs.release_environment }}@SIMULATION_ENTRYPOINT_PUBLIC_URL=${{ inputs.entrypoint_public_url }}@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-env-vars "^@^APP_ENVIRONMENT=${{ inputs.release_environment }}@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=${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}:latest" \ --cpu 1 \ --memory 512Mi \ @@ -184,17 +220,43 @@ jobs: --min-instances "${{ inputs.entrypoint_min_instances }}" \ --max-instances "${{ inputs.entrypoint_max_instances }}" - - name: Resolve tagged candidate URL - id: url + - name: Resolve exact candidate metadata + id: candidate env: TAG: ${{ steps.metadata.outputs.tag }} run: | - url="$(gcloud run services describe "${{ inputs.entrypoint_service }}" \ + service_json="$(gcloud run services describe "${{ inputs.entrypoint_service }}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format=json)" + url="$(jq -er --arg tag "${TAG}" ' + .status.traffic[] + | select(.tag == $tag) + | .url + | select(type == "string" and length > 0) + ' <<<"${service_json}")" + revision="$(jq -er --arg tag "${TAG}" ' + .status.traffic[] + | select(.tag == $tag) + | .revisionName + | select(type == "string" and length > 0) + ' <<<"${service_json}")" + revision_json="$(gcloud run revisions describe "${revision}" \ --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" + --format=json)" + image="$(jq -er ' + (.status.imageDigest // .spec.containers[0].image) + | select( + type == "string" + and contains("@sha256:") + ) + ' <<<"${revision_json}")" + { + echo "url=${url}" + echo "revision=${revision}" + echo "image=${image}" + } >> "$GITHUB_OUTPUT" - name: Install entrypoint unit-test dependencies working-directory: ${{ env.PROJECT_DIR }} @@ -358,7 +420,7 @@ jobs: - uses: astral-sh/setup-uv@v8.1.0 - name: Verify the complete deployed request path - run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ needs.deploy_entrypoint.outputs.url }}" + run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ needs.deploy_entrypoint.outputs.candidate_url }}" "${{ needs.deploy_entrypoint.outputs.revision }}" - name: Generate API clients run: ./scripts/generate-clients.sh @@ -370,7 +432,7 @@ jobs: SIMULATION_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} SIMULATION_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} SIMULATION_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} - run: .github/scripts/simulation-run-integ-tests.sh "${{ inputs.release_environment }}" "${{ needs.deploy_entrypoint.outputs.url }}" "${{ needs.prepare.outputs.us_version }}" "${{ needs.prepare.outputs.uk_version }}" + run: .github/scripts/simulation-run-integ-tests.sh "${{ inputs.release_environment }}" "${{ needs.deploy_entrypoint.outputs.candidate_url }}" "${{ needs.prepare.outputs.us_version }}" "${{ needs.prepare.outputs.uk_version }}" - name: Record deployment marker working-directory: projects/policyengine-simulation-executor @@ -390,7 +452,7 @@ jobs: runs-on: ubuntu-latest environment: ${{ inputs.release_environment }} env: - SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ needs.deploy_entrypoint.outputs.url }} + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ needs.deploy_entrypoint.outputs.candidate_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 }} @@ -411,3 +473,88 @@ jobs: - name: Run authenticated deployment tests working-directory: projects/policyengine-simulation-entry run: uv run pytest authenticated_tests/ -v + + promote_entrypoint: + name: Promote and verify Cloud Run entrypoint + if: ${{ inputs.promote_entrypoint }} + needs: [deploy_entrypoint, authenticated_test] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + permissions: + contents: read + id-token: write + env: + REGION: ${{ vars.SIMULATION_ENTRYPOINT_GCP_REGION || 'us-central1' }} + PROJECT_ID: ${{ vars.SIMULATION_ENTRYPOINT_GCP_PROJECT_ID }} + SERVICE: ${{ inputs.entrypoint_service }} + TARGET_REVISION: ${{ needs.deploy_entrypoint.outputs.revision }} + PREVIOUS_REVISION: ${{ needs.deploy_entrypoint.outputs.previous_revision }} + STABLE_URL: ${{ needs.deploy_entrypoint.outputs.stable_url }} + CUSTOM_URL: ${{ vars.SIMULATION_ENTRYPOINT_CUSTOM_URL }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install stable-endpoint test dependencies + working-directory: projects/policyengine-simulation-entry + run: uv sync --extra test --frozen + + - uses: google-github-actions/auth@v2 + with: + 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 + + - name: Promote exact tested revision + id: promote + env: + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID: ${{ env.PROJECT_ID }} + SIMULATION_ENTRYPOINT_GCP_REGION: ${{ env.REGION }} + SIMULATION_ENTRYPOINT_SERVICE: ${{ env.SERVICE }} + SIMULATION_ENTRYPOINT_TARGET_REVISION: ${{ env.TARGET_REVISION }} + SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION: ${{ env.PREVIOUS_REVISION }} + run: .github/scripts/set-cloud-run-simulation-entry-revision.sh + + - name: Verify stable service routing + run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${STABLE_URL}" "${TARGET_REVISION}" + + - name: Verify stable service authentication + working-directory: projects/policyengine-simulation-entry + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ env.STABLE_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 }} + run: uv run pytest authenticated_tests/ -v + + - name: Verify configured custom hostname + if: ${{ env.CUSTOM_URL != '' }} + run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${CUSTOM_URL}" "${TARGET_REVISION}" + + - name: Verify configured custom-hostname authentication + if: ${{ env.CUSTOM_URL != '' }} + working-directory: projects/policyengine-simulation-entry + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ env.CUSTOM_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 }} + run: uv run pytest authenticated_tests/ -v + + - name: Restore previous revision after failed verification + if: ${{ failure() && steps.promote.outcome == 'success' }} + env: + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID: ${{ env.PROJECT_ID }} + SIMULATION_ENTRYPOINT_GCP_REGION: ${{ env.REGION }} + SIMULATION_ENTRYPOINT_SERVICE: ${{ env.SERVICE }} + SIMULATION_ENTRYPOINT_TARGET_REVISION: ${{ env.PREVIOUS_REVISION }} + SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION: ${{ env.TARGET_REVISION }} + run: .github/scripts/set-cloud-run-simulation-entry-revision.sh diff --git a/.github/workflows/simulation-deploy.yml b/.github/workflows/simulation-deploy.yml index 5646ee1d6..84aa75c58 100644 --- a/.github/workflows/simulation-deploy.yml +++ b/.github/workflows/simulation-deploy.yml @@ -35,10 +35,10 @@ jobs: release_environment: beta modal_environment: staging entrypoint_service: policyengine-simulation-entry-staging - entrypoint_public_url: https://staging.simulation.api.policyengine.org entrypoint_revision_prefix: s entrypoint_min_instances: 0 entrypoint_max_instances: 2 + promote_entrypoint: false force_latest: ${{ inputs.force_latest || false }} force_recompute: ${{ inputs.force_recompute || false }} secrets: inherit @@ -54,10 +54,10 @@ jobs: release_environment: prod modal_environment: main entrypoint_service: policyengine-simulation-entry - entrypoint_public_url: https://simulation.api.policyengine.org entrypoint_revision_prefix: p entrypoint_min_instances: 1 entrypoint_max_instances: 20 + promote_entrypoint: true force_latest: ${{ inputs.force_latest || false }} force_recompute: ${{ inputs.force_recompute || false }} secrets: inherit @@ -76,4 +76,4 @@ jobs: "${{ needs.beta.result }}" \ "${{ needs.beta.outputs.simulation_api_url }}" \ "${{ needs.prod.result }}" \ - "${{ needs.prod.outputs.simulation_api_url }}" + "${{ needs.prod.outputs.stable_url }}" diff --git a/README.md b/README.md index 66b6d1cef..59becbefe 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,10 @@ make generate-clients # regenerate the OpenAPI Python client Simulation workers and the old gateway continue to deploy to Modal. The permanent Simulation Entrypoint deploys to Cloud Run as tagged no-traffic -candidates. On merge to `main`, `.github/workflows/simulation-deploy.yml` -promotes one complete stack at a time: +candidates. Beta qualification uses Cloud Run's generated candidate URLs and +does not require a custom beta domain. On merge to `main`, +`.github/workflows/simulation-deploy.yml` releases one complete stack at a +time: 1. prepare the environment's deployment inputs and runtime secrets; 2. deploy and unit test the Entrypoint, Modal gateway, and Modal executor in @@ -85,12 +87,20 @@ promotes one complete stack at a time: 4. run generated-client and live authentication tests through the complete three-service request path; and 5. begin the `prod` deployment only after every `beta` deployment and - qualification job succeeds. + qualification job succeeds; +6. assign stable production traffic to the exact tested entrypoint revision + after the complete production suite passes; and +7. verify the stable endpoint, restoring its previous revision if immediate + post-promotion checks fail. A successful push-triggered full-stack deployment publishes the API client from the exact deployed commit. -Cloud Run traffic promotion and rollback remain manual operator actions. +The stable production service may have a separately managed custom hostname. +Domain ownership, DNS, TLS, and other one-time cloud setup remain operator +responsibilities outside committed deployment automation. Entrypoint traffic +promotion does not change API v1 revision traffic or its simulation-entrypoint +migration flag. 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/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 77ecfde16..44efb8492 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -22,26 +22,54 @@ 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. -## Candidate deployment +## Candidate qualification For each eligible revision, the workflow first deploys `beta` candidates for the Cloud Run entrypoint, Modal gateway, and versioned Modal executor. Each deployment job runs its own service unit tests. Only after all three jobs pass does the workflow publish routing and run integration and -authentication checks through the complete request path. +authentication checks through the complete request path. Beta entrypoint +qualification uses Cloud Run's generated tagged revision URL and does not +require a custom domain. The `prod` environment follows the same sequence automatically, but cannot begin until every `beta` deployment, routing, integration, and authentication -job has succeeded. - -The workflow does not assign production traffic. +job has succeeded. Production tests also use the tagged candidate URL, so the +currently serving revision remains unchanged during qualification. ## Promotion and rollback -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. +After the complete production suite passes, the workflow verifies that the +exact tested revision belongs to the production service and is ready. It also +checks that stable traffic still points to the revision recorded before +deployment. If those guards pass, it assigns all stable service traffic to the +tested production revision. + +The workflow then checks the stable generated Cloud Run URL. It uses the +runtime revision header to prove that the stable URL reaches the exact revision +that passed candidate tests, and it repeats the deployed authentication checks. +If an immediate post-promotion check fails, the workflow restores the exact +previous revision recorded before deployment and fails visibly. + +Deployment concurrency prevents two releases from changing the entrypoint +service simultaneously. Promotion never targets `LATEST`. + +This traffic change applies only to the Simulation Entrypoint Cloud Run +service. API v1 revision traffic and its choice between direct Modal access and +the Cloud Run entrypoint remain separately controlled. + +## Service URLs and domains + +Cloud Run provides a stable service URL and tagged candidate URLs. Beta uses +those generated URLs; it does not require a custom hostname. + +Production may use a persistent custom hostname mapped to its stable Cloud Run +service. The mapping follows the service's active traffic assignment and is +not recreated for each release. When a custom hostname is configured as +protected environment metadata, post-promotion checks verify it in addition +to the generated stable URL. -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. +Domain ownership, mapping creation, DNS publication, managed TLS activation, +and infrastructure administration are one-time operator actions governed by +private runbooks rather than committed bootstrap scripts in this public +repository. diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index 0a5a12a0a..00b406408 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -16,5 +16,10 @@ compute dispatch. 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. +The service does not require its own public URL as runtime configuration. +Cloud Run's generated revision name is returned in a response header so +deployment checks can prove which revision a tagged or stable URL serves. + +Live caller/backend authentication checks are isolated in `authenticated_tests`. +They qualify tagged Cloud Run candidates before production traffic changes and +verify the stable production endpoint immediately after promotion. 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 1ece62a42..2bca5473e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -135,6 +135,10 @@ async def request_context(request: Request, call_next): response = await call_next(request) elapsed_ms = round((time.monotonic() - started) * 1000, 2) response.headers["X-Request-ID"] = request_id + if runtime_settings.revision: + response.headers["X-PolicyEngine-Simulation-Revision"] = ( + runtime_settings.revision + ) logger.info( "simulation_entry_request", extra={ diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index 74e26ee71..009daba4b 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -30,7 +30,7 @@ class Settings: """All configuration required by the Stage 5 control-plane proxy.""" environment: str - public_url: str + revision: str auth_required: bool auth_issuer: str auth_audience: str @@ -46,7 +46,7 @@ class Settings: def from_env(cls) -> "Settings": return cls( environment=os.getenv("APP_ENVIRONMENT", "local").lower(), - public_url=os.getenv("SIMULATION_ENTRYPOINT_PUBLIC_URL", ""), + revision=os.getenv("K_REVISION", ""), auth_required=_truthy( os.getenv("SIMULATION_ENTRYPOINT_AUTH_REQUIRED"), default=True, @@ -107,22 +107,10 @@ def validate(self) -> None: if self.connect_timeout_seconds <= 0 or self.request_timeout_seconds <= 0: raise ConfigurationError("Old-gateway timeouts must be positive.") - 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-entry/tests/conftest.py b/projects/policyengine-simulation-entry/tests/conftest.py index 47fb01a7e..53cc19b70 100644 --- a/projects/policyengine-simulation-entry/tests/conftest.py +++ b/projects/policyengine-simulation-entry/tests/conftest.py @@ -14,7 +14,7 @@ def make_settings(**overrides) -> Settings: values = { "environment": "test", - "public_url": "https://simulation.example.test", + "revision": "simulation-entry-test-revision", "auth_required": True, "auth_issuer": "https://issuer.example/", "auth_audience": "simulation-entry", diff --git a/projects/policyengine-simulation-entry/tests/test_app.py b/projects/policyengine-simulation-entry/tests/test_app.py index 16e206a1f..424ac3697 100644 --- a/projects/policyengine-simulation-entry/tests/test_app.py +++ b/projects/policyengine-simulation-entry/tests/test_app.py @@ -30,6 +30,10 @@ def test_health_is_local_and_compatible(client, backend): assert result.status_code == 200 assert result.json() == {"status": "healthy"} + assert ( + result.headers["x-policyengine-simulation-revision"] + == "simulation-entry-test-revision" + ) assert backend.requests == [] diff --git a/projects/policyengine-simulation-entry/tests/test_config.py b/projects/policyengine-simulation-entry/tests/test_config.py index cff626dea..8820509d0 100644 --- a/projects/policyengine-simulation-entry/tests/test_config.py +++ b/projects/policyengine-simulation-entry/tests/test_config.py @@ -17,23 +17,6 @@ def test_partial_caller_auth_is_rejected(): 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", [ diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index 001be1edd..4bff5147f 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -2,17 +2,12 @@ import os import subprocess +import textwrap import tomllib from pathlib import Path REPOSITORY_ROOT = Path(__file__).resolve().parents[3] -DOMAIN_SCRIPT = ( - REPOSITORY_ROOT - / ".github" - / "scripts" - / "configure-cloud-run-simulation-entry-domains.sh" -) TRAFFIC_SCRIPT = ( REPOSITORY_ROOT / ".github" @@ -20,10 +15,7 @@ / "set-cloud-run-simulation-entry-revision.sh" ) SMOKE_SCRIPT = ( - REPOSITORY_ROOT - / ".github" - / "scripts" - / "cloud-run-simulation-entry-smoke.sh" + REPOSITORY_ROOT / ".github" / "scripts" / "cloud-run-simulation-entry-smoke.sh" ) DEPLOY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.yml" REUSABLE_DEPLOY_WORKFLOW = ( @@ -44,8 +36,7 @@ ) -def test_operator_scripts_have_valid_shell_syntax(): - subprocess.run(["bash", "-n", DOMAIN_SCRIPT], check=True) +def test_deployment_scripts_have_valid_shell_syntax(): subprocess.run(["bash", "-n", TRAFFIC_SCRIPT], check=True) subprocess.run(["bash", "-n", SMOKE_SCRIPT], check=True) assert os.access(SMOKE_SCRIPT, os.X_OK) @@ -56,8 +47,7 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): reusable_workflow = REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8") assert "gcloud run deploy" in reusable_workflow - assert "update-traffic" not in reusable_workflow - assert "set-cloud-run-simulation-entry-revision.sh" not in reusable_workflow + assert "set-cloud-run-simulation-entry-revision.sh" in reusable_workflow assert "terraform" not in reusable_workflow.lower() assert "deploy_entrypoint:" in reusable_workflow assert "deploy_gateway:" in reusable_workflow @@ -65,6 +55,7 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): assert "update_routing:" in reusable_workflow assert "integration:" in reusable_workflow assert "authenticated_test:" in reusable_workflow + assert "promote_entrypoint:" in reusable_workflow assert ( "needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor]" in reusable_workflow @@ -73,13 +64,16 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): assert deploy_workflow.index("beta:") < deploy_workflow.index("prod:") assert "release_environment: beta" in deploy_workflow assert "release_environment: prod" in deploy_workflow + assert "promote_entrypoint: false" in deploy_workflow + assert "promote_entrypoint: true" in deploy_workflow assert "entrypoint_environment" not in deploy_workflow assert "entrypoint_environment" not in reusable_workflow + assert "entrypoint_public_url" not in deploy_workflow + assert "entrypoint_public_url" not in reusable_workflow + assert "SIMULATION_ENTRYPOINT_PUBLIC_URL" not in reusable_workflow + assert "staging.simulation.api.policyengine.org" not in deploy_workflow assert ( - reusable_workflow.count( - "environment: ${{ inputs.release_environment }}" - ) - == 7 + reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 8 ) assert "APP_ENVIRONMENT=${{ inputs.release_environment }}" in reusable_workflow assert "id-token: write" in reusable_workflow @@ -93,6 +87,12 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): "*.tf" ) ) + assert not ( + REPOSITORY_ROOT + / ".github" + / "scripts" + / "configure-cloud-run-simulation-entry-domains.sh" + ).exists() def test_full_stack_promotion_order_is_explicit(): @@ -118,35 +118,149 @@ def test_full_stack_promotion_order_is_explicit(): assert reusable_workflow.index("integration:") < reusable_workflow.index( "authenticated_test:" ) + assert reusable_workflow.index("\n authenticated_test:") < reusable_workflow.index( + "\n promote_entrypoint:" + ) + assert "needs: [deploy_entrypoint, authenticated_test]" in reusable_workflow + assert ( + 'cloud-run-simulation-entry-smoke.sh "${STABLE_URL}" "${TARGET_REVISION}"' + in reusable_workflow + ) + assert "failure() && steps.promote.outcome == 'success'" in reusable_workflow assert "needs: beta" in deploy_workflow assert "skip_beta" not in deploy_workflow -def test_traffic_changes_are_operator_run_and_revision_validated(): +def test_traffic_changes_are_exact_revision_validated_and_reversible(tmp_path): 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 + assert '--to-revisions "${target_revision}=100"' in script + assert "expected_current_revision" in script + assert '--to-revisions "LATEST=100"' not in script - result = subprocess.run( + state_file = tmp_path / "active-revision" + state_file.write_text("policyengine-simulation-entry-00001-old", encoding="utf-8") + fake_gcloud = tmp_path / "gcloud" + fake_gcloud.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + case "$1 $2 $3" in + "run services describe") + active="$(cat "${FAKE_GCLOUD_STATE}")" + printf '{"status":{"traffic":[{"revisionName":"%s","percent":100}]}}\\n' "${active}" + ;; + "run revisions describe") + revision="$4" + printf '{"metadata":{"labels":{"serving.knative.dev/service":"policyengine-simulation-entry"}},"status":{"conditions":[{"type":"Ready","status":"True"}]},"revision":"%s"}\\n' "${revision}" + ;; + "run services update-traffic") + target="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--to-revisions" ]; then + target="${2%=100}" + break + fi + shift + done + test -n "${target}" + printf '%s' "${target}" > "${FAKE_GCLOUD_STATE}" + ;; + *) + printf 'Unexpected fake gcloud command: %s\\n' "$*" >&2 + exit 2 + ;; + esac + """ + ), + encoding="utf-8", + ) + fake_gcloud.chmod(0o755) + + base_env = { + **os.environ, + "GCLOUD_BIN": str(fake_gcloud), + "FAKE_GCLOUD_STATE": str(state_file), + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", + "SIMULATION_ENTRYPOINT_SERVICE": "policyengine-simulation-entry", + } + + subprocess.run( + ["bash", TRAFFIC_SCRIPT], + check=True, + capture_output=True, + text=True, + env={ + **base_env, + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00002-new", + "SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION": "policyengine-simulation-entry-00001-old", + }, + ) + assert ( + state_file.read_text(encoding="utf-8") + == "policyengine-simulation-entry-00002-new" + ) + + subprocess.run( ["bash", TRAFFIC_SCRIPT], check=True, capture_output=True, text=True, + env={ + **base_env, + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00001-old", + "SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION": "policyengine-simulation-entry-00002-new", + }, + ) + assert ( + state_file.read_text(encoding="utf-8") + == "policyengine-simulation-entry-00001-old" + ) + + +def test_traffic_change_refuses_an_intervening_promotion(tmp_path): + state_file = tmp_path / "active-revision" + state_file.write_text( + "policyengine-simulation-entry-00003-intervening", + encoding="utf-8", + ) + fake_gcloud = tmp_path / "gcloud" + fake_gcloud.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + active="$(cat "${FAKE_GCLOUD_STATE}")" + printf '{"status":{"traffic":[{"revisionName":"%s","percent":100}]}}\\n' "${active}" + """ + ), + encoding="utf-8", + ) + fake_gcloud.chmod(0o755) + + result = subprocess.run( + ["bash", TRAFFIC_SCRIPT], + check=False, + capture_output=True, + text=True, env={ **os.environ, + "GCLOUD_BIN": str(fake_gcloud), + "FAKE_GCLOUD_STATE": str(state_file), "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", - "SIMULATION_ENTRYPOINT_DEPLOYMENT_ENVIRONMENT": "production", - "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00001-abc", - "SIMULATION_ENTRYPOINT_TRAFFIC_DRY_RUN": "1", + "SIMULATION_ENTRYPOINT_SERVICE": "policyengine-simulation-entry", + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00002-new", + "SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION": "policyengine-simulation-entry-00001-old", }, ) - assert "policyengine-simulation-entry-00001-abc=100" in result.stdout - assert "policyengine-simulation-entry-staging" not in result.stdout + assert result.returncode == 2 + assert "Stable traffic changed after deployment" in result.stderr def test_container_context_excludes_local_environments_and_unrelated_projects(): @@ -177,25 +291,6 @@ def test_production_lock_excludes_modal_and_database_runtime_packages(): 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_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", - "SIMULATION_ENTRYPOINT_DOMAINS_DRY_RUN": "1", - }, - ) - - assert "policyengine-simulation-entry-staging" in result.stdout - assert "staging.simulation.api.policyengine.org" 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") diff --git a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py index 32ab714ba..168058f07 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py @@ -173,6 +173,8 @@ def test_generates_success_summary(self, temp_github_step_summary): assert "Simulation API Deployment Summary" in summary assert "Beta deployment" in summary assert "Prod deployment" in summary + assert "Candidate URL: https://beta.example.com" in summary + assert "Stable URL: https://prod.example.com" in summary assert "https://beta.example.com" in summary assert "https://prod.example.com" in summary @@ -680,9 +682,7 @@ def test_deploy_workflow_records_marker_after_integration(self): # written until the routed three-service stack passes integration. assert reusable_workflow.index( "Run integration tests through entrypoint, gateway, and executor" - ) < ( - reusable_workflow.index("modal-record-deployment.sh") - ) + ) < (reusable_workflow.index("modal-record-deployment.sh")) # The payload builder maps missing env vars to empty strings, so a # silent rename here would empty the marker's fields — assert every @@ -702,6 +702,7 @@ def test_deploy_workflow_records_marker_after_integration(self): ): assert env_line in marker_step, f"marker step is missing: {env_line}" + class TestModalGetUrl: """Tests for modal-get-url.sh""" From dda87ac1555116936f3add8321893ae30e4b0b8e Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 28 Jul 2026 14:47:51 +0300 Subject: [PATCH 14/15] Harden simulation entrypoint deployment --- .gitignore | 1 + Makefile | 2 +- README.md | 30 ++++++++----- .../Dockerfile.dockerignore | 1 + .../src/policyengine_simulation_entry/app.py | 21 +++++++-- .../policyengine_simulation_entry/config.py | 45 +++++++++++++++++-- .../tests/test_app.py | 36 +++++++++++++++ .../tests/test_config.py | 26 +++++++++++ .../tests/test_deployment_assets.py | 4 ++ 9 files changed, 148 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 51ed0134a..59e26815c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ backend.tfvars .vscode deployment/terraform/*/auto.tfvars .claude-plan +gha-creds-*.json # macOS Finder metadata .DS_Store diff --git a/Makefile b/Makefile index 4c14462a4..42b26955b 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ help: @echo " make clean - Remove caches and stop containers" @echo " make push-pr-branch - Push current branch to origin for a PR" @echo "" - @echo "Deployment is automated via GitHub Actions to Modal; there is no manual deploy target." + @echo "Deployment is automated via GitHub Actions to Cloud Run and Modal; there is no manual deploy target." # Development commands dev: diff --git a/README.md b/README.md index 59becbefe..292ceb7ff 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,21 @@ Monorepo for PolicyEngine's simulation API — the services, shared libraries, and deployment configuration behind PolicyEngine's economic simulation service. The -public API is served by a stable Modal gateway that routes requests to versioned -simulation executor apps. +permanent public entrypoint runs on Cloud Run. During the current migration it +authenticates callers and delegates to the existing Modal gateway, which +continues to route requests to versioned simulation executor apps. ## Architecture Active services (`projects/`): -- **policyengine-simulation-gateway** — the stable, public Modal gateway. Serves - the API contract and routes each request to a versioned executor app - (`policyengine-simulation-py{X}`) using routing state in a `modal.Dict`. +- **policyengine-simulation-entry** — the permanent Cloud Run entrypoint. It + owns caller authentication and currently proxies the public contract to the + existing Modal gateway with a separate machine identity. +- **policyengine-simulation-gateway** — the existing Modal routing gateway. + During migration it remains the entrypoint's upstream and routes each request + to a versioned executor app (`policyengine-simulation-py{X}`) using routing + state in a `modal.Dict`. - **policyengine-simulation-executor** — the simulation engine. Runs on Modal as versioned apps, and also builds a Docker image used for local development and integration tests (port 8082). @@ -46,8 +51,9 @@ make logs # tail logs make down # stop ``` -`make dev` runs it in the foreground with a live-reload build. The gateway is not -run locally — it is a Modal-only service. +`make dev` runs it in the foreground with a live-reload build. The gateway is +not run locally — it is a Modal-only service. The Cloud Run entrypoint can be +run and tested independently from `projects/policyengine-simulation-entry`. ## Testing @@ -58,8 +64,8 @@ make test-complete # unit + integration (manages services) ``` Each project's unit tests run in its own locked uv environment — the same lock -the Modal image installs with `uv_sync(frozen=True)`. Integration tests generate -the API client, start the executor via Docker Compose, wait for +used by its deployed image. Integration tests generate the API client, start +the executor via Docker Compose, wait for `http://localhost:8082/ping/alive`, then run `projects/policyengine-apis-integ` against it. @@ -107,6 +113,7 @@ versioned `policyengine-simulation-py{version}` apps. For Modal image and deploy specifics (image dependency pinning, artifact fetch, observability), see the service READMEs: +- `projects/policyengine-simulation-entry/README.md` - `projects/policyengine-simulation-gateway/README.md` - `projects/policyengine-simulation-executor/README.md` @@ -114,7 +121,8 @@ service READMEs: ``` projects/ - policyengine-simulation-gateway/ # stable Modal gateway (public API) + policyengine-simulation-entry/ # permanent Cloud Run public entrypoint + policyengine-simulation-gateway/ # existing Modal routing gateway policyengine-simulation-executor/ # simulation engine (versioned Modal apps) policyengine-apis-integ/ # integration tests policyengine-api-full/ # reserved stub @@ -126,7 +134,7 @@ libs/ deployment/ docker-compose.yml # local simulation-executor scripts/ # client generation + local integration helpers -.github/workflows/ # CI, Modal deploy, client publishing +.github/workflows/ # CI, Cloud Run/Modal deploy, client publishing ``` ## Contributing diff --git a/projects/policyengine-simulation-entry/Dockerfile.dockerignore b/projects/policyengine-simulation-entry/Dockerfile.dockerignore index 0740fe81c..6e5fd2216 100644 --- a/projects/policyengine-simulation-entry/Dockerfile.dockerignore +++ b/projects/policyengine-simulation-entry/Dockerfile.dockerignore @@ -1,4 +1,5 @@ .git +gha-creds-*.json projects/* !projects/policyengine-simulation-entry/ 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 2bca5473e..134864098 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -35,7 +35,6 @@ from policyengine_simulation_entry.auth import CallerAuthenticator from policyengine_simulation_entry.backend import ( - BackendAuthenticationError, BackendResponse, BackendTimeout, BackendUnavailable, @@ -132,7 +131,23 @@ 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) + try: + response = await call_next(request) + except Exception: + logger.exception( + "simulation_entry_unhandled_request", + extra={ + "request_id": request_id, + "method": request.method, + "path": _route_template(request), + **_request_identifiers(request), + }, + ) + response = Response( + content="Internal Server Error", + status_code=500, + media_type="text/plain", + ) elapsed_ms = round((time.monotonic() - started) * 1000, 2) response.headers["X-Request-ID"] = request_id if runtime_settings.revision: @@ -218,7 +233,7 @@ async def forward( **BACKEND_RESPONSE_HEADER, }, ) - except (BackendUnavailable, BackendAuthenticationError): + except BackendUnavailable: record_event( "simulation_entry_backend_unavailable", request_id=request.state.request_id, diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index 009daba4b..09a78541f 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -25,6 +25,29 @@ def _normalized_issuer(value: str) -> str: return f"{value.rstrip('/')}/" if value else "" +def _validate_https_url( + name: str, + value: str, + *, + allow_path: bool = True, +) -> None: + parsed = urlparse(value) + if ( + value != value.strip() + or parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + ): + raise ConfigurationError(f"{name} must be an absolute HTTPS URL.") + if parsed.params or parsed.query or parsed.fragment: + raise ConfigurationError( + f"{name} must not contain parameters, a query string, or a fragment." + ) + if not allow_path and parsed.path not in {"", "/"}: + raise ConfigurationError(f"{name} must not contain a path.") + + @dataclass(frozen=True) class Settings: """All configuration required by the Stage 5 control-plane proxy.""" @@ -90,6 +113,11 @@ def validate(self) -> None: raise ConfigurationError( "Caller authentication is required but issuer/audience are missing." ) + if self.auth_issuer: + _validate_https_url( + "SIMULATION_ENTRYPOINT_AUTH_ISSUER", + self.auth_issuer, + ) required_backend = { "OLD_GATEWAY_URL": self.old_gateway_url, @@ -104,13 +132,24 @@ def validate(self) -> None: f"Missing old-gateway configuration: {', '.join(missing)}." ) + _validate_https_url( + "OLD_GATEWAY_AUTH_ISSUER", + self.old_gateway_auth_issuer, + ) + if self.connect_timeout_seconds <= 0 or self.request_timeout_seconds <= 0: raise ConfigurationError("Old-gateway timeouts must be positive.") + _validate_https_url( + "OLD_GATEWAY_URL", + self.old_gateway_url, + allow_path=False, + ) upstream = urlparse(self.old_gateway_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): + upstream_hostname = upstream.hostname + if upstream_hostname is None or not upstream_hostname.endswith( + MODAL_GATEWAY_HOST_SUFFIX + ): raise ConfigurationError( "OLD_GATEWAY_URL must point to the existing Modal gateway." ) diff --git a/projects/policyengine-simulation-entry/tests/test_app.py b/projects/policyengine-simulation-entry/tests/test_app.py index 424ac3697..6e53b04ba 100644 --- a/projects/policyengine-simulation-entry/tests/test_app.py +++ b/projects/policyengine-simulation-entry/tests/test_app.py @@ -228,6 +228,42 @@ async def request(self, *args, **kwargs): assert result.headers["x-policyengine-simulation-backend"] == "old_gateway" +def test_unexpected_failure_preserves_correlation_headers_and_request_log(caplog): + class FailingBackend(FakeBackend): + async def request(self, *args, **kwargs): + raise RuntimeError("unexpected backend failure") + + app = create_app( + settings=make_settings(), + backend=FailingBackend(), + auth_dependency=lambda: None, + ) + + from fastapi.testclient import TestClient + + with caplog.at_level(logging.INFO), TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"X-Request-ID": "request-500"}, + ) + + assert result.status_code == 500 + assert result.text == "Internal Server Error" + assert result.headers["x-request-id"] == "request-500" + assert ( + result.headers["x-policyengine-simulation-revision"] + == "simulation-entry-test-revision" + ) + request_record = next( + record + for record in caplog.records + if record.getMessage() == "simulation_entry_request" + ) + assert request_record.status_code == 500 + assert request_record.path == "/jobs/{job_id}" + assert request_record.job_id == "job-1" + + @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( diff --git a/projects/policyengine-simulation-entry/tests/test_config.py b/projects/policyengine-simulation-entry/tests/test_config.py index 8820509d0..84416af81 100644 --- a/projects/policyengine-simulation-entry/tests/test_config.py +++ b/projects/policyengine-simulation-entry/tests/test_config.py @@ -17,12 +17,38 @@ def test_partial_caller_auth_is_rejected(): make_settings(auth_audience="").validate() +@pytest.mark.parametrize( + ("setting", "value"), + [ + ("auth_issuer", "http://caller-auth.example/"), + ("auth_issuer", "https://caller-auth.example/?tenant=wrong"), + ("old_gateway_auth_issuer", "http://backend-auth.example/"), + ("old_gateway_auth_issuer", "https://client:secret@backend-auth.example/"), + ], +) +def test_auth_issuers_must_be_safe_https_urls(setting, value): + with pytest.raises(ConfigurationError, match="must"): + make_settings(**{setting: value}).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", + ( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" + "/unexpected-path" + ), + ( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" + "?wrong=path" + ), + ( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" + "#wrong-path" + ), ], ) def test_old_gateway_must_be_the_https_modal_service(old_gateway_url): diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index 4bff5147f..58b96a78a 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -8,6 +8,7 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +GITIGNORE = REPOSITORY_ROOT / ".gitignore" TRAFFIC_SCRIPT = ( REPOSITORY_ROOT / ".github" @@ -265,7 +266,10 @@ def test_traffic_change_refuses_an_intervening_promotion(tmp_path): def test_container_context_excludes_local_environments_and_unrelated_projects(): ignore_rules = DOCKERIGNORE.read_text(encoding="utf-8") + gitignore_rules = GITIGNORE.read_text(encoding="utf-8") + assert "gha-creds-*.json" in ignore_rules + assert "gha-creds-*.json" in gitignore_rules assert "**/.venv" in ignore_rules assert "projects/*" in ignore_rules assert "!projects/policyengine-simulation-entry/**" in ignore_rules From aebfb44c5c76c45c96a2b9bd9dac6951cfe1487b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 28 Jul 2026 15:21:43 +0300 Subject: [PATCH 15/15] Unify simulation entry request IDs --- .../src/policyengine_simulation_entry/app.py | 13 ++++++-- .../tests/test_app.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) 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 134864098..b4c1b4d92 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -12,7 +12,7 @@ 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_observability import REQUEST_ID_HEADER, record_event from policyengine_simulation_contract.gateway_models import ( BudgetWindowBatchRequest, BudgetWindowBatchStatusResponse, @@ -32,6 +32,7 @@ configure_process_observability, init_simulation_observability, ) +from starlette.datastructures import MutableHeaders from policyengine_simulation_entry.auth import CallerAuthenticator from policyengine_simulation_entry.backend import ( @@ -128,7 +129,15 @@ async def lifespan(_: FastAPI): @app.middleware("http") async def request_context(request: Request, call_next): - request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) + headers = MutableHeaders(scope=request.scope) + request_id = ( + headers.get("x-request-id") + or headers.get(REQUEST_ID_HEADER) + or str(uuid.uuid4()) + ) + headers["x-request-id"] = request_id + # policyengine-observability currently reads its legacy header name. + headers[REQUEST_ID_HEADER] = request_id request.state.request_id = request_id started = time.monotonic() try: diff --git a/projects/policyengine-simulation-entry/tests/test_app.py b/projects/policyengine-simulation-entry/tests/test_app.py index 6e53b04ba..7c6190902 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_observability import REQUEST_ID_HEADER, current_context from policyengine_simulation_contract.json_types import JsonObject from policyengine_simulation_entry import app as app_module @@ -190,6 +191,36 @@ def test_request_id_is_propagated_and_returned(client, backend): assert backend.requests[-1].request_id == "request-123" +def test_generated_request_id_is_shared_with_observability(): + class CapturingBackend(FakeBackend): + observability_request_id: str | None = None + + async def request(self, *args, **kwargs): + context = current_context() + self.observability_request_id = ( + context.request_id if context is not None else None + ) + return await super().request(*args, **kwargs) + + backend = CapturingBackend() + app = create_app( + settings=make_settings(), + backend=backend, + auth_dependency=lambda: None, + ) + + from fastapi.testclient import TestClient + + with TestClient(app) as client: + result = client.get("/versions") + + request_id = result.headers["x-request-id"] + assert request_id + assert result.headers[REQUEST_ID_HEADER] == request_id + assert backend.requests[-1].request_id == request_id + assert backend.observability_request_id == request_id + + def test_request_validation_matches_shared_contract(client): result = client.post( "/simulate/economy/comparison",