diff --git a/.github/request-simulation-model-versions.sh b/.github/request-simulation-model-versions.sh index 1bf77b142..454574745 100755 --- a/.github/request-simulation-model-versions.sh +++ b/.github/request-simulation-model-versions.sh @@ -8,7 +8,7 @@ set -euo pipefail # versions are optional compatibility checks that should resolve to the same # gateway app as the .py bundle route. -GATEWAY_URL="${SIMULATION_API_URL:-https://policyengine--policyengine-simulation-gateway-web-app.modal.run}" +GATEWAY_URL="${SIMULATION_ENTRYPOINT_URL:-https://policyengine--policyengine-simulation-gateway-web-app.modal.run}" usage() { echo "Usage: $0 -py [-us ] [-uk ]" diff --git a/.github/scripts/deploy_cloud_run_candidate.sh b/.github/scripts/deploy_cloud_run_candidate.sh index c06373987..7ec0a5069 100755 --- a/.github/scripts/deploy_cloud_run_candidate.sh +++ b/.github/scripts/deploy_cloud_run_candidate.sh @@ -11,14 +11,15 @@ env_vars=( "POLICYENGINE_DB_INSTANCE_CONNECTION_NAME=${CLOUD_RUN_CLOUD_SQL_INSTANCE}" "POLICYENGINE_DB_USER=${POLICYENGINE_DB_USER:-policyengine}" "POLICYENGINE_DB_NAME=${POLICYENGINE_DB_NAME:-policyengine}" - "SIMULATION_API_URL=${SIMULATION_API_URL}" + "SIMULATION_ENTRYPOINT_URL=${SIMULATION_ENTRYPOINT_URL}" + "OLD_SIMULATION_GATEWAY_URL=${OLD_SIMULATION_GATEWAY_URL}" "GATEWAY_AUTH_REQUIRED=1" "GATEWAY_AUTH_ISSUER=${GATEWAY_AUTH_ISSUER}" "GATEWAY_AUTH_AUDIENCE=${GATEWAY_AUTH_AUDIENCE}" "GATEWAY_AUTH_CLIENT_ID=${GATEWAY_AUTH_CLIENT_ID}" "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE=${GATEWAY_AUTH_CLIENT_SECRET_RESOURCE}" "API_HOST_BACKEND=cloud_run" - "SIM_FRONT_DOOR=old_gateway_direct" + "SIM_ENTRYPOINT=${SIM_ENTRYPOINT:-old_gateway_direct}" "SIM_COMPUTE_ECONOMY=old_gateway" "CLOUD_RUN_REVISION_TAG=${CLOUD_RUN_TAG}" "WEB_CONCURRENCY=${CLOUD_RUN_WEB_CONCURRENCY}" diff --git a/.github/scripts/ramp_simulation_entrypoint.sh b/.github/scripts/ramp_simulation_entrypoint.sh new file mode 100644 index 000000000..c22ccaa16 --- /dev/null +++ b/.github/scripts/ramp_simulation_entrypoint.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Operator-run production command. This script is intentionally not called by a +# GitHub Actions workflow: traffic percentages are changed manually after the +# corresponding observation gate has been reviewed. + +set -euo pipefail + +source .github/scripts/cloud_run_env.sh +cloud_run_set_defaults + +new_revision="${SIMULATION_NEW_ENTRYPOINT_REVISION:?SIMULATION_NEW_ENTRYPOINT_REVISION is required}" +direct_revision="${SIMULATION_DIRECT_GATEWAY_REVISION:?SIMULATION_DIRECT_GATEWAY_REVISION is required}" +new_percent="${SIMULATION_NEW_ENTRYPOINT_PERCENT:?SIMULATION_NEW_ENTRYPOINT_PERCENT is required}" + +case "${new_percent}" in + 0|5|25|50|100) ;; + *) + echo "SIMULATION_NEW_ENTRYPOINT_PERCENT must be one of 0, 5, 25, 50, or 100" >&2 + exit 1 + ;; +esac + +if [ "${new_revision}" = "${direct_revision}" ]; then + echo "New-entrypoint and direct-gateway revisions must be different" >&2 + exit 1 +fi + +validate_revision_entrypoint() { + local revision="${1:?revision is required}" + local expected="${2:?expected entrypoint is required}" + + if [[ "${CLOUD_RUN_DRY_RUN:-0}" == "1" ]]; then + cloud_run_run gcloud run revisions describe "${revision}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --format=json + return + fi + + local revision_json actual + revision_json="$(gcloud run revisions describe "${revision}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --format=json)" + actual="$(jq -er '.spec.containers[0].env[] | select(.name == "SIM_ENTRYPOINT") | .value' <<<"${revision_json}")" + if [[ "${actual}" != "${expected}" ]]; then + echo "Revision ${revision} has SIM_ENTRYPOINT=${actual}; expected ${expected}" >&2 + exit 1 + fi +} + +validate_revision_entrypoint "${new_revision}" cloud_run_simulation_entrypoint +validate_revision_entrypoint "${direct_revision}" old_gateway_direct + +if [ "${new_percent}" = "0" ]; then + traffic="${direct_revision}=100" +elif [ "${new_percent}" = "100" ]; then + traffic="${new_revision}=100" +else + direct_percent=$((100 - new_percent)) + traffic="${new_revision}=${new_percent},${direct_revision}=${direct_percent}" +fi + +cloud_run_run gcloud run services update-traffic "${CLOUD_RUN_SERVICE}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --to-revisions "${traffic}" diff --git a/.github/scripts/validate_app_engine_deploy_env.sh b/.github/scripts/validate_app_engine_deploy_env.sh index 8dfc7941d..fa49b8e0f 100644 --- a/.github/scripts/validate_app_engine_deploy_env.sh +++ b/.github/scripts/validate_app_engine_deploy_env.sh @@ -3,7 +3,7 @@ set -euo pipefail required=( - SIMULATION_API_URL + SIMULATION_ENTRYPOINT_URL GATEWAY_AUTH_ISSUER GATEWAY_AUTH_AUDIENCE GATEWAY_AUTH_CLIENT_ID diff --git a/.github/scripts/validate_cloud_run_deploy_env.sh b/.github/scripts/validate_cloud_run_deploy_env.sh index 4270bb19e..f69463c85 100755 --- a/.github/scripts/validate_cloud_run_deploy_env.sh +++ b/.github/scripts/validate_cloud_run_deploy_env.sh @@ -28,7 +28,8 @@ cloud_run_require_env \ CLOUD_RUN_ANTHROPIC_API_KEY_SECRET \ CLOUD_RUN_OPENAI_API_KEY_SECRET \ CLOUD_RUN_HUGGING_FACE_TOKEN_SECRET \ - SIMULATION_API_URL \ + SIMULATION_ENTRYPOINT_URL \ + OLD_SIMULATION_GATEWAY_URL \ GATEWAY_AUTH_ISSUER \ GATEWAY_AUTH_AUDIENCE \ GATEWAY_AUTH_CLIENT_ID \ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0d47b8e2f..cb2cc45b5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: run: sudo apt-get install -y jq - name: Check simulation API supports updated PolicyEngine bundle env: - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} run: bash .github/check-policyengine-bundle-supported.sh --if-changed-from-base "${{ github.base_ref }}" lint: diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 47980029c..8daf5acc9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -41,7 +41,7 @@ jobs: run: sudo apt-get install -y jq - name: Check simulation API supports PolicyEngine bundle env: - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} run: bash .github/check-policyengine-bundle-supported.sh versioning: @@ -151,7 +151,7 @@ jobs: # Transitional: these values are still passed into the deploy bundle # by gcp/export.py. Long-term target is a generic image plus runtime # config / Secret Manager lookups instead of image bake-in. - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} @@ -168,7 +168,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} @@ -186,7 +186,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} @@ -252,7 +252,9 @@ jobs: CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT: ${{ secrets.GCP_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT }} POLICYENGINE_DB_USER: ${{ vars.POLICYENGINE_DB_USER }} POLICYENGINE_DB_NAME: ${{ vars.POLICYENGINE_DB_NAME }} - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT || 'old_gateway_direct' }} GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} @@ -401,7 +403,7 @@ jobs: run: sudo apt-get install -y jq - name: Check simulation API supports PolicyEngine bundle env: - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} run: bash .github/check-policyengine-bundle-supported.sh deploy-production-candidate: @@ -442,7 +444,7 @@ jobs: # Transitional: these values are still passed into the deploy bundle # by gcp/export.py. Long-term target is a generic image plus runtime # config / Secret Manager lookups instead of image bake-in. - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} @@ -460,7 +462,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} @@ -572,7 +574,9 @@ jobs: CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT: ${{ secrets.GCP_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT }} POLICYENGINE_DB_USER: ${{ vars.POLICYENGINE_DB_USER }} POLICYENGINE_DB_NAME: ${{ vars.POLICYENGINE_DB_NAME }} - SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT || 'old_gateway_direct' }} GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} @@ -595,6 +599,7 @@ jobs: API_BASE_URL: ${{ steps.cloud_run_url.outputs.url }} STAGING_API_TEST_PROBE_ID: cloud-run-${{ steps.cloud_run.outputs.revision_tag }} - name: Promote Cloud Run production candidate + if: ${{ vars.SIM_ENTRYPOINT != 'cloud_run_simulation_entrypoint' }} run: bash .github/scripts/promote_cloud_run_tag.sh env: CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} diff --git a/README.md b/README.md index 5aa1d2477..0f59990e4 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Keep that commented unless you are pointing at a real local credential file. The If you are running against an auth-protected simulation gateway outside the managed deploy path, you may also need: -- `SIMULATION_API_URL` +- `SIMULATION_ENTRYPOINT_URL` - `GATEWAY_AUTH_REQUIRED` - `GATEWAY_AUTH_ISSUER` - `GATEWAY_AUTH_AUDIENCE` diff --git a/docs/migration/history/migration-pr1-baseline-runbook.md b/docs/migration/history/migration-pr1-baseline-runbook.md index 11dfa6674..c6d94eb2a 100644 --- a/docs/migration/history/migration-pr1-baseline-runbook.md +++ b/docs/migration/history/migration-pr1-baseline-runbook.md @@ -38,7 +38,7 @@ representative economy-comparison payload: ```bash API_BASE_URL=https://example-dot-policyengine-api.appspot.com \ -SIMULATION_API_URL=https://policyengine--policyengine-simulation-gateway-web-app.modal.run \ +SIMULATION_ENTRYPOINT_URL=https://policyengine--policyengine-simulation-gateway-web-app.modal.run \ SIMULATION_PAYLOAD_FILE=/path/to/representative-simulation-payload.json \ python scripts/capture_migration_baseline.py --repetitions 5 ``` diff --git a/gcp/export.py b/gcp/export.py index aaaac0578..5bc0907a5 100644 --- a/gcp/export.py +++ b/gcp/export.py @@ -5,7 +5,7 @@ ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"] OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] HUGGING_FACE_TOKEN = os.environ["HUGGING_FACE_TOKEN"] -SIMULATION_API_URL = os.environ["SIMULATION_API_URL"] +SIMULATION_ENTRYPOINT_URL = os.environ["SIMULATION_ENTRYPOINT_URL"] GATEWAY_AUTH_ISSUER = os.environ["GATEWAY_AUTH_ISSUER"] GATEWAY_AUTH_AUDIENCE = os.environ["GATEWAY_AUTH_AUDIENCE"] GATEWAY_AUTH_CLIENT_ID = os.environ["GATEWAY_AUTH_CLIENT_ID"] @@ -28,7 +28,9 @@ dockerfile = dockerfile.replace(".anthropic_api_key", ANTHROPIC_API_KEY) dockerfile = dockerfile.replace(".openai_api_key", OPENAI_API_KEY) dockerfile = dockerfile.replace(".hugging_face_token", HUGGING_FACE_TOKEN) - dockerfile = dockerfile.replace(".simulation_api_url", SIMULATION_API_URL) + dockerfile = dockerfile.replace( + ".simulation_entrypoint_url", SIMULATION_ENTRYPOINT_URL + ) dockerfile = dockerfile.replace(".gateway_auth_issuer", GATEWAY_AUTH_ISSUER) dockerfile = dockerfile.replace(".gateway_auth_audience", GATEWAY_AUTH_AUDIENCE) dockerfile = dockerfile.replace( diff --git a/gcp/policyengine_api/Dockerfile b/gcp/policyengine_api/Dockerfile index b3f74d0cf..c92b684ba 100644 --- a/gcp/policyengine_api/Dockerfile +++ b/gcp/policyengine_api/Dockerfile @@ -7,7 +7,7 @@ ENV POLICYENGINE_GITHUB_MICRODATA_AUTH_TOKEN .github_microdata_token ENV ANTHROPIC_API_KEY .anthropic_api_key ENV OPENAI_API_KEY .openai_api_key ENV HUGGING_FACE_TOKEN .hugging_face_token -ENV SIMULATION_API_URL .simulation_api_url +ENV SIMULATION_ENTRYPOINT_URL .simulation_entrypoint_url ENV CREDENTIALS_JSON_API_V2 .credentials_json_api_v2 ENV GATEWAY_AUTH_REQUIRED 1 ENV GATEWAY_AUTH_ISSUER .gateway_auth_issuer diff --git a/policyengine_api/asgi_factory.py b/policyengine_api/asgi_factory.py index 7eed9975e..f062363f7 100644 --- a/policyengine_api/asgi_factory.py +++ b/policyengine_api/asgi_factory.py @@ -94,10 +94,12 @@ def health() -> HealthResponse: include_in_schema=False, ) def simulation_gateway_health() -> SimulationGatewayHealthResponse: - from policyengine_api.libs.simulation_api_modal import SimulationAPIModal + from policyengine_api.libs.simulation_entrypoint import ( + SimulationEntrypointClient, + ) try: - gateway_healthy = SimulationAPIModal().health_check() + gateway_healthy = SimulationEntrypointClient().health_check() except Exception as error: raise HTTPException( status_code=503, diff --git a/policyengine_api/libs/simulation_api.py b/policyengine_api/libs/simulation_api.py new file mode 100644 index 000000000..8d95ceee1 --- /dev/null +++ b/policyengine_api/libs/simulation_api.py @@ -0,0 +1,30 @@ +"""Compatibility exports for the Simulation Entrypoint client. + +New code should import from :mod:`policyengine_api.libs.simulation_entrypoint`. +""" + +from policyengine_api.libs.simulation_entrypoint import ( + ModalBudgetWindowBatchExecution, + ModalSimulationExecution, + SimulationAPIClient, + SimulationEntrypointClient, + SimulationAPIModal, + resolve_simulation_api_url, + resolve_simulation_entrypoint_url, + simulation_api, + simulation_api_modal, + simulation_entrypoint, +) + +__all__ = [ + "ModalBudgetWindowBatchExecution", + "ModalSimulationExecution", + "SimulationAPIClient", + "SimulationEntrypointClient", + "SimulationAPIModal", + "resolve_simulation_api_url", + "resolve_simulation_entrypoint_url", + "simulation_api", + "simulation_api_modal", + "simulation_entrypoint", +] diff --git a/policyengine_api/libs/simulation_api_modal.py b/policyengine_api/libs/simulation_api_modal.py index ba996fba4..3632c610b 100644 --- a/policyengine_api/libs/simulation_api_modal.py +++ b/policyengine_api/libs/simulation_api_modal.py @@ -1,422 +1,27 @@ -""" -HTTP client for the Modal Simulation API. - -This module provides a client for submitting simulation jobs to the -Modal-based simulation API and polling for results. -""" - -import os -import sys -from dataclasses import dataclass, field -from typing import Optional - -import httpx -from policyengine_api.gcp_logging import logger -from policyengine_api.libs.gateway_auth import ( - GatewayAuthError, - GatewayAuthTokenProvider, - GatewayBearerAuth, - _require_all_or_none_gateway_auth_env, - gateway_auth_required, +"""Legacy import shim for the canonical Simulation Entrypoint client.""" + +from policyengine_api.libs.simulation_entrypoint import ( + ModalBudgetWindowBatchExecution, + ModalSimulationExecution, + SimulationAPIClient, + SimulationEntrypointClient, + SimulationAPIModal, + resolve_simulation_api_url, + resolve_simulation_entrypoint_url, + simulation_api, + simulation_api_modal, + simulation_entrypoint, ) - -@dataclass -class ModalSimulationExecution: - """ - Represents a Modal simulation job execution. - """ - - job_id: str - status: str - run_id: Optional[str] = None - result: Optional[dict] = None - error: Optional[str] = None - policyengine_bundle: Optional[dict] = None - resolved_app_name: Optional[str] = None - - @property - def name(self) -> str: - """Alias for job_id.""" - return self.job_id - - -@dataclass -class ModalBudgetWindowBatchExecution: - """ - Represents a budget-window batch execution in the Modal simulation API. - """ - - batch_job_id: str - status: str - progress: Optional[int] = None - completed_years: list[str] = field(default_factory=list) - running_years: list[str] = field(default_factory=list) - queued_years: list[str] = field(default_factory=list) - failed_years: list[str] = field(default_factory=list) - result: Optional[dict] = None - error: Optional[str] = None - - @property - def name(self) -> str: - """Alias for batch_job_id.""" - return self.batch_job_id - - -class SimulationAPIModal: - """ - HTTP client for the Modal Simulation API. - - This class provides methods for submitting simulation jobs and - polling for their status/results via HTTP endpoints. - """ - - def __init__(self): - self.base_url = os.environ.get( - "SIMULATION_API_URL", - "https://policyengine--policyengine-simulation-gateway-web-app.modal.run", - ) - self._token_provider = GatewayAuthTokenProvider() - _require_all_or_none_gateway_auth_env() - auth = ( - GatewayBearerAuth(self._token_provider) - if self._token_provider.configured - else None - ) - if auth is None: - if gateway_auth_required(): - raise GatewayAuthError( - "Gateway auth is required in this runtime: set " - "GATEWAY_AUTH_ISSUER, GATEWAY_AUTH_AUDIENCE, " - "GATEWAY_AUTH_CLIENT_ID, and " - "GATEWAY_AUTH_CLIENT_SECRET." - ) - print( - "SimulationAPIModal initialized without gateway auth; " - "all GATEWAY_AUTH_* env vars are unset and " - "GATEWAY_AUTH_REQUIRED is not enabled.", - file=sys.stderr, - flush=True, - ) - self.client = httpx.Client(timeout=30.0, auth=auth) - - def _normalize_submission_payload(self, payload: dict) -> dict: - modal_payload = { - key: value for key, value in payload.items() if value is not None - } - if "model_version" in modal_payload: - modal_payload["version"] = modal_payload.pop("model_version") - modal_payload.pop("data_version", None) - return modal_payload - - def run(self, payload: dict) -> ModalSimulationExecution: - """ - Submit a simulation job to the Modal API. - - Parameters - ---------- - payload : dict - The simulation parameters (country, reform, baseline, etc.) - Expected to match the simulation gateway submission schema. - - Returns - ------- - ModalSimulationExecution - Execution object with job_id and initial status. - - Raises - ------ - httpx.HTTPStatusError - If the API returns an error response. - """ - try: - modal_payload = self._normalize_submission_payload(payload) - - response = self.client.post( - f"{self.base_url}/simulate/economy/comparison", - json=modal_payload, - ) - response.raise_for_status() - data = response.json() - - logger.log_struct( - { - "message": "Modal simulation job submitted", - "job_id": data.get("job_id"), - "run_id": data.get("run_id"), - "status": data.get("status"), - }, - severity="INFO", - ) - - return ModalSimulationExecution( - job_id=data["job_id"], - status=data["status"], - policyengine_bundle=data.get("policyengine_bundle"), - resolved_app_name=data.get("resolved_app_name"), - run_id=data.get("run_id"), - ) - - except httpx.HTTPStatusError as e: - logger.log_struct( - { - "message": f"Modal API HTTP error: {e.response.status_code}", - "run_id": (payload.get("_telemetry") or {}).get("run_id"), - "response_text": e.response.text[:500], - }, - severity="ERROR", - ) - raise - - except httpx.RequestError as e: - logger.log_struct( - { - "message": f"Modal API request error: {str(e)}", - "run_id": (payload.get("_telemetry") or {}).get("run_id"), - }, - severity="ERROR", - ) - raise - - def run_budget_window_batch(self, payload: dict) -> ModalBudgetWindowBatchExecution: - """ - Submit a budget-window batch job to the Modal API. - """ - try: - modal_payload = self._normalize_submission_payload(payload) - - response = self.client.post( - f"{self.base_url}/simulate/economy/budget-window", - json=modal_payload, - ) - response.raise_for_status() - data = response.json() - - logger.log_struct( - { - "message": "Modal budget-window batch submitted", - "batch_job_id": data.get("batch_job_id"), - "status": data.get("status"), - }, - severity="INFO", - ) - - return ModalBudgetWindowBatchExecution( - batch_job_id=data["batch_job_id"], - status=data["status"], - ) - - except httpx.HTTPStatusError as e: - logger.log_struct( - { - "message": f"Modal batch API HTTP error: {e.response.status_code}", - "response_text": e.response.text[:500], - }, - severity="ERROR", - ) - raise - - except httpx.RequestError as e: - logger.log_struct( - { - "message": f"Modal batch API request error: {str(e)}", - "run_id": (payload.get("_telemetry") or {}).get("run_id"), - }, - severity="ERROR", - ) - raise - - def resolve_app_name( - self, - country: str, - version: Optional[str] = None, - policyengine_version: Optional[str] = None, - ) -> tuple[str, str]: - """Resolve the current gateway app name for a country/model version.""" - if policyengine_version is not None: - response = self.client.get(f"{self.base_url}/versions/policyengine") - response.raise_for_status() - policyengine_version_map = response.json() - try: - return policyengine_version_map[policyengine_version], ( - version or policyengine_version - ) - except KeyError as exc: - raise ValueError( - f"Unknown policyengine version {policyengine_version}" - ) from exc - - response = self.client.get(f"{self.base_url}/versions/{country}") - response.raise_for_status() - version_map = response.json() - - resolved_version = version or version_map["latest"] - try: - return version_map[resolved_version], resolved_version - except KeyError as exc: - raise ValueError( - f"Unknown version {resolved_version} for country {country}" - ) from exc - - def get_execution_id(self, execution: ModalSimulationExecution) -> str: - """ - Get the job ID from an execution. - - Parameters - ---------- - execution : ModalSimulationExecution - The execution object returned from run(). - - Returns - ------- - str - The job ID. - """ - return execution.job_id - - def get_execution_by_id(self, job_id: str) -> ModalSimulationExecution: - """ - Poll the Modal API for the current status of a job. - - Parameters - ---------- - job_id : str - The job ID returned from run(). - - Returns - ------- - ModalSimulationExecution - Execution object with current status and result if complete. - """ - try: - response = self.client.get(f"{self.base_url}/jobs/{job_id}") - if response.status_code not in (200, 202, 500): - response.raise_for_status() - data = response.json() - - return ModalSimulationExecution( - job_id=job_id, - status=data["status"], - run_id=data.get("run_id"), - result=data.get("result"), - error=data.get("error"), - policyengine_bundle=data.get("policyengine_bundle"), - resolved_app_name=data.get("resolved_app_name"), - ) - - except httpx.HTTPStatusError as e: - logger.log_struct( - { - "message": f"Modal API HTTP error polling job {job_id}: {e.response.status_code}", - "response_text": e.response.text[:500], - }, - severity="ERROR", - ) - raise - - except httpx.RequestError as e: - logger.log_struct( - { - "message": f"Modal API request error polling job {job_id}: {str(e)}", - }, - severity="ERROR", - ) - raise - - def get_budget_window_batch_by_id( - self, batch_job_id: str - ) -> ModalBudgetWindowBatchExecution: - """ - Poll the Modal API for the current status of a budget-window batch. - """ - try: - response = self.client.get( - f"{self.base_url}/budget-window-jobs/{batch_job_id}" - ) - if response.status_code not in (200, 202, 500): - response.raise_for_status() - data = response.json() - - return ModalBudgetWindowBatchExecution( - batch_job_id=batch_job_id, - status=data["status"], - progress=data.get("progress"), - completed_years=data.get("completed_years", []), - running_years=data.get("running_years", []), - queued_years=data.get("queued_years", []), - failed_years=data.get("failed_years", []), - result=data.get("result"), - error=data.get("error"), - ) - - except httpx.HTTPStatusError as e: - logger.log_struct( - { - "message": f"Modal batch API HTTP error polling job {batch_job_id}: {e.response.status_code}", - "response_text": e.response.text[:500], - }, - severity="ERROR", - ) - raise - - except httpx.RequestError as e: - logger.log_struct( - { - "message": f"Modal batch API request error polling job {batch_job_id}: {str(e)}", - }, - severity="ERROR", - ) - raise - - def get_execution_status(self, execution: ModalSimulationExecution) -> str: - """ - Get the status string from an execution. - - Parameters - ---------- - execution : ModalSimulationExecution - The execution object. - - Returns - ------- - str - The status string ("submitted", "running", "complete", "failed"). - """ - return execution.status - - def get_execution_result( - self, execution: ModalSimulationExecution - ) -> Optional[dict]: - """ - Get the result from a completed execution. - - Parameters - ---------- - execution : ModalSimulationExecution - The execution object. - - Returns - ------- - dict or None - The simulation result if complete, None otherwise. - """ - return execution.result - - def health_check(self) -> bool: - """ - Check if the Modal API is healthy. - - Returns - ------- - bool - True if the API is healthy, False otherwise. - """ - try: - response = self.client.get(f"{self.base_url}/health") - return response.status_code == 200 - except Exception: - return False - - -# Global instance for use throughout the application -simulation_api_modal = SimulationAPIModal() +__all__ = [ + "ModalBudgetWindowBatchExecution", + "ModalSimulationExecution", + "SimulationAPIClient", + "SimulationEntrypointClient", + "SimulationAPIModal", + "resolve_simulation_api_url", + "resolve_simulation_entrypoint_url", + "simulation_api", + "simulation_api_modal", + "simulation_entrypoint", +] diff --git a/policyengine_api/libs/simulation_entrypoint.py b/policyengine_api/libs/simulation_entrypoint.py new file mode 100644 index 000000000..42ffdb74b --- /dev/null +++ b/policyengine_api/libs/simulation_entrypoint.py @@ -0,0 +1,456 @@ +"""HTTP client for the configured PolicyEngine Simulation Entrypoint.""" + +import os +import sys +from dataclasses import dataclass, field +from typing import Optional +from urllib.parse import urlparse + +import httpx +from policyengine_api.gcp_logging import logger +from policyengine_api.libs.gateway_auth import ( + GatewayAuthError, + GatewayAuthTokenProvider, + GatewayBearerAuth, + _require_all_or_none_gateway_auth_env, + gateway_auth_required, +) +from policyengine_api.migration_flags import get_sim_entrypoint + + +def _required_base_url(env_name: str) -> str: + value = os.environ.get(env_name, "").strip() + if not value: + raise ValueError(f"{env_name} is required") + + parsed = urlparse(value) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ValueError( + f"{env_name} must be an absolute HTTP(S) base URL without " + "credentials, a query, or a fragment" + ) + return value.rstrip("/") + + +def resolve_simulation_entrypoint_url(entrypoint: str | None = None) -> str: + """Resolve old/new upstream URLs without conflating their configuration.""" + selected_entrypoint = entrypoint or get_sim_entrypoint() + if selected_entrypoint == "old_gateway_direct": + return _required_base_url("OLD_SIMULATION_GATEWAY_URL") + if selected_entrypoint == "cloud_run_simulation_entrypoint": + return _required_base_url("SIMULATION_ENTRYPOINT_URL") + raise ValueError(f"Unsupported simulation entrypoint: {selected_entrypoint!r}") + + +@dataclass +class ModalSimulationExecution: + """ + Represents a Modal simulation job execution. + """ + + job_id: str + status: str + run_id: Optional[str] = None + result: Optional[dict] = None + error: Optional[str] = None + policyengine_bundle: Optional[dict] = None + resolved_app_name: Optional[str] = None + + @property + def name(self) -> str: + """Alias for job_id.""" + return self.job_id + + +@dataclass +class ModalBudgetWindowBatchExecution: + """ + Represents a budget-window batch execution in the Simulation Entrypoint. + """ + + batch_job_id: str + status: str + progress: Optional[int] = None + completed_years: list[str] = field(default_factory=list) + running_years: list[str] = field(default_factory=list) + queued_years: list[str] = field(default_factory=list) + failed_years: list[str] = field(default_factory=list) + result: Optional[dict] = None + error: Optional[str] = None + + @property + def name(self) -> str: + """Alias for batch_job_id.""" + return self.batch_job_id + + +class SimulationEntrypointClient: + """ + HTTP client for the configured Simulation Entrypoint. + + This class provides methods for submitting simulation jobs and + polling for their status/results via HTTP endpoints. + """ + + def __init__(self, entrypoint: str | None = None): + self.entrypoint = entrypoint or get_sim_entrypoint() + self.base_url = resolve_simulation_entrypoint_url(self.entrypoint) + self._token_provider = GatewayAuthTokenProvider() + _require_all_or_none_gateway_auth_env() + auth = ( + GatewayBearerAuth(self._token_provider) + if self._token_provider.configured + else None + ) + if auth is None: + if gateway_auth_required(): + raise GatewayAuthError( + "Gateway auth is required in this runtime: set " + "GATEWAY_AUTH_ISSUER, GATEWAY_AUTH_AUDIENCE, " + "GATEWAY_AUTH_CLIENT_ID, and " + "GATEWAY_AUTH_CLIENT_SECRET." + ) + print( + "SimulationEntrypointClient initialized without gateway auth; " + "all GATEWAY_AUTH_* env vars are unset and " + "GATEWAY_AUTH_REQUIRED is not enabled.", + file=sys.stderr, + flush=True, + ) + self.client = httpx.Client(timeout=30.0, auth=auth) + + def _normalize_submission_payload(self, payload: dict) -> dict: + modal_payload = { + key: value for key, value in payload.items() if value is not None + } + if "model_version" in modal_payload: + modal_payload["version"] = modal_payload.pop("model_version") + modal_payload.pop("data_version", None) + return modal_payload + + def run(self, payload: dict) -> ModalSimulationExecution: + """ + Submit a simulation job to the selected Simulation entrypoint. + + Parameters + ---------- + payload : dict + The simulation parameters (country, reform, baseline, etc.) + Expected to match the simulation gateway submission schema. + + Returns + ------- + ModalSimulationExecution + Execution object with job_id and initial status. + + Raises + ------ + httpx.HTTPStatusError + If the API returns an error response. + """ + try: + modal_payload = self._normalize_submission_payload(payload) + + response = self.client.post( + f"{self.base_url}/simulate/economy/comparison", + json=modal_payload, + ) + response.raise_for_status() + data = response.json() + + logger.log_struct( + { + "message": "Simulation entrypoint job submitted", + "job_id": data.get("job_id"), + "run_id": data.get("run_id"), + "status": data.get("status"), + }, + severity="INFO", + ) + + return ModalSimulationExecution( + job_id=data["job_id"], + status=data["status"], + policyengine_bundle=data.get("policyengine_bundle"), + resolved_app_name=data.get("resolved_app_name"), + run_id=data.get("run_id"), + ) + + except httpx.HTTPStatusError as e: + logger.log_struct( + { + "message": f"Simulation entrypoint HTTP error: {e.response.status_code}", + "run_id": (payload.get("_telemetry") or {}).get("run_id"), + "response_text": e.response.text[:500], + }, + severity="ERROR", + ) + raise + + except httpx.RequestError as e: + logger.log_struct( + { + "message": f"Simulation entrypoint request error: {str(e)}", + "run_id": (payload.get("_telemetry") or {}).get("run_id"), + }, + severity="ERROR", + ) + raise + + def run_budget_window_batch(self, payload: dict) -> ModalBudgetWindowBatchExecution: + """ + Submit a budget-window batch job to the selected Simulation entrypoint. + """ + try: + modal_payload = self._normalize_submission_payload(payload) + + response = self.client.post( + f"{self.base_url}/simulate/economy/budget-window", + json=modal_payload, + ) + response.raise_for_status() + data = response.json() + + logger.log_struct( + { + "message": "Modal budget-window batch submitted", + "batch_job_id": data.get("batch_job_id"), + "status": data.get("status"), + }, + severity="INFO", + ) + + return ModalBudgetWindowBatchExecution( + batch_job_id=data["batch_job_id"], + status=data["status"], + ) + + except httpx.HTTPStatusError as e: + logger.log_struct( + { + "message": f"Simulation batch API HTTP error: {e.response.status_code}", + "response_text": e.response.text[:500], + }, + severity="ERROR", + ) + raise + + except httpx.RequestError as e: + logger.log_struct( + { + "message": f"Simulation batch API request error: {str(e)}", + "run_id": (payload.get("_telemetry") or {}).get("run_id"), + }, + severity="ERROR", + ) + raise + + def resolve_app_name( + self, + country: str, + version: Optional[str] = None, + policyengine_version: Optional[str] = None, + ) -> tuple[str, str]: + """Resolve the current gateway app name for a country/model version.""" + if policyengine_version is not None: + response = self.client.get(f"{self.base_url}/versions/policyengine") + response.raise_for_status() + policyengine_version_map = response.json() + try: + return policyengine_version_map[policyengine_version], ( + version or policyengine_version + ) + except KeyError as exc: + raise ValueError( + f"Unknown policyengine version {policyengine_version}" + ) from exc + + response = self.client.get(f"{self.base_url}/versions/{country}") + response.raise_for_status() + version_map = response.json() + + resolved_version = version or version_map["latest"] + try: + return version_map[resolved_version], resolved_version + except KeyError as exc: + raise ValueError( + f"Unknown version {resolved_version} for country {country}" + ) from exc + + def get_execution_id(self, execution: ModalSimulationExecution) -> str: + """ + Get the job ID from an execution. + + Parameters + ---------- + execution : ModalSimulationExecution + The execution object returned from run(). + + Returns + ------- + str + The job ID. + """ + return execution.job_id + + def get_execution_by_id(self, job_id: str) -> ModalSimulationExecution: + """ + Poll the selected Simulation entrypoint for the current status of a job. + + Parameters + ---------- + job_id : str + The job ID returned from run(). + + Returns + ------- + ModalSimulationExecution + Execution object with current status and result if complete. + """ + try: + response = self.client.get(f"{self.base_url}/jobs/{job_id}") + if response.status_code not in (200, 202, 500): + response.raise_for_status() + data = response.json() + + return ModalSimulationExecution( + job_id=job_id, + status=data["status"], + run_id=data.get("run_id"), + result=data.get("result"), + error=data.get("error"), + policyengine_bundle=data.get("policyengine_bundle"), + resolved_app_name=data.get("resolved_app_name"), + ) + + except httpx.HTTPStatusError as e: + logger.log_struct( + { + "message": f"Simulation entrypoint HTTP error polling job {job_id}: {e.response.status_code}", + "response_text": e.response.text[:500], + }, + severity="ERROR", + ) + raise + + except httpx.RequestError as e: + logger.log_struct( + { + "message": f"Simulation entrypoint request error polling job {job_id}: {str(e)}", + }, + severity="ERROR", + ) + raise + + def get_budget_window_batch_by_id( + self, batch_job_id: str + ) -> ModalBudgetWindowBatchExecution: + """ + Poll the selected Simulation entrypoint for a budget-window batch. + """ + try: + response = self.client.get( + f"{self.base_url}/budget-window-jobs/{batch_job_id}" + ) + if response.status_code not in (200, 202, 500): + response.raise_for_status() + data = response.json() + + return ModalBudgetWindowBatchExecution( + batch_job_id=batch_job_id, + status=data["status"], + progress=data.get("progress"), + completed_years=data.get("completed_years", []), + running_years=data.get("running_years", []), + queued_years=data.get("queued_years", []), + failed_years=data.get("failed_years", []), + result=data.get("result"), + error=data.get("error"), + ) + + except httpx.HTTPStatusError as e: + logger.log_struct( + { + "message": f"Simulation batch API HTTP error polling job {batch_job_id}: {e.response.status_code}", + "response_text": e.response.text[:500], + }, + severity="ERROR", + ) + raise + + except httpx.RequestError as e: + logger.log_struct( + { + "message": f"Simulation batch API request error polling job {batch_job_id}: {str(e)}", + }, + severity="ERROR", + ) + raise + + def get_execution_status(self, execution: ModalSimulationExecution) -> str: + """ + Get the status string from an execution. + + Parameters + ---------- + execution : ModalSimulationExecution + The execution object. + + Returns + ------- + str + The status string ("submitted", "running", "complete", "failed"). + """ + return execution.status + + def get_execution_result( + self, execution: ModalSimulationExecution + ) -> Optional[dict]: + """ + Get the result from a completed execution. + + Parameters + ---------- + execution : ModalSimulationExecution + The execution object. + + Returns + ------- + dict or None + The simulation result if complete, None otherwise. + """ + return execution.result + + def health_check(self) -> bool: + """ + Check if the selected Simulation entrypoint is healthy. + + Returns + ------- + bool + True if the API is healthy, False otherwise. + """ + try: + response = self.client.get(f"{self.base_url}/health") + return response.status_code == 200 + except Exception: + return False + + +# Compatibility aliases preserve existing imports and generated-client consumers. +SimulationAPIClient = SimulationEntrypointClient +SimulationAPIModal = SimulationEntrypointClient +resolve_simulation_api_url = resolve_simulation_entrypoint_url + +# All compatibility names intentionally reference one client so a process cannot +# split traffic accidentally. +simulation_entrypoint = SimulationEntrypointClient() +simulation_api = simulation_entrypoint +simulation_api_modal = simulation_entrypoint diff --git a/policyengine_api/migration_flags.py b/policyengine_api/migration_flags.py index c270fa0e8..5a8e746f3 100644 --- a/policyengine_api/migration_flags.py +++ b/policyengine_api/migration_flags.py @@ -19,7 +19,7 @@ ROUTE_IMPLEMENTATIONS = frozenset({"flask_fallback", "fastapi_native"}) DB_WRITE_SOURCES = frozenset({"cloud_sql", "dual_write", "supabase"}) DB_READ_SOURCES = frozenset({"cloud_sql", "read_compare", "supabase"}) -SIM_FRONT_DOORS = frozenset({"old_gateway_direct", "cloud_run_facade"}) +SIM_ENTRYPOINTS = frozenset({"old_gateway_direct", "cloud_run_simulation_entrypoint"}) SIM_COMPUTE_BACKENDS = frozenset( {"old_gateway", "v2_shadow", "v2_percent", "v2_primary"} ) @@ -27,7 +27,7 @@ DEFAULT_API_HOST_BACKEND = "app_engine" DEFAULT_ROUTE_IMPLEMENTATION = "flask_fallback" DEFAULT_DB_SOURCE = "cloud_sql" -DEFAULT_SIM_FRONT_DOOR = "old_gateway_direct" +DEFAULT_SIM_ENTRYPOINT = "old_gateway_direct" DEFAULT_SIM_COMPUTE_BACKEND = "old_gateway" BACKEND_RESPONSE_HEADER = "X-PolicyEngine-Backend" @@ -42,7 +42,7 @@ class MigrationContext: db_write: str | None db_read: str | None sim_flow: str | None - sim_front_door: str + sim_entrypoint: str sim_compute: str | None def to_log_dict(self) -> dict: @@ -54,7 +54,7 @@ def to_log_dict(self) -> dict: "db_write": self.db_write, "db_read": self.db_read, "sim_flow": self.sim_flow, - "sim_front_door": self.sim_front_door, + "sim_entrypoint": self.sim_entrypoint, "sim_compute": self.sim_compute, } @@ -135,6 +135,15 @@ def get_sim_compute(flow: str) -> str: ) +def get_sim_entrypoint() -> str: + """Return the configured API v1-to-simulation-service entrypoint.""" + return _read_choice( + "SIM_ENTRYPOINT", + DEFAULT_SIM_ENTRYPOINT, + SIM_ENTRYPOINTS, + ) + + def get_migration_context( route_group: str, *, @@ -160,11 +169,7 @@ def get_migration_context( db_write=get_db_write(db_entity) if db_entity else None, db_read=get_db_read(db_entity) if db_entity else None, sim_flow=sim_flow, - sim_front_door=_read_choice( - "SIM_FRONT_DOOR", - DEFAULT_SIM_FRONT_DOOR, - SIM_FRONT_DOORS, - ), + sim_entrypoint=get_sim_entrypoint(), sim_compute=get_sim_compute(sim_flow) if sim_flow else None, ) diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index 9142d3c2f..d896d6d01 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -24,7 +24,7 @@ ) from policyengine_api.data.places import validate_place_code from policyengine_api.gcp_logging import logger -from policyengine_api.libs.simulation_api_modal import simulation_api_modal +from policyengine_api.libs.simulation_entrypoint import simulation_entrypoint from policyengine_api.services.budget_window_cache import BudgetWindowCache from policyengine_api.services.policy_service import PolicyService from policyengine_api.services.reform_impacts_service import ( @@ -37,7 +37,6 @@ policy_service = PolicyService() reform_impacts_service = ReformImpactsService() -simulation_api = simulation_api_modal budget_window_cache = BudgetWindowCache() @@ -458,7 +457,7 @@ def _start_budget_window_batch( severity="INFO", ) - return simulation_api.run_budget_window_batch(sim_params) + return simulation_entrypoint.run_budget_window_batch(sim_params) def _build_budget_window_submission_error_message( self, error: httpx.HTTPStatusError @@ -489,7 +488,9 @@ def _get_budget_window_result_from_batch_job_id( queued_years_on_submit: list[str], cache_status: Optional[str] = None, ) -> BudgetWindowEconomicImpactResult: - batch_execution = simulation_api.get_budget_window_batch_by_id(batch_job_id) + batch_execution = simulation_entrypoint.get_budget_window_batch_by_id( + batch_job_id + ) if batch_execution.status in EXECUTION_STATUSES_SUCCESS: result = batch_execution.result @@ -690,7 +691,7 @@ def _resolve_runtime_bundle_for_setup_options( ( setup_options.runtime_app_name, setup_options.model_version, - ) = simulation_api.resolve_app_name( + ) = simulation_entrypoint.resolve_app_name( setup_options.country_id, setup_options.model_version, policyengine_version=setup_options.policyengine_version, @@ -813,7 +814,7 @@ def _handle_execution_state( """ if execution_state in EXECUTION_STATUSES_SUCCESS: result = self._with_policyengine_bundle( - result=simulation_api.get_execution_result(execution), + result=simulation_entrypoint.get_execution_result(execution), setup_options=setup_options, execution=execution, ) @@ -830,13 +831,15 @@ def _handle_execution_state( elif execution_state in EXECUTION_STATUSES_FAILURE: # For Modal, try to get error message from execution - error_message = "Simulation API execution failed" + error_message = "Simulation entrypoint execution failed" if ( execution is not None and hasattr(execution, "error") and execution.error ): - error_message = f"Simulation API execution failed: {execution.error}" + error_message = ( + f"Simulation entrypoint execution failed: {execution.error}" + ) self._set_reform_impact_error( setup_options=setup_options, @@ -877,10 +880,10 @@ def _handle_computing_impact( setup_options: EconomicImpactSetupOptions, most_recent_impact: dict, ) -> EconomicImpactResult: - execution = simulation_api.get_execution_by_id( + execution = simulation_entrypoint.get_execution_by_id( most_recent_impact["execution_id"] ) - execution_state = simulation_api.get_execution_status(execution) + execution_state = simulation_entrypoint.get_execution_status(execution) return self._handle_execution_state( execution_state=execution_state, setup_options=setup_options, @@ -949,10 +952,10 @@ def _handle_create_impact( if sim_params.get("time_period") is not None: sim_params["time_period"] = str(sim_params["time_period"]) - sim_api_execution = simulation_api.run(sim_params) - execution_id = simulation_api.get_execution_id(sim_api_execution) + entrypoint_execution = simulation_entrypoint.run(sim_params) + execution_id = simulation_entrypoint.get_execution_id(entrypoint_execution) - run_id = getattr(sim_api_execution, "run_id", None) or telemetry["run_id"] + run_id = getattr(entrypoint_execution, "run_id", None) or telemetry["run_id"] progress_log = { **setup_options.model_dump(), @@ -1059,10 +1062,12 @@ def _should_refresh_cached_impact( cached_result = self._extract_cached_result(most_recent_impact) cached_resolved_app_name = cached_result.get("resolved_app_name") try: - runtime_app_name, resolved_model_version = simulation_api.resolve_app_name( - setup_options.country_id, - setup_options.model_version, - policyengine_version=setup_options.policyengine_version, + runtime_app_name, resolved_model_version = ( + simulation_entrypoint.resolve_app_name( + setup_options.country_id, + setup_options.model_version, + policyengine_version=setup_options.policyengine_version, + ) ) except Exception: return False diff --git a/scripts/capture_migration_baseline.py b/scripts/capture_migration_baseline.py index 5b4519fcd..db4fdfef0 100644 --- a/scripts/capture_migration_baseline.py +++ b/scripts/capture_migration_baseline.py @@ -246,7 +246,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--base-url", default=os.environ.get("API_BASE_URL")) parser.add_argument( "--simulation-gateway-url", - default=os.environ.get("SIMULATION_API_URL"), + default=os.environ.get("SIMULATION_ENTRYPOINT_URL"), ) parser.add_argument( "--simulation-payload-file", @@ -275,7 +275,7 @@ def main(argv: list[str] | None = None) -> int: ) elif args.simulation_gateway_url or args.simulation_payload_file: print( - "SIMULATION_API_URL and SIMULATION_PAYLOAD_FILE are both required; " + "SIMULATION_ENTRYPOINT_URL and SIMULATION_PAYLOAD_FILE are both required; " "skipping simulation gateway baseline capture." ) diff --git a/tests/conftest.py b/tests/conftest.py index fe49ae051..cf5a9aa70 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os from pathlib import Path import time from contextlib import contextmanager @@ -5,6 +6,13 @@ import sys import pytest +# API startup now requires an explicit direct-gateway rollback target. Tests use +# the non-routable example hostname unless a case overrides it. +os.environ.setdefault( + "OLD_SIMULATION_GATEWAY_URL", + "https://old-simulation-gateway.example.test", +) + # Add the project root directory to PYTHONPATH root_dir = Path(__file__).parent sys.path.append(str(root_dir)) diff --git a/tests/contract/test_simulation_gateway_contract.py b/tests/contract/test_simulation_gateway_contract.py index 0c13587bd..edbdd11a3 100644 --- a/tests/contract/test_simulation_gateway_contract.py +++ b/tests/contract/test_simulation_gateway_contract.py @@ -1,11 +1,11 @@ from unittest.mock import MagicMock import httpx -import policyengine_api.libs.simulation_api_modal as simulation_api_modal +import policyengine_api.libs.simulation_entrypoint as simulation_entrypoint import pytest -from policyengine_api.libs.simulation_api_modal import SimulationAPIModal +from policyengine_api.libs.simulation_entrypoint import SimulationAPIModal -from tests.fixtures.libs.simulation_api_modal import ( +from tests.fixtures.libs.simulation_entrypoint import ( MOCK_BATCH_JOB_ID, MOCK_BATCH_POLL_RESPONSE_COMPLETE, MOCK_BATCH_SUBMIT_RESPONSE_SUCCESS, @@ -20,7 +20,7 @@ @pytest.fixture(autouse=True) def disable_modal_logging(monkeypatch): - monkeypatch.setattr(simulation_api_modal, "logger", MagicMock()) + monkeypatch.setattr(simulation_entrypoint, "logger", MagicMock()) def _client_for(responses: dict[tuple[str, str], httpx.Response]) -> SimulationAPIModal: @@ -53,7 +53,7 @@ def _clear_gateway_auth_env(monkeypatch): def test_gateway_comparison_submit_and_poll_contract(monkeypatch): _clear_gateway_auth_env(monkeypatch) - monkeypatch.setenv("SIMULATION_API_URL", "https://simulation.test") + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://simulation.test") client = _client_for( { ("POST", "/simulate/economy/comparison"): _response( @@ -85,7 +85,7 @@ def test_gateway_comparison_submit_and_poll_contract(monkeypatch): def test_gateway_budget_window_submit_and_poll_contract(monkeypatch): _clear_gateway_auth_env(monkeypatch) - monkeypatch.setenv("SIMULATION_API_URL", "https://simulation.test") + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://simulation.test") client = _client_for( { ( @@ -122,7 +122,7 @@ def test_gateway_budget_window_submit_and_poll_contract(monkeypatch): def test_gateway_versions_and_health_contract(monkeypatch): _clear_gateway_auth_env(monkeypatch) - monkeypatch.setenv("SIMULATION_API_URL", "https://simulation.test") + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://simulation.test") client = _client_for( { ("GET", "/versions/us"): _response( diff --git a/tests/fixtures/libs/simulation_api_modal.py b/tests/fixtures/libs/simulation_entrypoint.py similarity index 96% rename from tests/fixtures/libs/simulation_api_modal.py rename to tests/fixtures/libs/simulation_entrypoint.py index 56030c2f7..b196559bd 100644 --- a/tests/fixtures/libs/simulation_api_modal.py +++ b/tests/fixtures/libs/simulation_entrypoint.py @@ -182,10 +182,10 @@ def create_mock_httpx_response( @pytest.fixture def mock_modal_env_url(): - """Mock the SIMULATION_API_URL environment variable.""" + """Mock the SIMULATION_ENTRYPOINT_URL environment variable.""" with patch.dict( "os.environ", - {"SIMULATION_API_URL": MOCK_MODAL_BASE_URL}, + {"SIMULATION_ENTRYPOINT_URL": MOCK_MODAL_BASE_URL}, ): yield MOCK_MODAL_BASE_URL @@ -198,7 +198,7 @@ def mock_httpx_client(): Returns a mock client that can be configured for different responses. """ with patch( - "policyengine_api.libs.simulation_api_modal.httpx.Client" + "policyengine_api.libs.simulation_entrypoint.httpx.Client" ) as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -248,5 +248,5 @@ def mock_httpx_client_poll_failed(mock_httpx_client): @pytest.fixture def mock_modal_logger(): """Mock logger for SimulationAPIModal.""" - with patch("policyengine_api.libs.simulation_api_modal.logger") as mock: + with patch("policyengine_api.libs.simulation_entrypoint.logger") as mock: yield mock diff --git a/tests/fixtures/services/economy_service.py b/tests/fixtures/services/economy_service.py index 046f32434..e4061efe3 100644 --- a/tests/fixtures/services/economy_service.py +++ b/tests/fixtures/services/economy_service.py @@ -128,7 +128,7 @@ def mock_reform_impacts_service(): @pytest.fixture -def mock_simulation_api(): +def mock_simulation_entrypoint(): """Mock SimulationAPIModal with all required methods.""" mock_api = MagicMock() mock_execution = create_mock_modal_execution() @@ -150,7 +150,7 @@ def mock_simulation_api(): mock_api.get_budget_window_batch_by_id.return_value = mock_batch_execution with patch( - "policyengine_api.services.economy_service.simulation_api", mock_api + "policyengine_api.services.economy_service.simulation_entrypoint", mock_api ) as mock: yield mock @@ -309,7 +309,7 @@ def create_mock_budget_window_batch_execution( @pytest.fixture -def mock_simulation_api_modal(): +def mock_simulation_entrypoint_legacy(): """Mock SimulationAPIModal with all required methods.""" mock_api = MagicMock() mock_execution = create_mock_modal_execution() @@ -327,6 +327,6 @@ def mock_simulation_api_modal(): mock_api.get_execution_result.return_value = MOCK_REFORM_IMPACT_DATA with patch( - "policyengine_api.services.economy_service.simulation_api", mock_api + "policyengine_api.services.economy_service.simulation_entrypoint", mock_api ) as mock: yield mock diff --git a/tests/integration/test_budget_window_in_flight_dedupe.py b/tests/integration/test_budget_window_in_flight_dedupe.py index e6bb1c680..14a273214 100644 --- a/tests/integration/test_budget_window_in_flight_dedupe.py +++ b/tests/integration/test_budget_window_in_flight_dedupe.py @@ -32,7 +32,7 @@ def test_budget_window_in_flight_dedupe_uses_existing_batch_without_live_db( monkeypatch.setenv("POLICYENGINE_DB_PASSWORD", "test") monkeypatch.setenv("FLASK_DEBUG", "1") - from policyengine_api.libs.simulation_api_modal import ( + from policyengine_api.libs.simulation_entrypoint import ( ModalBudgetWindowBatchExecution, ) from policyengine_api.routes.economy_routes import economy_bp @@ -40,16 +40,16 @@ def test_budget_window_in_flight_dedupe_uses_existing_batch_without_live_db( from policyengine_api.services.budget_window_cache import BudgetWindowCache fake_cache = BudgetWindowCache(client=FakeRedis()) - simulation_api = MagicMock() + simulation_entrypoint = MagicMock() reform_impacts_service = MagicMock() - simulation_api.run_budget_window_batch.return_value = ( + simulation_entrypoint.run_budget_window_batch.return_value = ( ModalBudgetWindowBatchExecution( batch_job_id="fc-budget-window-parent", status="submitted", ) ) - simulation_api.get_budget_window_batch_by_id.return_value = ( + simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( ModalBudgetWindowBatchExecution( batch_job_id="fc-budget-window-parent", status="running", @@ -61,7 +61,9 @@ def test_budget_window_in_flight_dedupe_uses_existing_batch_without_live_db( ) monkeypatch.setattr(economy_service_module, "budget_window_cache", fake_cache) - monkeypatch.setattr(economy_service_module, "simulation_api", simulation_api) + monkeypatch.setattr( + economy_service_module, "simulation_entrypoint", simulation_entrypoint + ) monkeypatch.setattr( economy_service_module, "reform_impacts_service", @@ -108,8 +110,8 @@ def test_budget_window_in_flight_dedupe_uses_existing_batch_without_live_db( assert second_payload["computing_years"] == ["2027"] assert second_payload["queued_years"] == ["2028"] - simulation_api.run_budget_window_batch.assert_called_once() - simulation_api.get_budget_window_batch_by_id.assert_called_once_with( + simulation_entrypoint.run_budget_window_batch.assert_called_once() + simulation_entrypoint.get_budget_window_batch_by_id.assert_called_once_with( "fc-budget-window-parent" ) reform_impacts_service.assert_not_called() diff --git a/tests/unit/libs/test_simulation_api_modal.py b/tests/unit/libs/test_simulation_entrypoint.py similarity index 88% rename from tests/unit/libs/test_simulation_api_modal.py rename to tests/unit/libs/test_simulation_entrypoint.py index 93672979f..681c996d1 100644 --- a/tests/unit/libs/test_simulation_api_modal.py +++ b/tests/unit/libs/test_simulation_entrypoint.py @@ -1,7 +1,7 @@ """ Unit tests for SimulationAPIModal class. -Tests the Modal simulation API HTTP client functionality including +Tests the selectable simulation API HTTP client functionality including job submission, status polling, and error handling. """ @@ -25,13 +25,15 @@ MODAL_EXECUTION_STATUS_RUNNING, MODAL_EXECUTION_STATUS_SUBMITTED, ) -from policyengine_api.libs.simulation_api_modal import ( # noqa: E402 +from policyengine_api.libs.simulation_entrypoint import ( # noqa: E402 ModalBudgetWindowBatchExecution, ModalSimulationExecution, + SimulationEntrypointClient, SimulationAPIModal, + resolve_simulation_entrypoint_url, ) -from tests.fixtures.libs.simulation_api_modal import ( # noqa: E402 +from tests.fixtures.libs.simulation_entrypoint import ( # noqa: E402 MOCK_BATCH_JOB_ID, MOCK_BATCH_POLL_RESPONSE_COMPLETE, MOCK_BATCH_POLL_RESPONSE_FAILED, @@ -53,7 +55,7 @@ create_mock_httpx_response, ) -pytest_plugins = ("tests.fixtures.libs.simulation_api_modal",) +pytest_plugins = ("tests.fixtures.libs.simulation_entrypoint",) GATEWAY_AUTH_TEST_ENV_VARS = ( "GATEWAY_AUTH_ISSUER", @@ -64,12 +66,84 @@ "GATEWAY_AUTH_REQUIRED", ) +ENTRYPOINT_TEST_ENV_VARS = ( + "SIM_ENTRYPOINT", + "OLD_SIMULATION_GATEWAY_URL", + "SIMULATION_ENTRYPOINT_URL", +) + @pytest.fixture(autouse=True) def clear_gateway_auth_env(monkeypatch): """Isolate unit tests from gateway-auth env injected during Docker builds.""" for key in GATEWAY_AUTH_TEST_ENV_VARS: monkeypatch.delenv(key, raising=False) + for key in ENTRYPOINT_TEST_ENV_VARS: + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", MOCK_MODAL_BASE_URL) + + +def test_generic_client_name_retains_old_class_alias(): + assert SimulationAPIModal is SimulationEntrypointClient + + +def test_legacy_simulation_api_modules_export_entrypoint_aliases(): + from policyengine_api.libs import simulation_api, simulation_api_modal + from policyengine_api.libs import simulation_entrypoint + + assert simulation_api.SimulationAPIClient is SimulationEntrypointClient + assert simulation_api_modal.SimulationAPIClient is SimulationEntrypointClient + assert ( + simulation_api.simulation_api + is simulation_entrypoint.simulation_entrypoint + is simulation_api_modal.simulation_api_modal + ) + + +def test_direct_entrypoint_prefers_explicit_old_gateway_url(monkeypatch): + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://old.example.test/") + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", "https://new.example.test") + + assert resolve_simulation_entrypoint_url("old_gateway_direct") == ( + "https://old.example.test" + ) + + +def test_cloud_run_entrypoint_uses_entrypoint_url(monkeypatch): + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://old.example.test") + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", "https://new.example.test/") + + assert resolve_simulation_entrypoint_url("cloud_run_simulation_entrypoint") == ( + "https://new.example.test" + ) + + +def test_cloud_run_entrypoint_requires_entrypoint_url(monkeypatch): + monkeypatch.delenv("SIMULATION_ENTRYPOINT_URL", raising=False) + + with pytest.raises(ValueError, match="SIMULATION_ENTRYPOINT_URL is required"): + resolve_simulation_entrypoint_url("cloud_run_simulation_entrypoint") + + +def test_direct_entrypoint_requires_old_gateway_url(monkeypatch): + monkeypatch.delenv("OLD_SIMULATION_GATEWAY_URL", raising=False) + + with pytest.raises(ValueError, match="OLD_SIMULATION_GATEWAY_URL is required"): + resolve_simulation_entrypoint_url("old_gateway_direct") + + +@pytest.mark.parametrize( + ("entrypoint", "env_name"), + [ + ("old_gateway_direct", "OLD_SIMULATION_GATEWAY_URL"), + ("cloud_run_simulation_entrypoint", "SIMULATION_ENTRYPOINT_URL"), + ], +) +def test_selected_entrypoint_rejects_invalid_url(monkeypatch, entrypoint, env_name): + monkeypatch.setenv(env_name, "not-an-absolute-url") + + with pytest.raises(ValueError, match="absolute HTTP"): + resolve_simulation_entrypoint_url(entrypoint) class TestModalSimulationExecution: @@ -143,7 +217,10 @@ def test__given_env_var_set__then_uses_env_url(self, mock_httpx_client): # Given with patch.dict( "os.environ", - {"SIMULATION_API_URL": MOCK_MODAL_BASE_URL}, + { + "SIM_ENTRYPOINT": "cloud_run_simulation_entrypoint", + "SIMULATION_ENTRYPOINT_URL": MOCK_MODAL_BASE_URL, + }, ): # When api = SimulationAPIModal() @@ -151,25 +228,28 @@ def test__given_env_var_set__then_uses_env_url(self, mock_httpx_client): # Then assert api.base_url == MOCK_MODAL_BASE_URL - def test__given_env_var_not_set__then_uses_default_url(self, mock_httpx_client): + def test__given_selected_url_not_set__then_fails_startup( + self, mock_httpx_client + ): # Given with patch.dict("os.environ", {}, clear=False): import os - os.environ.pop("SIMULATION_API_URL", None) + os.environ["SIM_ENTRYPOINT"] = "old_gateway_direct" + os.environ.pop("OLD_SIMULATION_GATEWAY_URL", None) - # When - api = SimulationAPIModal() - - # Then - assert "policyengine-simulation-gateway" in api.base_url - assert "modal.run" in api.base_url + # When / Then + with pytest.raises( + ValueError, + match="OLD_SIMULATION_GATEWAY_URL is required", + ): + SimulationAPIModal() def test__given_gateway_auth_env_vars__then_attaches_bearer_auth( self, mock_httpx_client, monkeypatch ): from policyengine_api.libs.gateway_auth import GatewayBearerAuth - from policyengine_api.libs.simulation_api_modal import httpx as modal_httpx + from policyengine_api.libs.simulation_entrypoint import httpx as modal_httpx monkeypatch.setenv("GATEWAY_AUTH_ISSUER", "https://tenant.auth0.com") monkeypatch.setenv("GATEWAY_AUTH_AUDIENCE", "https://sim-gateway") @@ -184,7 +264,7 @@ def test__given_gateway_auth_env_vars__then_attaches_bearer_auth( def test__given_missing_gateway_auth_env_vars__then_no_auth_attached( self, mock_httpx_client, monkeypatch, mock_modal_logger ): - from policyengine_api.libs.simulation_api_modal import httpx as modal_httpx + from policyengine_api.libs.simulation_entrypoint import httpx as modal_httpx for key in ( "GATEWAY_AUTH_ISSUER", @@ -408,7 +488,7 @@ def test__given_network_error__then_raises_exception( api.run(MOCK_SIMULATION_PAYLOAD_WITH_TELEMETRY) log_payload = mock_modal_logger.log_struct.call_args.args[0] - assert "Modal API request error" in log_payload["message"] + assert "Simulation entrypoint request error" in log_payload["message"] assert log_payload["run_id"] == MOCK_RUN_ID class TestResolveAppName: diff --git a/tests/unit/services/test_economy_service.py b/tests/unit/services/test_economy_service.py index e2fa54032..9600f5124 100644 --- a/tests/unit/services/test_economy_service.py +++ b/tests/unit/services/test_economy_service.py @@ -106,7 +106,7 @@ def test__given_completed_impact__returns_completed_result( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -132,7 +132,7 @@ def test__given_completed_impact__returns_completed_result( ( mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.assert_called_once() ) - mock_simulation_api.run.assert_not_called() + mock_simulation_entrypoint.run.assert_not_called() def test__given_legacy_completed_impact__refreshes_cache( self, @@ -142,7 +142,7 @@ def test__given_legacy_completed_impact__refreshes_cache( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -159,12 +159,12 @@ def test__given_legacy_completed_impact__refreshes_cache( result = economy_service.get_economic_impact(**base_params) assert result.status == ImpactStatus.COMPUTING - mock_simulation_api.resolve_app_name.assert_called_once_with( + mock_simulation_entrypoint.resolve_app_name.assert_called_once_with( MOCK_COUNTRY_ID, MOCK_MODEL_VERSION, policyengine_version=MOCK_POLICYENGINE_VERSION, ) - mock_simulation_api.run.assert_called_once() + mock_simulation_entrypoint.run.assert_called_once() def test__given_computing_impact_with_succeeded_execution__returns_completed_result( self, @@ -174,7 +174,7 @@ def test__given_computing_impact_with_succeeded_execution__returns_completed_res mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -183,8 +183,8 @@ def test__given_computing_impact_with_succeeded_execution__returns_completed_res mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.return_value = [ computing_impact ] - mock_simulation_api.get_execution_status.return_value = "complete" - mock_simulation_api.get_execution_result.return_value = ( + mock_simulation_entrypoint.get_execution_status.return_value = "complete" + mock_simulation_entrypoint.get_execution_result.return_value = ( MOCK_REFORM_IMPACT_DATA ) @@ -200,7 +200,7 @@ def test__given_computing_impact_with_succeeded_execution__returns_completed_res "data_version": MOCK_DATA_VERSION, "dataset": MOCK_RESOLVED_DATASET, } - mock_simulation_api.get_execution_by_id.assert_called_once_with( + mock_simulation_entrypoint.get_execution_by_id.assert_called_once_with( MOCK_EXECUTION_ID ) mock_reform_impacts_service.set_complete_reform_impact.assert_called_once() @@ -213,7 +213,7 @@ def test__given_computing_impact_with_failed_execution__returns_error_result( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -222,7 +222,7 @@ def test__given_computing_impact_with_failed_execution__returns_error_result( mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.return_value = [ computing_impact ] - mock_simulation_api.get_execution_status.return_value = "failed" + mock_simulation_entrypoint.get_execution_status.return_value = "failed" result = economy_service.get_economic_impact(**base_params) @@ -238,7 +238,7 @@ def test__given_computing_impact_with_active_execution__returns_computing_result mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -247,7 +247,7 @@ def test__given_computing_impact_with_active_execution__returns_computing_result mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.return_value = [ computing_impact ] - mock_simulation_api.get_execution_status.return_value = "running" + mock_simulation_entrypoint.get_execution_status.return_value = "running" result = economy_service.get_economic_impact(**base_params) @@ -262,7 +262,7 @@ def test__given_no_previous_impact__creates_new_simulation( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -273,7 +273,7 @@ def test__given_no_previous_impact__creates_new_simulation( assert result.status == ImpactStatus.COMPUTING assert result.data is None - mock_simulation_api.run.assert_called_once() + mock_simulation_entrypoint.run.assert_called_once() mock_reform_impacts_service.set_reform_impact.assert_called_once() def test__given_no_previous_impact__includes_metadata_in_simulation_params( @@ -284,7 +284,7 @@ def test__given_no_previous_impact__includes_metadata_in_simulation_params( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -294,8 +294,8 @@ def test__given_no_previous_impact__includes_metadata_in_simulation_params( economy_service.get_economic_impact(**base_params) - # Get the params passed to simulation_api.run() - call_args = mock_simulation_api.run.call_args + # Get the params passed to simulation_entrypoint.run() + call_args = mock_simulation_entrypoint.run.call_args sim_params = call_args[0][0] # First positional argument # Verify _metadata is included with correct values @@ -323,7 +323,7 @@ def test__given_no_previous_impact__includes_telemetry_in_simulation_params( mock_country_package_versions, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -332,7 +332,7 @@ def test__given_no_previous_impact__includes_telemetry_in_simulation_params( economy_service.get_economic_impact(**base_params) - sim_params = mock_simulation_api.run.call_args[0][0] + sim_params = mock_simulation_entrypoint.run.call_args[0][0] assert sim_params["_telemetry"]["run_id"] assert sim_params["_telemetry"]["process_id"] == MOCK_PROCESS_ID @@ -355,7 +355,7 @@ def test__given_runtime_cache_version__uses_versioned_economy_cache_key( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -392,7 +392,7 @@ def test__given_alias_dataset__queries_previous_impacts_with_resolved_bundle( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -419,7 +419,7 @@ def test__given_completed_impact__uses_resolved_runtime_bundle_for_cache_lookup( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -432,7 +432,7 @@ def test__given_completed_impact__uses_resolved_runtime_bundle_for_cache_lookup( result = economy_service.get_economic_impact(**base_params) assert result.status == ImpactStatus.OK - mock_simulation_api.resolve_app_name.assert_called_once_with( + mock_simulation_entrypoint.resolve_app_name.assert_called_once_with( MOCK_COUNTRY_ID, MOCK_MODEL_VERSION, policyengine_version=MOCK_POLICYENGINE_VERSION, @@ -446,7 +446,7 @@ def test__given_cached_impact_and_runtime_lookup_fails__then_returns_cached_resu mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -455,7 +455,7 @@ def test__given_cached_impact_and_runtime_lookup_fails__then_returns_cached_resu mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.return_value = [ completed_impact ] - mock_simulation_api.resolve_app_name.side_effect = RuntimeError( + mock_simulation_entrypoint.resolve_app_name.side_effect = RuntimeError( "versions down" ) @@ -465,7 +465,7 @@ def test__given_cached_impact_and_runtime_lookup_fails__then_returns_cached_resu assert ( result.data["policyengine_bundle"]["dataset"] == MOCK_RESOLVED_DATASET ) - mock_simulation_api.run.assert_not_called() + mock_simulation_entrypoint.run.assert_not_called() def test__given_legacy_cached_impact_without_resolved_app_name__then_refreshes_cache( self, @@ -475,7 +475,7 @@ def test__given_legacy_cached_impact_without_resolved_app_name__then_refreshes_c mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -492,12 +492,12 @@ def test__given_legacy_cached_impact_without_resolved_app_name__then_refreshes_c result = economy_service.get_economic_impact(**base_params) assert result.status == ImpactStatus.COMPUTING - mock_simulation_api.resolve_app_name.assert_called_once_with( + mock_simulation_entrypoint.resolve_app_name.assert_called_once_with( MOCK_COUNTRY_ID, MOCK_MODEL_VERSION, policyengine_version=MOCK_POLICYENGINE_VERSION, ) - mock_simulation_api.run.assert_called_once() + mock_simulation_entrypoint.run.assert_called_once() def test__given_legacy_and_refreshed_cached_impacts__then_reuses_refreshed_entry( self, @@ -507,7 +507,7 @@ def test__given_legacy_and_refreshed_cached_impacts__then_reuses_refreshed_entry mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -536,7 +536,7 @@ def test__given_legacy_and_refreshed_cached_impacts__then_reuses_refreshed_entry mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.call_count == 2 ) - mock_simulation_api.run.assert_not_called() + mock_simulation_entrypoint.run.assert_not_called() def test__given_legacy_cached_impact_and_runtime_lookup_fails__then_returns_cached_result( self, @@ -546,7 +546,7 @@ def test__given_legacy_cached_impact_and_runtime_lookup_fails__then_returns_cach mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -559,7 +559,7 @@ def test__given_legacy_cached_impact_and_runtime_lookup_fails__then_returns_cach mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.return_value = [ completed_impact ] - mock_simulation_api.resolve_app_name.side_effect = RuntimeError( + mock_simulation_entrypoint.resolve_app_name.side_effect = RuntimeError( "versions down" ) @@ -567,7 +567,7 @@ def test__given_legacy_cached_impact_and_runtime_lookup_fails__then_returns_cach assert result.status == ImpactStatus.OK assert result.data["policyengine_bundle"]["model_version"] is None - mock_simulation_api.run.assert_not_called() + mock_simulation_entrypoint.run.assert_not_called() def test__given_legacy_computing_impact_without_resolved_app_name__then_reuses_execution( self, @@ -577,7 +577,7 @@ def test__given_legacy_computing_impact_without_resolved_app_name__then_reuses_e mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -589,13 +589,13 @@ def test__given_legacy_computing_impact_without_resolved_app_name__then_reuses_e mock_reform_impacts_service.get_all_reform_impacts_by_options_hash_prefix.return_value = [ computing_impact ] - mock_simulation_api.get_execution_status.return_value = "running" + mock_simulation_entrypoint.get_execution_status.return_value = "running" result = economy_service.get_economic_impact(**base_params) assert result.status == ImpactStatus.COMPUTING - mock_simulation_api.resolve_app_name.assert_not_called() - mock_simulation_api.run.assert_not_called() + mock_simulation_entrypoint.resolve_app_name.assert_not_called() + mock_simulation_entrypoint.run.assert_not_called() def test__given_exception__raises_error( self, @@ -605,7 +605,7 @@ def test__given_exception__raises_error( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -625,7 +625,7 @@ def test__given_uk_request__preserves_model_version_in_bundle( mock_policyengine_version, mock_policy_service, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_logger, mock_datetime, mock_numpy_random, @@ -645,7 +645,7 @@ def test__given_uk_request__preserves_model_version_in_bundle( target="general", ) - sim_params = mock_simulation_api.run.call_args[0][0] + sim_params = mock_simulation_entrypoint.run.call_args[0][0] assert sim_params["_metadata"]["model_version"] == "2.7.8" class TestGetBudgetWindowEconomicImpact: @@ -680,14 +680,16 @@ def test__given_no_cached_batch__submits_parent_batch_and_returns_queued_result( economy_service, base_params, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): batch_execution = create_mock_budget_window_batch_execution( batch_job_id="fc-budget-123", status="submitted", ) - mock_simulation_api.run_budget_window_batch.return_value = batch_execution + mock_simulation_entrypoint.run_budget_window_batch.return_value = ( + batch_execution + ) result = economy_service.get_budget_window_economic_impact(**base_params) @@ -698,9 +700,9 @@ def test__given_no_cached_batch__submits_parent_batch_and_returns_queued_result( assert result.queued_years == ["2026", "2027", "2028"] assert result.cache_status == "miss" assert "Queued 2026" in result.message - mock_simulation_api.run_budget_window_batch.assert_called_once() + mock_simulation_entrypoint.run_budget_window_batch.assert_called_once() submitted_payload = ( - mock_simulation_api.run_budget_window_batch.call_args.args[0] + mock_simulation_entrypoint.run_budget_window_batch.call_args.args[0] ) assert submitted_payload["start_year"] == "2026" assert submitted_payload["window_size"] == 3 @@ -719,7 +721,7 @@ def test__given_completed_cached_result__returns_completed_batch_result( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): completed_result = { @@ -756,18 +758,18 @@ def test__given_completed_cached_result__returns_completed_batch_result( assert result.progress == 100 assert result.data == completed_result assert result.cache_status == "result-hit" - mock_simulation_api.get_budget_window_batch_by_id.assert_not_called() - mock_simulation_api.run_budget_window_batch.assert_not_called() + mock_simulation_entrypoint.get_budget_window_batch_by_id.assert_not_called() + mock_simulation_entrypoint.run_budget_window_batch.assert_not_called() def test__given_cached_batch_id__returns_running_batch_progress( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): mock_budget_window_cache.get_batch_job_id.return_value = "fc-budget-123" - mock_simulation_api.get_budget_window_batch_by_id.return_value = ( + mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( create_mock_budget_window_batch_execution( batch_job_id="fc-budget-123", status="running", @@ -787,7 +789,7 @@ def test__given_cached_batch_id__returns_running_batch_progress( assert result.queued_years == ["2028"] assert result.cache_status == "batch-id-hit" assert "1 of 3 complete" in result.message - mock_simulation_api.get_budget_window_batch_by_id.assert_called_once_with( + mock_simulation_entrypoint.get_budget_window_batch_by_id.assert_called_once_with( "fc-budget-123" ) @@ -795,7 +797,7 @@ def test__given_completed_batch_poll__caches_result_and_returns_completed( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): completed_result = { @@ -807,7 +809,7 @@ def test__given_completed_batch_poll__caches_result_and_returns_completed( "totals": {}, } mock_budget_window_cache.get_batch_job_id.return_value = "fc-budget-123" - mock_simulation_api.get_budget_window_batch_by_id.return_value = ( + mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( create_mock_budget_window_batch_execution( batch_job_id="fc-budget-123", status="complete", @@ -834,12 +836,12 @@ def test__given_completed_batch_without_result__returns_error_without_caching( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, malformed_result, ): mock_budget_window_cache.get_batch_job_id.return_value = "fc-budget-123" - mock_simulation_api.get_budget_window_batch_by_id.return_value = ( + mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( create_mock_budget_window_batch_execution( batch_job_id="fc-budget-123", status="complete", @@ -868,7 +870,7 @@ def test__given_completed_batch_cache_write_fails__does_not_clear_batch_id( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): completed_result = { @@ -883,7 +885,7 @@ def test__given_completed_batch_cache_write_fails__does_not_clear_batch_id( mock_budget_window_cache.set_completed_result.side_effect = RuntimeError( "redis unavailable" ) - mock_simulation_api.get_budget_window_batch_by_id.return_value = ( + mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( create_mock_budget_window_batch_execution( batch_job_id="fc-budget-123", status="complete", @@ -902,11 +904,11 @@ def test__given_failed_batch_poll__returns_failed( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): mock_budget_window_cache.get_batch_job_id.return_value = "fc-budget-123" - mock_simulation_api.get_budget_window_batch_by_id.return_value = ( + mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( create_mock_budget_window_batch_execution( batch_job_id="fc-budget-123", status="failed", @@ -935,7 +937,7 @@ def test__given_existing_start_claim__does_not_submit_duplicate_batch( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): mock_budget_window_cache.claim_batch_start.return_value = False @@ -946,17 +948,17 @@ def test__given_existing_start_claim__does_not_submit_duplicate_batch( assert result.progress == 0 assert result.queued_years == ["2026", "2027", "2028"] assert result.cache_status == "starting-claim-hit" - mock_simulation_api.run_budget_window_batch.assert_not_called() + mock_simulation_entrypoint.run_budget_window_batch.assert_not_called() def test__given_batch_submission_fails__clears_start_claim( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): - mock_simulation_api.run_budget_window_batch.side_effect = RuntimeError( - "submit failed" + mock_simulation_entrypoint.run_budget_window_batch.side_effect = ( + RuntimeError("submit failed") ) with pytest.raises(RuntimeError, match="submit failed"): @@ -971,11 +973,11 @@ def test__given_modal_rejects_batch_submission_for_validation__returns_failed_re self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, status_code, ): - mock_simulation_api.run_budget_window_batch.side_effect = make_http_status_error( + mock_simulation_entrypoint.run_budget_window_batch.side_effect = make_http_status_error( status_code, { "detail": ( @@ -1007,11 +1009,11 @@ def test__given_modal_non_validation_error_on_batch_submission__raises( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, status_code, ): - mock_simulation_api.run_budget_window_batch.side_effect = ( + mock_simulation_entrypoint.run_budget_window_batch.side_effect = ( make_http_status_error(status_code, {"detail": "gateway unavailable"}) ) @@ -1104,7 +1106,7 @@ def test__given_runtime_cache_version__uses_versioned_cache_key_for_budget_windo economy_service, base_params, mock_country_package_versions, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, mock_logger, mock_datetime, @@ -1128,7 +1130,7 @@ def test__given_reordered_options__uses_same_budget_window_cache_identity( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): mock_budget_window_cache.get_completed_result.return_value = { @@ -1157,14 +1159,14 @@ def test__given_reordered_options__uses_same_budget_window_cache_identity( ) assert first_cache_key_kwargs == second_cache_key_kwargs - mock_simulation_api.run_budget_window_batch.assert_not_called() + mock_simulation_entrypoint.run_budget_window_batch.assert_not_called() def test__given_legacy_us_region__normalizes_before_building_cache_key( self, economy_service, base_params, mock_reform_impacts_service, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): base_params["region"] = "ca" @@ -1181,11 +1183,11 @@ def test__given_unexpected_batch_status__raises_value_error( self, economy_service, base_params, - mock_simulation_api, + mock_simulation_entrypoint, mock_budget_window_cache, ): mock_budget_window_cache.get_batch_job_id.return_value = "fc-budget-123" - mock_simulation_api.get_budget_window_batch_by_id.return_value = ( + mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( create_mock_budget_window_batch_execution( batch_job_id="fc-budget-123", status="paused", @@ -1389,13 +1391,13 @@ def test__given_succeeded_state__returns_completed_result( self, economy_service, setup_options, - mock_simulation_api, + mock_simulation_entrypoint, mock_reform_impacts_service, mock_logger, ): reform_impact = create_mock_reform_impact(status="computing") mock_execution = MagicMock() - mock_simulation_api.get_execution_result.return_value = ( + mock_simulation_entrypoint.get_execution_result.return_value = ( MOCK_REFORM_IMPACT_DATA ) @@ -1457,14 +1459,14 @@ def test__given_modal_complete_state__then_returns_completed_result( self, economy_service, setup_options, - mock_simulation_api, + mock_simulation_entrypoint, mock_reform_impacts_service, mock_logger, ): # Given reform_impact = create_mock_reform_impact(status="computing") mock_execution = MagicMock() - mock_simulation_api.get_execution_result.return_value = ( + mock_simulation_entrypoint.get_execution_result.return_value = ( MOCK_REFORM_IMPACT_DATA ) diff --git a/tests/unit/test_asgi_factory.py b/tests/unit/test_asgi_factory.py index 766339aa8..ebb9bf866 100644 --- a/tests/unit/test_asgi_factory.py +++ b/tests/unit/test_asgi_factory.py @@ -184,9 +184,9 @@ def test_public_simulation_gateway_health_probe_checks_gateway(): client = TestClient(create_asgi_app(create_test_wsgi_app())) with patch( - "policyengine_api.libs.simulation_api_modal.SimulationAPIModal" - ) as simulation_api: - simulation_api.return_value.health_check.return_value = True + "policyengine_api.libs.simulation_entrypoint.SimulationEntrypointClient" + ) as simulation_entrypoint: + simulation_entrypoint.return_value.health_check.return_value = True response = client.get("/simulation-gateway-check") @@ -195,17 +195,17 @@ def test_public_simulation_gateway_health_probe_checks_gateway(): "status": "healthy", "simulation_gateway": "healthy", } - simulation_api.assert_called_once_with() - simulation_api.return_value.health_check.assert_called_once_with() + simulation_entrypoint.assert_called_once_with() + simulation_entrypoint.return_value.health_check.assert_called_once_with() def test_public_simulation_gateway_health_probe_reports_failure(): client = TestClient(create_asgi_app(create_test_wsgi_app())) with patch( - "policyengine_api.libs.simulation_api_modal.SimulationAPIModal" - ) as simulation_api: - simulation_api.return_value.health_check.return_value = False + "policyengine_api.libs.simulation_entrypoint.SimulationEntrypointClient" + ) as simulation_entrypoint: + simulation_entrypoint.return_value.health_check.return_value = False response = client.get("/simulation-gateway-check") diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index d05c058d4..a9a67f760 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -7,6 +7,8 @@ import subprocess from pathlib import Path +import pytest + REPO = Path(__file__).resolve().parents[2] PRODUCTION_CLOUD_SQL_INSTANCE = "policyengine-api:us-central1:policyengine-api-data" PRODUCTION_CLOUD_RUN_SERVICE = "policyengine-api" @@ -55,7 +57,9 @@ def _required_runtime_env() -> dict[str, str]: "ANTHROPIC_API_KEY": "raw-anthropic-secret-value", "OPENAI_API_KEY": "raw-openai-secret-value", "HUGGING_FACE_TOKEN": "raw-hf-secret-value", - "SIMULATION_API_URL": "https://simulation.example.test", + "SIMULATION_ENTRYPOINT_URL": "https://simulation.example.test", + "OLD_SIMULATION_GATEWAY_URL": "https://old-gateway.example.test", + "SIM_ENTRYPOINT": "cloud_run_simulation_entrypoint", "GATEWAY_AUTH_ISSUER": "https://issuer.example.test", "GATEWAY_AUTH_AUDIENCE": "simulation-gateway", "GATEWAY_AUTH_CLIENT_ID": "client-id", @@ -88,7 +92,7 @@ def _run_simulation_version_guard( return subprocess.run( ["bash", "-c", command, "request-simulation-model-versions.sh", *args], cwd=REPO, - env=_script_env(SIMULATION_API_URL="https://simulation.example.test"), + env=_script_env(SIMULATION_ENTRYPOINT_URL="https://simulation.example.test"), text=True, capture_output=True, check=False, @@ -297,7 +301,8 @@ def test_validate_cloud_run_deploy_env_reports_missing_runtime_config(): assert result.returncode == 1 assert "Missing required Cloud Run deployment configuration" in result.stderr - assert "SIMULATION_API_URL" in result.stderr + assert "SIMULATION_ENTRYPOINT_URL" in result.stderr + assert "OLD_SIMULATION_GATEWAY_URL" in result.stderr assert "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE" in result.stderr assert "POLICYENGINE_DB_PASSWORD" not in result.stderr @@ -356,6 +361,78 @@ def test_deploy_cloud_run_candidate_dry_run_never_shifts_traffic(): assert "CLOUD_RUN_INTERNAL_PROBES" not in result.stdout assert "--to-latest" not in result.stdout assert "update-traffic" not in result.stdout + assert ( + "OLD_SIMULATION_GATEWAY_URL=https://old-gateway.example.test" in result.stdout + ) + assert "SIM_ENTRYPOINT=cloud_run_simulation_entrypoint" in result.stdout + + +@pytest.mark.parametrize( + ("new_percent", "expected_traffic"), + [ + ("0", "direct-revision=100"), + ("5", "new-revision=5,direct-revision=95"), + ("25", "new-revision=25,direct-revision=75"), + ("50", "new-revision=50,direct-revision=50"), + ("100", "new-revision=100"), + ], +) +def test_simulation_entrypoint_ramp_uses_only_approved_percentages( + new_percent, expected_traffic +): + result = _run_script( + ".github/scripts/ramp_simulation_entrypoint.sh", + _script_env( + SIMULATION_NEW_ENTRYPOINT_REVISION="new-revision", + SIMULATION_DIRECT_GATEWAY_REVISION="direct-revision", + SIMULATION_NEW_ENTRYPOINT_PERCENT=new_percent, + ), + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.count("gcloud run revisions describe") == 2 + assert "gcloud run services update-traffic" in result.stdout + expected_dry_run = expected_traffic.replace(",", "\\,") + assert f"--to-revisions {expected_dry_run}" in result.stdout + + +def test_simulation_entrypoint_ramp_rejects_unapproved_percentage(): + result = _run_script( + ".github/scripts/ramp_simulation_entrypoint.sh", + _script_env( + SIMULATION_NEW_ENTRYPOINT_REVISION="new-revision", + SIMULATION_DIRECT_GATEWAY_REVISION="direct-revision", + SIMULATION_NEW_ENTRYPOINT_PERCENT="10", + ), + ) + + assert result.returncode == 1 + assert "must be one of 0, 5, 25, 50, or 100" in result.stderr + + +def test_simulation_entrypoint_traffic_changes_are_operator_run_only(): + assert not (REPO / ".github/workflows/ramp-simulation-entrypoint.yml").exists() + for workflow in (REPO / ".github/workflows").glob("*.y*ml"): + assert "ramp_simulation_entrypoint.sh" not in workflow.read_text( + encoding="utf-8" + ) + + +def test_simulation_entrypoint_ramp_validates_revision_modes_before_traffic(): + script = (REPO / ".github/scripts/ramp_simulation_entrypoint.sh").read_text( + encoding="utf-8" + ) + + assert ( + 'validate_revision_entrypoint "${new_revision}" cloud_run_simulation_entrypoint' + in script + ) + assert ( + 'validate_revision_entrypoint "${direct_revision}" old_gateway_direct' in script + ) + assert script.index("validate_revision_entrypoint") < script.index( + "gcloud run services update-traffic" + ) def test_deploy_cloud_run_candidate_pins_runtime_shape(): @@ -764,4 +841,7 @@ def test_push_workflow_promotes_production_cloud_run_after_candidate_smoke(): ) assert smoke_index < promote_index + assert "if: ${{ vars.SIM_ENTRYPOINT != 'cloud_run_simulation_entrypoint' }}" in ( + cloud_run_production + ) assert "bash .github/scripts/get_cloud_run_service_url.sh" in cloud_run_production diff --git a/tests/unit/test_migration_flags.py b/tests/unit/test_migration_flags.py index 5367a4494..19837071f 100644 --- a/tests/unit/test_migration_flags.py +++ b/tests/unit/test_migration_flags.py @@ -12,7 +12,7 @@ def test_default_migration_context_preserves_current_behavior(monkeypatch): "ROUTE_IMPL_POLICY", "DB_WRITE_POLICY", "DB_READ_POLICY", - "SIM_FRONT_DOOR", + "SIM_ENTRYPOINT", ): monkeypatch.delenv(key, raising=False) @@ -23,7 +23,7 @@ def test_default_migration_context_preserves_current_behavior(monkeypatch): assert context.db_entity == "policy" assert context.db_write == "cloud_sql" assert context.db_read == "cloud_sql" - assert context.sim_front_door == "old_gateway_direct" + assert context.sim_entrypoint == "old_gateway_direct" assert context.sim_compute is None @@ -32,7 +32,7 @@ def test_explicit_valid_migration_context_values(monkeypatch): monkeypatch.setenv("ROUTE_IMPL_ECONOMY", "fastapi_native") monkeypatch.setenv("DB_WRITE_SIMULATION", "dual_write") monkeypatch.setenv("DB_READ_SIMULATION", "read_compare") - monkeypatch.setenv("SIM_FRONT_DOOR", "cloud_run_facade") + monkeypatch.setenv("SIM_ENTRYPOINT", "cloud_run_simulation_entrypoint") monkeypatch.setenv("SIM_COMPUTE_ECONOMY", "v2_shadow") context = get_migration_context("economy") @@ -42,7 +42,7 @@ def test_explicit_valid_migration_context_values(monkeypatch): assert context.db_entity == "simulation" assert context.db_write == "dual_write" assert context.db_read == "read_compare" - assert context.sim_front_door == "cloud_run_facade" + assert context.sim_entrypoint == "cloud_run_simulation_entrypoint" assert context.sim_compute == "v2_shadow" diff --git a/uv.lock b/uv.lock index cb686f5dd..c345d2531 100644 --- a/uv.lock +++ b/uv.lock @@ -2461,7 +2461,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2616,7 +2616,7 @@ models = [ [[package]] name = "policyengine-api" -version = "3.45.0" +version = "3.46.2" source = { editable = "." } dependencies = [ { name = "a2wsgi" },