From 3520650e396dfe9759282ddf556c8621122a3ed3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:58:09 +0300 Subject: [PATCH 1/6] feat: add selectable simulation API front door --- policyengine_api/asgi_factory.py | 4 +- policyengine_api/libs/simulation_api.py | 25 ++++++ policyengine_api/libs/simulation_api_modal.py | 87 ++++++++++++------- policyengine_api/migration_flags.py | 17 ++-- policyengine_api/services/economy_service.py | 3 +- tests/unit/libs/test_simulation_api_modal.py | 43 ++++++++- tests/unit/test_asgi_factory.py | 4 +- tests/unit/test_migration_flags.py | 4 +- 8 files changed, 140 insertions(+), 47 deletions(-) create mode 100644 policyengine_api/libs/simulation_api.py diff --git a/policyengine_api/asgi_factory.py b/policyengine_api/asgi_factory.py index 7eed9975e..8bb4c0a6f 100644 --- a/policyengine_api/asgi_factory.py +++ b/policyengine_api/asgi_factory.py @@ -94,10 +94,10 @@ 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_api import SimulationAPIClient try: - gateway_healthy = SimulationAPIModal().health_check() + gateway_healthy = SimulationAPIClient().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..47bd633ff --- /dev/null +++ b/policyengine_api/libs/simulation_api.py @@ -0,0 +1,25 @@ +"""Generic Simulation API client exports. + +The implementation remains in ``simulation_api_modal`` temporarily so existing +imports and test patches keep working during the Stage 5 cutover. +""" + +from policyengine_api.libs.simulation_api_modal import ( + ModalBudgetWindowBatchExecution, + ModalSimulationExecution, + SimulationAPIClient, + SimulationAPIModal, + resolve_simulation_api_url, + simulation_api, + simulation_api_modal, +) + +__all__ = [ + "ModalBudgetWindowBatchExecution", + "ModalSimulationExecution", + "SimulationAPIClient", + "SimulationAPIModal", + "resolve_simulation_api_url", + "simulation_api", + "simulation_api_modal", +] diff --git a/policyengine_api/libs/simulation_api_modal.py b/policyengine_api/libs/simulation_api_modal.py index ba996fba4..fe01ff395 100644 --- a/policyengine_api/libs/simulation_api_modal.py +++ b/policyengine_api/libs/simulation_api_modal.py @@ -1,9 +1,4 @@ -""" -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. -""" +"""Compatibility HTTP client for the configured Simulation API front door.""" import os import sys @@ -19,6 +14,33 @@ _require_all_or_none_gateway_auth_env, gateway_auth_required, ) +from policyengine_api.migration_flags import get_sim_front_door + + +DEFAULT_OLD_SIMULATION_GATEWAY_URL = ( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" +) + + +def resolve_simulation_api_url(front_door: str | None = None) -> str: + """Resolve old/new upstream URLs without conflating their configuration.""" + selected_front_door = front_door or get_sim_front_door() + if selected_front_door == "old_gateway_direct": + # SIMULATION_API_URL is retained as a legacy fallback while deployments + # gain the explicit OLD_SIMULATION_GATEWAY_URL setting. + return ( + os.environ.get("OLD_SIMULATION_GATEWAY_URL") + or os.environ.get("SIMULATION_API_URL") + or DEFAULT_OLD_SIMULATION_GATEWAY_URL + ).rstrip("/") + + simulation_api_url = os.environ.get("SIMULATION_API_URL") + if not simulation_api_url: + raise ValueError( + "SIMULATION_API_URL is required when " + "SIM_FRONT_DOOR=cloud_run_simulation_api" + ) + return simulation_api_url.rstrip("/") @dataclass @@ -44,7 +66,7 @@ def name(self) -> str: @dataclass class ModalBudgetWindowBatchExecution: """ - Represents a budget-window batch execution in the Modal simulation API. + Represents a budget-window batch execution in the Simulation API. """ batch_job_id: str @@ -63,19 +85,17 @@ def name(self) -> str: return self.batch_job_id -class SimulationAPIModal: +class SimulationAPIClient: """ - HTTP client for the Modal Simulation API. + HTTP client for the configured Simulation API front door. 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", - ) + def __init__(self, front_door: str | None = None): + self.front_door = front_door or get_sim_front_door() + self.base_url = resolve_simulation_api_url(self.front_door) self._token_provider = GatewayAuthTokenProvider() _require_all_or_none_gateway_auth_env() auth = ( @@ -92,7 +112,7 @@ def __init__(self): "GATEWAY_AUTH_CLIENT_SECRET." ) print( - "SimulationAPIModal initialized without gateway auth; " + "SimulationAPIClient initialized without gateway auth; " "all GATEWAY_AUTH_* env vars are unset and " "GATEWAY_AUTH_REQUIRED is not enabled.", file=sys.stderr, @@ -111,7 +131,7 @@ def _normalize_submission_payload(self, payload: dict) -> dict: def run(self, payload: dict) -> ModalSimulationExecution: """ - Submit a simulation job to the Modal API. + Submit a simulation job to the selected Simulation API. Parameters ---------- @@ -141,7 +161,7 @@ def run(self, payload: dict) -> ModalSimulationExecution: logger.log_struct( { - "message": "Modal simulation job submitted", + "message": "Simulation API job submitted", "job_id": data.get("job_id"), "run_id": data.get("run_id"), "status": data.get("status"), @@ -160,7 +180,7 @@ def run(self, payload: dict) -> ModalSimulationExecution: except httpx.HTTPStatusError as e: logger.log_struct( { - "message": f"Modal API HTTP error: {e.response.status_code}", + "message": f"Simulation API HTTP error: {e.response.status_code}", "run_id": (payload.get("_telemetry") or {}).get("run_id"), "response_text": e.response.text[:500], }, @@ -171,7 +191,7 @@ def run(self, payload: dict) -> ModalSimulationExecution: except httpx.RequestError as e: logger.log_struct( { - "message": f"Modal API request error: {str(e)}", + "message": f"Simulation API request error: {str(e)}", "run_id": (payload.get("_telemetry") or {}).get("run_id"), }, severity="ERROR", @@ -180,7 +200,7 @@ def run(self, payload: dict) -> ModalSimulationExecution: def run_budget_window_batch(self, payload: dict) -> ModalBudgetWindowBatchExecution: """ - Submit a budget-window batch job to the Modal API. + Submit a budget-window batch job to the selected Simulation API. """ try: modal_payload = self._normalize_submission_payload(payload) @@ -209,7 +229,7 @@ def run_budget_window_batch(self, payload: dict) -> ModalBudgetWindowBatchExecut except httpx.HTTPStatusError as e: logger.log_struct( { - "message": f"Modal batch API HTTP error: {e.response.status_code}", + "message": f"Simulation batch API HTTP error: {e.response.status_code}", "response_text": e.response.text[:500], }, severity="ERROR", @@ -219,7 +239,7 @@ def run_budget_window_batch(self, payload: dict) -> ModalBudgetWindowBatchExecut except httpx.RequestError as e: logger.log_struct( { - "message": f"Modal batch API request error: {str(e)}", + "message": f"Simulation batch API request error: {str(e)}", "run_id": (payload.get("_telemetry") or {}).get("run_id"), }, severity="ERROR", @@ -276,7 +296,7 @@ def get_execution_id(self, execution: ModalSimulationExecution) -> str: def get_execution_by_id(self, job_id: str) -> ModalSimulationExecution: """ - Poll the Modal API for the current status of a job. + Poll the selected Simulation API for the current status of a job. Parameters ---------- @@ -307,7 +327,7 @@ def get_execution_by_id(self, job_id: str) -> ModalSimulationExecution: except httpx.HTTPStatusError as e: logger.log_struct( { - "message": f"Modal API HTTP error polling job {job_id}: {e.response.status_code}", + "message": f"Simulation API HTTP error polling job {job_id}: {e.response.status_code}", "response_text": e.response.text[:500], }, severity="ERROR", @@ -317,7 +337,7 @@ def get_execution_by_id(self, job_id: str) -> ModalSimulationExecution: except httpx.RequestError as e: logger.log_struct( { - "message": f"Modal API request error polling job {job_id}: {str(e)}", + "message": f"Simulation API request error polling job {job_id}: {str(e)}", }, severity="ERROR", ) @@ -327,7 +347,7 @@ 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. + Poll the selected Simulation API for a budget-window batch. """ try: response = self.client.get( @@ -352,7 +372,7 @@ def get_budget_window_batch_by_id( except httpx.HTTPStatusError as e: logger.log_struct( { - "message": f"Modal batch API HTTP error polling job {batch_job_id}: {e.response.status_code}", + "message": f"Simulation batch API HTTP error polling job {batch_job_id}: {e.response.status_code}", "response_text": e.response.text[:500], }, severity="ERROR", @@ -362,7 +382,7 @@ def get_budget_window_batch_by_id( except httpx.RequestError as e: logger.log_struct( { - "message": f"Modal batch API request error polling job {batch_job_id}: {str(e)}", + "message": f"Simulation batch API request error polling job {batch_job_id}: {str(e)}", }, severity="ERROR", ) @@ -404,7 +424,7 @@ def get_execution_result( def health_check(self) -> bool: """ - Check if the Modal API is healthy. + Check if the selected Simulation API is healthy. Returns ------- @@ -418,5 +438,10 @@ def health_check(self) -> bool: return False -# Global instance for use throughout the application -simulation_api_modal = SimulationAPIModal() +# Compatibility aliases remain until callers have migrated to generic names. +SimulationAPIModal = SimulationAPIClient + +# Global instances for use throughout the application. Both names intentionally +# reference the same client so a process cannot split traffic accidentally. +simulation_api = SimulationAPIClient() +simulation_api_modal = simulation_api diff --git a/policyengine_api/migration_flags.py b/policyengine_api/migration_flags.py index c270fa0e8..deeb38664 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_FRONT_DOORS = frozenset({"old_gateway_direct", "cloud_run_simulation_api"}) SIM_COMPUTE_BACKENDS = frozenset( {"old_gateway", "v2_shadow", "v2_percent", "v2_primary"} ) @@ -135,6 +135,15 @@ def get_sim_compute(flow: str) -> str: ) +def get_sim_front_door() -> str: + """Return the configured API v1-to-simulation-service front door.""" + return _read_choice( + "SIM_FRONT_DOOR", + DEFAULT_SIM_FRONT_DOOR, + SIM_FRONT_DOORS, + ) + + 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_front_door=get_sim_front_door(), 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..9a29f2904 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_api import simulation_api 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() diff --git a/tests/unit/libs/test_simulation_api_modal.py b/tests/unit/libs/test_simulation_api_modal.py index 93672979f..265bfc198 100644 --- a/tests/unit/libs/test_simulation_api_modal.py +++ b/tests/unit/libs/test_simulation_api_modal.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. """ @@ -28,7 +28,9 @@ from policyengine_api.libs.simulation_api_modal import ( # noqa: E402 ModalBudgetWindowBatchExecution, ModalSimulationExecution, + SimulationAPIClient, SimulationAPIModal, + resolve_simulation_api_url, ) from tests.fixtures.libs.simulation_api_modal import ( # noqa: E402 @@ -64,12 +66,49 @@ "GATEWAY_AUTH_REQUIRED", ) +FRONT_DOOR_TEST_ENV_VARS = ( + "SIM_FRONT_DOOR", + "OLD_SIMULATION_GATEWAY_URL", + "SIMULATION_API_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 FRONT_DOOR_TEST_ENV_VARS: + monkeypatch.delenv(key, raising=False) + + +def test_generic_client_name_retains_old_class_alias(): + assert SimulationAPIModal is SimulationAPIClient + + +def test_direct_front_door_prefers_explicit_old_gateway_url(monkeypatch): + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://old.example.test/") + monkeypatch.setenv("SIMULATION_API_URL", "https://new.example.test") + + assert resolve_simulation_api_url("old_gateway_direct") == ( + "https://old.example.test" + ) + + +def test_cloud_run_front_door_uses_simulation_api_url(monkeypatch): + monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://old.example.test") + monkeypatch.setenv("SIMULATION_API_URL", "https://new.example.test/") + + assert resolve_simulation_api_url("cloud_run_simulation_api") == ( + "https://new.example.test" + ) + + +def test_cloud_run_front_door_requires_simulation_api_url(monkeypatch): + monkeypatch.delenv("SIMULATION_API_URL", raising=False) + + with pytest.raises(ValueError, match="SIMULATION_API_URL is required"): + resolve_simulation_api_url("cloud_run_simulation_api") class TestModalSimulationExecution: @@ -408,7 +447,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 API request error" in log_payload["message"] assert log_payload["run_id"] == MOCK_RUN_ID class TestResolveAppName: diff --git a/tests/unit/test_asgi_factory.py b/tests/unit/test_asgi_factory.py index 766339aa8..f78f4dc6e 100644 --- a/tests/unit/test_asgi_factory.py +++ b/tests/unit/test_asgi_factory.py @@ -184,7 +184,7 @@ 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" + "policyengine_api.libs.simulation_api.SimulationAPIClient" ) as simulation_api: simulation_api.return_value.health_check.return_value = True @@ -203,7 +203,7 @@ 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" + "policyengine_api.libs.simulation_api.SimulationAPIClient" ) as simulation_api: simulation_api.return_value.health_check.return_value = False diff --git a/tests/unit/test_migration_flags.py b/tests/unit/test_migration_flags.py index 5367a4494..c509e7f0a 100644 --- a/tests/unit/test_migration_flags.py +++ b/tests/unit/test_migration_flags.py @@ -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_FRONT_DOOR", "cloud_run_simulation_api") 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_front_door == "cloud_run_simulation_api" assert context.sim_compute == "v2_shadow" From bdd96b0096fa1570e7afd385bc90609200663808 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 23 Jul 2026 18:58:33 +0300 Subject: [PATCH 2/6] feat: add guarded simulation front-door ramp --- .github/scripts/deploy_cloud_run_candidate.sh | 3 +- .github/scripts/ramp_simulation_front_door.sh | 65 +++++++++++++++++ .../scripts/validate_cloud_run_deploy_env.sh | 1 + .github/workflows/push.yml | 5 ++ .../workflows/ramp-simulation-front-door.yml | 49 +++++++++++++ tests/unit/test_cloud_run_deploy_scripts.py | 72 +++++++++++++++++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/ramp_simulation_front_door.sh create mode 100644 .github/workflows/ramp-simulation-front-door.yml diff --git a/.github/scripts/deploy_cloud_run_candidate.sh b/.github/scripts/deploy_cloud_run_candidate.sh index c06373987..511ad447e 100755 --- a/.github/scripts/deploy_cloud_run_candidate.sh +++ b/.github/scripts/deploy_cloud_run_candidate.sh @@ -12,13 +12,14 @@ env_vars=( "POLICYENGINE_DB_USER=${POLICYENGINE_DB_USER:-policyengine}" "POLICYENGINE_DB_NAME=${POLICYENGINE_DB_NAME:-policyengine}" "SIMULATION_API_URL=${SIMULATION_API_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_FRONT_DOOR=${SIM_FRONT_DOOR:-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_front_door.sh b/.github/scripts/ramp_simulation_front_door.sh new file mode 100644 index 000000000..f3bf948c7 --- /dev/null +++ b/.github/scripts/ramp_simulation_front_door.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source .github/scripts/cloud_run_env.sh +cloud_run_set_defaults + +new_revision="${SIMULATION_NEW_FRONT_DOOR_REVISION:?SIMULATION_NEW_FRONT_DOOR_REVISION is required}" +direct_revision="${SIMULATION_DIRECT_GATEWAY_REVISION:?SIMULATION_DIRECT_GATEWAY_REVISION is required}" +new_percent="${SIMULATION_NEW_FRONT_DOOR_PERCENT:?SIMULATION_NEW_FRONT_DOOR_PERCENT is required}" + +case "${new_percent}" in + 0|5|25|50|100) ;; + *) + echo "SIMULATION_NEW_FRONT_DOOR_PERCENT must be one of 0, 5, 25, 50, or 100" >&2 + exit 1 + ;; +esac + +if [ "${new_revision}" = "${direct_revision}" ]; then + echo "New-front-door and direct-gateway revisions must be different" >&2 + exit 1 +fi + +validate_revision_front_door() { + local revision="${1:?revision is required}" + local expected="${2:?expected front door 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_FRONT_DOOR") | .value' <<<"${revision_json}")" + if [[ "${actual}" != "${expected}" ]]; then + echo "Revision ${revision} has SIM_FRONT_DOOR=${actual}; expected ${expected}" >&2 + exit 1 + fi +} + +validate_revision_front_door "${new_revision}" cloud_run_simulation_api +validate_revision_front_door "${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_cloud_run_deploy_env.sh b/.github/scripts/validate_cloud_run_deploy_env.sh index 4270bb19e..d7ef8491b 100755 --- a/.github/scripts/validate_cloud_run_deploy_env.sh +++ b/.github/scripts/validate_cloud_run_deploy_env.sh @@ -29,6 +29,7 @@ cloud_run_require_env \ CLOUD_RUN_OPENAI_API_KEY_SECRET \ CLOUD_RUN_HUGGING_FACE_TOKEN_SECRET \ SIMULATION_API_URL \ + OLD_SIMULATION_GATEWAY_URL \ GATEWAY_AUTH_ISSUER \ GATEWAY_AUTH_AUDIENCE \ GATEWAY_AUTH_CLIENT_ID \ diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 47980029c..05e80abbc 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -253,6 +253,8 @@ jobs: POLICYENGINE_DB_USER: ${{ vars.POLICYENGINE_DB_USER }} POLICYENGINE_DB_NAME: ${{ vars.POLICYENGINE_DB_NAME }} SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + SIM_FRONT_DOOR: ${{ vars.SIM_FRONT_DOOR || '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 }} @@ -573,6 +575,8 @@ jobs: POLICYENGINE_DB_USER: ${{ vars.POLICYENGINE_DB_USER }} POLICYENGINE_DB_NAME: ${{ vars.POLICYENGINE_DB_NAME }} SIMULATION_API_URL: ${{ secrets.SIMULATION_API_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + SIM_FRONT_DOOR: ${{ vars.SIM_FRONT_DOOR || '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_FRONT_DOOR != 'cloud_run_simulation_api' }} run: bash .github/scripts/promote_cloud_run_tag.sh env: CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} diff --git a/.github/workflows/ramp-simulation-front-door.yml b/.github/workflows/ramp-simulation-front-door.yml new file mode 100644 index 000000000..4ccce6549 --- /dev/null +++ b/.github/workflows/ramp-simulation-front-door.yml @@ -0,0 +1,49 @@ +name: Ramp Simulation API front door + +on: + workflow_dispatch: + inputs: + new_front_door_revision: + description: API v1 revision configured for cloud_run_simulation_api + required: true + type: string + direct_gateway_revision: + description: Known-good API v1 revision configured for old_gateway_direct + required: true + type: string + new_front_door_percent: + description: Percentage routed through the Cloud Run Simulation API + required: true + type: choice + options: ["0", "5", "25", "50", "100"] + +permissions: + contents: read + id-token: write + +concurrency: + group: production-simulation-front-door-ramp + cancel-in-progress: false + +jobs: + ramp: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }} + - uses: google-github-actions/setup-gcloud@v2 + - name: Install jq + run: sudo apt-get install -y jq + - name: Validate revisions and apply requested percentage + env: + CLOUD_RUN_PROJECT: ${{ vars.CLOUD_RUN_PROJECT }} + CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} + SIMULATION_NEW_FRONT_DOOR_REVISION: ${{ inputs.new_front_door_revision }} + SIMULATION_DIRECT_GATEWAY_REVISION: ${{ inputs.direct_gateway_revision }} + SIMULATION_NEW_FRONT_DOOR_PERCENT: ${{ inputs.new_front_door_percent }} + run: bash .github/scripts/ramp_simulation_front_door.sh diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index d05c058d4..45c0fee84 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" @@ -56,6 +58,8 @@ def _required_runtime_env() -> dict[str, str]: "OPENAI_API_KEY": "raw-openai-secret-value", "HUGGING_FACE_TOKEN": "raw-hf-secret-value", "SIMULATION_API_URL": "https://simulation.example.test", + "OLD_SIMULATION_GATEWAY_URL": "https://old-gateway.example.test", + "SIM_FRONT_DOOR": "cloud_run_simulation_api", "GATEWAY_AUTH_ISSUER": "https://issuer.example.test", "GATEWAY_AUTH_AUDIENCE": "simulation-gateway", "GATEWAY_AUTH_CLIENT_ID": "client-id", @@ -298,6 +302,7 @@ 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 "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,70 @@ 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_FRONT_DOOR=cloud_run_simulation_api" 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_front_door_ramp_uses_only_approved_percentages( + new_percent, expected_traffic +): + result = _run_script( + ".github/scripts/ramp_simulation_front_door.sh", + _script_env( + SIMULATION_NEW_FRONT_DOOR_REVISION="new-revision", + SIMULATION_DIRECT_GATEWAY_REVISION="direct-revision", + SIMULATION_NEW_FRONT_DOOR_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_front_door_ramp_rejects_unapproved_percentage(): + result = _run_script( + ".github/scripts/ramp_simulation_front_door.sh", + _script_env( + SIMULATION_NEW_FRONT_DOOR_REVISION="new-revision", + SIMULATION_DIRECT_GATEWAY_REVISION="direct-revision", + SIMULATION_NEW_FRONT_DOOR_PERCENT="10", + ), + ) + + assert result.returncode == 1 + assert "must be one of 0, 5, 25, 50, or 100" in result.stderr + + +def test_simulation_front_door_ramp_validates_revision_modes_before_traffic(): + script = (REPO / ".github/scripts/ramp_simulation_front_door.sh").read_text( + encoding="utf-8" + ) + + assert ( + 'validate_revision_front_door "${new_revision}" cloud_run_simulation_api' + in script + ) + assert ( + 'validate_revision_front_door "${direct_revision}" old_gateway_direct' in script + ) + assert script.index("validate_revision_front_door") < script.index( + "gcloud run services update-traffic" + ) def test_deploy_cloud_run_candidate_pins_runtime_shape(): @@ -764,4 +833,7 @@ def test_push_workflow_promotes_production_cloud_run_after_candidate_smoke(): ) assert smoke_index < promote_index + assert "if: ${{ vars.SIM_FRONT_DOOR != 'cloud_run_simulation_api' }}" in ( + cloud_run_production + ) assert "bash .github/scripts/get_cloud_run_service_url.sh" in cloud_run_production From 8ee58c8b4ca094923a30ed861ab85b854684f537 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 00:59:49 +0300 Subject: [PATCH 3/6] fix: make stage 5 rollout operator controlled --- .github/scripts/ramp_simulation_front_door.sh | 4 ++ .../workflows/ramp-simulation-front-door.yml | 49 ------------------- policyengine_api/libs/simulation_api_modal.py | 42 +++++++++------- tests/conftest.py | 8 +++ .../test_simulation_gateway_contract.py | 6 +-- tests/unit/libs/test_simulation_api_modal.py | 46 +++++++++++++---- tests/unit/test_cloud_run_deploy_scripts.py | 8 +++ 7 files changed, 84 insertions(+), 79 deletions(-) delete mode 100644 .github/workflows/ramp-simulation-front-door.yml diff --git a/.github/scripts/ramp_simulation_front_door.sh b/.github/scripts/ramp_simulation_front_door.sh index f3bf948c7..58f2f14b9 100644 --- a/.github/scripts/ramp_simulation_front_door.sh +++ b/.github/scripts/ramp_simulation_front_door.sh @@ -1,5 +1,9 @@ #!/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 diff --git a/.github/workflows/ramp-simulation-front-door.yml b/.github/workflows/ramp-simulation-front-door.yml deleted file mode 100644 index 4ccce6549..000000000 --- a/.github/workflows/ramp-simulation-front-door.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Ramp Simulation API front door - -on: - workflow_dispatch: - inputs: - new_front_door_revision: - description: API v1 revision configured for cloud_run_simulation_api - required: true - type: string - direct_gateway_revision: - description: Known-good API v1 revision configured for old_gateway_direct - required: true - type: string - new_front_door_percent: - description: Percentage routed through the Cloud Run Simulation API - required: true - type: choice - options: ["0", "5", "25", "50", "100"] - -permissions: - contents: read - id-token: write - -concurrency: - group: production-simulation-front-door-ramp - cancel-in-progress: false - -jobs: - ramp: - runs-on: ubuntu-latest - environment: production - steps: - - uses: actions/checkout@v4 - - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }} - - uses: google-github-actions/setup-gcloud@v2 - - name: Install jq - run: sudo apt-get install -y jq - - name: Validate revisions and apply requested percentage - env: - CLOUD_RUN_PROJECT: ${{ vars.CLOUD_RUN_PROJECT }} - CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} - CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} - SIMULATION_NEW_FRONT_DOOR_REVISION: ${{ inputs.new_front_door_revision }} - SIMULATION_DIRECT_GATEWAY_REVISION: ${{ inputs.direct_gateway_revision }} - SIMULATION_NEW_FRONT_DOOR_PERCENT: ${{ inputs.new_front_door_percent }} - run: bash .github/scripts/ramp_simulation_front_door.sh diff --git a/policyengine_api/libs/simulation_api_modal.py b/policyengine_api/libs/simulation_api_modal.py index fe01ff395..950052a20 100644 --- a/policyengine_api/libs/simulation_api_modal.py +++ b/policyengine_api/libs/simulation_api_modal.py @@ -4,6 +4,7 @@ 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 @@ -17,30 +18,35 @@ from policyengine_api.migration_flags import get_sim_front_door -DEFAULT_OLD_SIMULATION_GATEWAY_URL = ( - "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" -) +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_api_url(front_door: str | None = None) -> str: """Resolve old/new upstream URLs without conflating their configuration.""" selected_front_door = front_door or get_sim_front_door() if selected_front_door == "old_gateway_direct": - # SIMULATION_API_URL is retained as a legacy fallback while deployments - # gain the explicit OLD_SIMULATION_GATEWAY_URL setting. - return ( - os.environ.get("OLD_SIMULATION_GATEWAY_URL") - or os.environ.get("SIMULATION_API_URL") - or DEFAULT_OLD_SIMULATION_GATEWAY_URL - ).rstrip("/") - - simulation_api_url = os.environ.get("SIMULATION_API_URL") - if not simulation_api_url: - raise ValueError( - "SIMULATION_API_URL is required when " - "SIM_FRONT_DOOR=cloud_run_simulation_api" - ) - return simulation_api_url.rstrip("/") + return _required_base_url("OLD_SIMULATION_GATEWAY_URL") + if selected_front_door == "cloud_run_simulation_api": + return _required_base_url("SIMULATION_API_URL") + raise ValueError(f"Unsupported simulation front door: {selected_front_door!r}") @dataclass 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..96a2a0d24 100644 --- a/tests/contract/test_simulation_gateway_contract.py +++ b/tests/contract/test_simulation_gateway_contract.py @@ -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/unit/libs/test_simulation_api_modal.py b/tests/unit/libs/test_simulation_api_modal.py index 265bfc198..0b37de7e1 100644 --- a/tests/unit/libs/test_simulation_api_modal.py +++ b/tests/unit/libs/test_simulation_api_modal.py @@ -80,6 +80,7 @@ def clear_gateway_auth_env(monkeypatch): monkeypatch.delenv(key, raising=False) for key in FRONT_DOOR_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(): @@ -111,6 +112,27 @@ def test_cloud_run_front_door_requires_simulation_api_url(monkeypatch): resolve_simulation_api_url("cloud_run_simulation_api") +def test_direct_front_door_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_api_url("old_gateway_direct") + + +@pytest.mark.parametrize( + ("front_door", "env_name"), + [ + ("old_gateway_direct", "OLD_SIMULATION_GATEWAY_URL"), + ("cloud_run_simulation_api", "SIMULATION_API_URL"), + ], +) +def test_selected_front_door_rejects_invalid_url(monkeypatch, front_door, env_name): + monkeypatch.setenv(env_name, "not-an-absolute-url") + + with pytest.raises(ValueError, match="absolute HTTP"): + resolve_simulation_api_url(front_door) + + class TestModalSimulationExecution: """Tests for the ModalSimulationExecution dataclass.""" @@ -182,7 +204,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_FRONT_DOOR": "cloud_run_simulation_api", + "SIMULATION_API_URL": MOCK_MODAL_BASE_URL, + }, ): # When api = SimulationAPIModal() @@ -190,19 +215,22 @@ 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_FRONT_DOOR"] = "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 diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index 45c0fee84..ba8eadc76 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -410,6 +410,14 @@ def test_simulation_front_door_ramp_rejects_unapproved_percentage(): assert "must be one of 0, 5, 25, 50, or 100" in result.stderr +def test_simulation_front_door_traffic_changes_are_operator_run_only(): + assert not (REPO / ".github/workflows/ramp-simulation-front-door.yml").exists() + for workflow in (REPO / ".github/workflows").glob("*.y*ml"): + assert "ramp_simulation_front_door.sh" not in workflow.read_text( + encoding="utf-8" + ) + + def test_simulation_front_door_ramp_validates_revision_modes_before_traffic(): script = (REPO / ".github/scripts/ramp_simulation_front_door.sh").read_text( encoding="utf-8" From bff2ffc7b194dd593d94fc4a0bba911971e960ef Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 24 Jul 2026 17:28:36 +0300 Subject: [PATCH 4/6] refactor: rename simulation routing to entrypoint --- .github/request-simulation-model-versions.sh | 2 +- .github/scripts/deploy_cloud_run_candidate.sh | 4 +- ..._door.sh => ramp_simulation_entrypoint.sh} | 20 +- .../scripts/validate_app_engine_deploy_env.sh | 2 +- .../scripts/validate_cloud_run_deploy_env.sh | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/push.yml | 24 +- README.md | 2 +- .../history/migration-pr1-baseline-runbook.md | 2 +- gcp/export.py | 6 +- gcp/policyengine_api/Dockerfile | 2 +- policyengine_api/asgi_factory.py | 6 +- policyengine_api/libs/simulation_api.py | 13 +- policyengine_api/libs/simulation_api_modal.py | 476 +----------------- .../libs/simulation_entrypoint.py | 456 +++++++++++++++++ policyengine_api/migration_flags.py | 20 +- policyengine_api/services/economy_service.py | 38 +- scripts/capture_migration_baseline.py | 4 +- .../test_simulation_gateway_contract.py | 8 +- ..._api_modal.py => simulation_entrypoint.py} | 8 +- tests/fixtures/services/economy_service.py | 8 +- .../test_budget_window_in_flight_dedupe.py | 16 +- ...modal.py => test_simulation_entrypoint.py} | 77 +-- tests/unit/services/test_economy_service.py | 160 +++--- tests/unit/test_asgi_factory.py | 16 +- tests/unit/test_cloud_run_deploy_scripts.py | 44 +- tests/unit/test_migration_flags.py | 8 +- uv.lock | 4 +- 28 files changed, 746 insertions(+), 684 deletions(-) rename .github/scripts/{ramp_simulation_front_door.sh => ramp_simulation_entrypoint.sh} (71%) create mode 100644 policyengine_api/libs/simulation_entrypoint.py rename tests/fixtures/libs/{simulation_api_modal.py => simulation_entrypoint.py} (96%) rename tests/unit/libs/{test_simulation_api_modal.py => test_simulation_entrypoint.py} (92%) 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 511ad447e..7ec0a5069 100755 --- a/.github/scripts/deploy_cloud_run_candidate.sh +++ b/.github/scripts/deploy_cloud_run_candidate.sh @@ -11,7 +11,7 @@ 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}" @@ -19,7 +19,7 @@ env_vars=( "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=${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_front_door.sh b/.github/scripts/ramp_simulation_entrypoint.sh similarity index 71% rename from .github/scripts/ramp_simulation_front_door.sh rename to .github/scripts/ramp_simulation_entrypoint.sh index 58f2f14b9..c22ccaa16 100644 --- a/.github/scripts/ramp_simulation_front_door.sh +++ b/.github/scripts/ramp_simulation_entrypoint.sh @@ -9,26 +9,26 @@ set -euo pipefail source .github/scripts/cloud_run_env.sh cloud_run_set_defaults -new_revision="${SIMULATION_NEW_FRONT_DOOR_REVISION:?SIMULATION_NEW_FRONT_DOOR_REVISION is required}" +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_FRONT_DOOR_PERCENT:?SIMULATION_NEW_FRONT_DOOR_PERCENT 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_FRONT_DOOR_PERCENT must be one of 0, 5, 25, 50, or 100" >&2 + 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-front-door and direct-gateway revisions must be different" >&2 + echo "New-entrypoint and direct-gateway revisions must be different" >&2 exit 1 fi -validate_revision_front_door() { +validate_revision_entrypoint() { local revision="${1:?revision is required}" - local expected="${2:?expected front door 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}" \ @@ -43,15 +43,15 @@ validate_revision_front_door() { --project "${CLOUD_RUN_PROJECT}" \ --region "${CLOUD_RUN_REGION}" \ --format=json)" - actual="$(jq -er '.spec.containers[0].env[] | select(.name == "SIM_FRONT_DOOR") | .value' <<<"${revision_json}")" + actual="$(jq -er '.spec.containers[0].env[] | select(.name == "SIM_ENTRYPOINT") | .value' <<<"${revision_json}")" if [[ "${actual}" != "${expected}" ]]; then - echo "Revision ${revision} has SIM_FRONT_DOOR=${actual}; expected ${expected}" >&2 + echo "Revision ${revision} has SIM_ENTRYPOINT=${actual}; expected ${expected}" >&2 exit 1 fi } -validate_revision_front_door "${new_revision}" cloud_run_simulation_api -validate_revision_front_door "${direct_revision}" old_gateway_direct +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" 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 d7ef8491b..f69463c85 100755 --- a/.github/scripts/validate_cloud_run_deploy_env.sh +++ b/.github/scripts/validate_cloud_run_deploy_env.sh @@ -28,7 +28,7 @@ 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 \ 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 05e80abbc..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,9 +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_FRONT_DOOR: ${{ vars.SIM_FRONT_DOOR || 'old_gateway_direct' }} + 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 }} @@ -403,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: @@ -444,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 }} @@ -462,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 }} @@ -574,9 +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_FRONT_DOOR: ${{ vars.SIM_FRONT_DOOR || 'old_gateway_direct' }} + 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 }} @@ -599,7 +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_FRONT_DOOR != 'cloud_run_simulation_api' }} + 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 8bb4c0a6f..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 import SimulationAPIClient + from policyengine_api.libs.simulation_entrypoint import ( + SimulationEntrypointClient, + ) try: - gateway_healthy = SimulationAPIClient().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 index 47bd633ff..8d95ceee1 100644 --- a/policyengine_api/libs/simulation_api.py +++ b/policyengine_api/libs/simulation_api.py @@ -1,25 +1,30 @@ -"""Generic Simulation API client exports. +"""Compatibility exports for the Simulation Entrypoint client. -The implementation remains in ``simulation_api_modal`` temporarily so existing -imports and test patches keep working during the Stage 5 cutover. +New code should import from :mod:`policyengine_api.libs.simulation_entrypoint`. """ -from policyengine_api.libs.simulation_api_modal import ( +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 950052a20..3632c610b 100644 --- a/policyengine_api/libs/simulation_api_modal.py +++ b/policyengine_api/libs/simulation_api_modal.py @@ -1,453 +1,27 @@ -"""Compatibility HTTP client for the configured Simulation API front door.""" - -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, +"""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, ) -from policyengine_api.migration_flags import get_sim_front_door - - -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_api_url(front_door: str | None = None) -> str: - """Resolve old/new upstream URLs without conflating their configuration.""" - selected_front_door = front_door or get_sim_front_door() - if selected_front_door == "old_gateway_direct": - return _required_base_url("OLD_SIMULATION_GATEWAY_URL") - if selected_front_door == "cloud_run_simulation_api": - return _required_base_url("SIMULATION_API_URL") - raise ValueError(f"Unsupported simulation front door: {selected_front_door!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 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 SimulationAPIClient: - """ - HTTP client for the configured Simulation API front door. - - This class provides methods for submitting simulation jobs and - polling for their status/results via HTTP endpoints. - """ - - def __init__(self, front_door: str | None = None): - self.front_door = front_door or get_sim_front_door() - self.base_url = resolve_simulation_api_url(self.front_door) - 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( - "SimulationAPIClient 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 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": "Simulation API 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 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"Simulation 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 selected Simulation 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"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 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"Simulation 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"Simulation 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 selected Simulation API 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 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 - - -# Compatibility aliases remain until callers have migrated to generic names. -SimulationAPIModal = SimulationAPIClient -# Global instances for use throughout the application. Both names intentionally -# reference the same client so a process cannot split traffic accidentally. -simulation_api = SimulationAPIClient() -simulation_api_modal = simulation_api +__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 deeb38664..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_simulation_api"}) +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,12 +135,12 @@ def get_sim_compute(flow: str) -> str: ) -def get_sim_front_door() -> str: - """Return the configured API v1-to-simulation-service front door.""" +def get_sim_entrypoint() -> str: + """Return the configured API v1-to-simulation-service entrypoint.""" return _read_choice( - "SIM_FRONT_DOOR", - DEFAULT_SIM_FRONT_DOOR, - SIM_FRONT_DOORS, + "SIM_ENTRYPOINT", + DEFAULT_SIM_ENTRYPOINT, + SIM_ENTRYPOINTS, ) @@ -169,7 +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=get_sim_front_door(), + 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 9a29f2904..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 import simulation_api +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 ( @@ -457,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 @@ -488,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 @@ -689,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, @@ -812,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, ) @@ -829,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, @@ -876,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, @@ -948,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(), @@ -1058,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/contract/test_simulation_gateway_contract.py b/tests/contract/test_simulation_gateway_contract.py index 96a2a0d24..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: 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 92% rename from tests/unit/libs/test_simulation_api_modal.py rename to tests/unit/libs/test_simulation_entrypoint.py index 0b37de7e1..681c996d1 100644 --- a/tests/unit/libs/test_simulation_api_modal.py +++ b/tests/unit/libs/test_simulation_entrypoint.py @@ -25,15 +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, - SimulationAPIClient, + SimulationEntrypointClient, SimulationAPIModal, - resolve_simulation_api_url, + 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, @@ -55,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", @@ -66,10 +66,10 @@ "GATEWAY_AUTH_REQUIRED", ) -FRONT_DOOR_TEST_ENV_VARS = ( - "SIM_FRONT_DOOR", +ENTRYPOINT_TEST_ENV_VARS = ( + "SIM_ENTRYPOINT", "OLD_SIMULATION_GATEWAY_URL", - "SIMULATION_API_URL", + "SIMULATION_ENTRYPOINT_URL", ) @@ -78,59 +78,72 @@ 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 FRONT_DOOR_TEST_ENV_VARS: + 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 SimulationAPIClient + assert SimulationAPIModal is SimulationEntrypointClient -def test_direct_front_door_prefers_explicit_old_gateway_url(monkeypatch): +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_API_URL", "https://new.example.test") + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", "https://new.example.test") - assert resolve_simulation_api_url("old_gateway_direct") == ( + assert resolve_simulation_entrypoint_url("old_gateway_direct") == ( "https://old.example.test" ) -def test_cloud_run_front_door_uses_simulation_api_url(monkeypatch): +def test_cloud_run_entrypoint_uses_entrypoint_url(monkeypatch): monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://old.example.test") - monkeypatch.setenv("SIMULATION_API_URL", "https://new.example.test/") + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", "https://new.example.test/") - assert resolve_simulation_api_url("cloud_run_simulation_api") == ( + assert resolve_simulation_entrypoint_url("cloud_run_simulation_entrypoint") == ( "https://new.example.test" ) -def test_cloud_run_front_door_requires_simulation_api_url(monkeypatch): - monkeypatch.delenv("SIMULATION_API_URL", raising=False) +def test_cloud_run_entrypoint_requires_entrypoint_url(monkeypatch): + monkeypatch.delenv("SIMULATION_ENTRYPOINT_URL", raising=False) - with pytest.raises(ValueError, match="SIMULATION_API_URL is required"): - resolve_simulation_api_url("cloud_run_simulation_api") + with pytest.raises(ValueError, match="SIMULATION_ENTRYPOINT_URL is required"): + resolve_simulation_entrypoint_url("cloud_run_simulation_entrypoint") -def test_direct_front_door_requires_old_gateway_url(monkeypatch): +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_api_url("old_gateway_direct") + resolve_simulation_entrypoint_url("old_gateway_direct") @pytest.mark.parametrize( - ("front_door", "env_name"), + ("entrypoint", "env_name"), [ ("old_gateway_direct", "OLD_SIMULATION_GATEWAY_URL"), - ("cloud_run_simulation_api", "SIMULATION_API_URL"), + ("cloud_run_simulation_entrypoint", "SIMULATION_ENTRYPOINT_URL"), ], ) -def test_selected_front_door_rejects_invalid_url(monkeypatch, front_door, env_name): +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_api_url(front_door) + resolve_simulation_entrypoint_url(entrypoint) class TestModalSimulationExecution: @@ -205,8 +218,8 @@ def test__given_env_var_set__then_uses_env_url(self, mock_httpx_client): with patch.dict( "os.environ", { - "SIM_FRONT_DOOR": "cloud_run_simulation_api", - "SIMULATION_API_URL": MOCK_MODAL_BASE_URL, + "SIM_ENTRYPOINT": "cloud_run_simulation_entrypoint", + "SIMULATION_ENTRYPOINT_URL": MOCK_MODAL_BASE_URL, }, ): # When @@ -222,7 +235,7 @@ def test__given_selected_url_not_set__then_fails_startup( with patch.dict("os.environ", {}, clear=False): import os - os.environ["SIM_FRONT_DOOR"] = "old_gateway_direct" + os.environ["SIM_ENTRYPOINT"] = "old_gateway_direct" os.environ.pop("OLD_SIMULATION_GATEWAY_URL", None) # When / Then @@ -236,7 +249,7 @@ 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") @@ -251,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", @@ -475,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 "Simulation 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 f78f4dc6e..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.SimulationAPIClient" - ) 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.SimulationAPIClient" - ) 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 ba8eadc76..a9a67f760 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -57,9 +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_FRONT_DOOR": "cloud_run_simulation_api", + "SIM_ENTRYPOINT": "cloud_run_simulation_entrypoint", "GATEWAY_AUTH_ISSUER": "https://issuer.example.test", "GATEWAY_AUTH_AUDIENCE": "simulation-gateway", "GATEWAY_AUTH_CLIENT_ID": "client-id", @@ -92,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, @@ -301,7 +301,7 @@ 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 @@ -364,7 +364,7 @@ def test_deploy_cloud_run_candidate_dry_run_never_shifts_traffic(): assert ( "OLD_SIMULATION_GATEWAY_URL=https://old-gateway.example.test" in result.stdout ) - assert "SIM_FRONT_DOOR=cloud_run_simulation_api" in result.stdout + assert "SIM_ENTRYPOINT=cloud_run_simulation_entrypoint" in result.stdout @pytest.mark.parametrize( @@ -377,15 +377,15 @@ def test_deploy_cloud_run_candidate_dry_run_never_shifts_traffic(): ("100", "new-revision=100"), ], ) -def test_simulation_front_door_ramp_uses_only_approved_percentages( +def test_simulation_entrypoint_ramp_uses_only_approved_percentages( new_percent, expected_traffic ): result = _run_script( - ".github/scripts/ramp_simulation_front_door.sh", + ".github/scripts/ramp_simulation_entrypoint.sh", _script_env( - SIMULATION_NEW_FRONT_DOOR_REVISION="new-revision", + SIMULATION_NEW_ENTRYPOINT_REVISION="new-revision", SIMULATION_DIRECT_GATEWAY_REVISION="direct-revision", - SIMULATION_NEW_FRONT_DOOR_PERCENT=new_percent, + SIMULATION_NEW_ENTRYPOINT_PERCENT=new_percent, ), ) @@ -396,13 +396,13 @@ def test_simulation_front_door_ramp_uses_only_approved_percentages( assert f"--to-revisions {expected_dry_run}" in result.stdout -def test_simulation_front_door_ramp_rejects_unapproved_percentage(): +def test_simulation_entrypoint_ramp_rejects_unapproved_percentage(): result = _run_script( - ".github/scripts/ramp_simulation_front_door.sh", + ".github/scripts/ramp_simulation_entrypoint.sh", _script_env( - SIMULATION_NEW_FRONT_DOOR_REVISION="new-revision", + SIMULATION_NEW_ENTRYPOINT_REVISION="new-revision", SIMULATION_DIRECT_GATEWAY_REVISION="direct-revision", - SIMULATION_NEW_FRONT_DOOR_PERCENT="10", + SIMULATION_NEW_ENTRYPOINT_PERCENT="10", ), ) @@ -410,27 +410,27 @@ def test_simulation_front_door_ramp_rejects_unapproved_percentage(): assert "must be one of 0, 5, 25, 50, or 100" in result.stderr -def test_simulation_front_door_traffic_changes_are_operator_run_only(): - assert not (REPO / ".github/workflows/ramp-simulation-front-door.yml").exists() +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_front_door.sh" not in workflow.read_text( + assert "ramp_simulation_entrypoint.sh" not in workflow.read_text( encoding="utf-8" ) -def test_simulation_front_door_ramp_validates_revision_modes_before_traffic(): - script = (REPO / ".github/scripts/ramp_simulation_front_door.sh").read_text( +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_front_door "${new_revision}" cloud_run_simulation_api' + 'validate_revision_entrypoint "${new_revision}" cloud_run_simulation_entrypoint' in script ) assert ( - 'validate_revision_front_door "${direct_revision}" old_gateway_direct' in script + 'validate_revision_entrypoint "${direct_revision}" old_gateway_direct' in script ) - assert script.index("validate_revision_front_door") < script.index( + assert script.index("validate_revision_entrypoint") < script.index( "gcloud run services update-traffic" ) @@ -841,7 +841,7 @@ def test_push_workflow_promotes_production_cloud_run_after_candidate_smoke(): ) assert smoke_index < promote_index - assert "if: ${{ vars.SIM_FRONT_DOOR != 'cloud_run_simulation_api' }}" in ( + 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 c509e7f0a..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_simulation_api") + 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_simulation_api" + 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" }, From b4fad3e23c1860fabb2f6d2e1aee801973249f82 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 29 Jul 2026 15:07:34 +0300 Subject: [PATCH 5/6] docs: add Stage 5 changelog fragment --- changelog.d/stage5-simulation-entrypoint.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/stage5-simulation-entrypoint.added.md diff --git a/changelog.d/stage5-simulation-entrypoint.added.md b/changelog.d/stage5-simulation-entrypoint.added.md new file mode 100644 index 000000000..4ac61e529 --- /dev/null +++ b/changelog.d/stage5-simulation-entrypoint.added.md @@ -0,0 +1 @@ +Add a selectable Cloud Run Simulation Entrypoint for API v1 simulation calls while retaining direct Modal routing for controlled rollout and rollback. From 357f7e6de959cc1b4f9d42a1706b1484735282c6 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 29 Jul 2026 19:05:42 +0300 Subject: [PATCH 6/6] fix: automate exact Cloud Run revision promotion --- .../capture_cloud_run_service_state.sh | 46 ++ .github/scripts/get_cloud_run_service_url.sh | 17 - .github/scripts/get_cloud_run_tag_url.sh | 31 -- .github/scripts/promote_cloud_run_tag.sh | 18 - .github/scripts/ramp_simulation_entrypoint.sh | 69 --- .../resolve_cloud_run_candidate_state.sh | 95 ++++ .github/scripts/set_cloud_run_revision.sh | 113 +++++ .../scripts/validate_app_engine_deploy_env.sh | 2 + .github/workflows/push.yml | 136 ++++-- README.md | 2 + .../stage5-simulation-entrypoint.added.md | 2 +- docs/engineering/skills/testing.md | 22 +- docs/migration/cloud-run-operations.md | 27 +- gcp/export.py | 6 + gcp/policyengine_api/Dockerfile | 2 + tests/unit/test_cloud_run_deploy_scripts.py | 451 +++++++++++++++--- 16 files changed, 771 insertions(+), 268 deletions(-) create mode 100755 .github/scripts/capture_cloud_run_service_state.sh delete mode 100644 .github/scripts/get_cloud_run_service_url.sh delete mode 100755 .github/scripts/get_cloud_run_tag_url.sh delete mode 100644 .github/scripts/promote_cloud_run_tag.sh delete mode 100644 .github/scripts/ramp_simulation_entrypoint.sh create mode 100755 .github/scripts/resolve_cloud_run_candidate_state.sh create mode 100755 .github/scripts/set_cloud_run_revision.sh diff --git a/.github/scripts/capture_cloud_run_service_state.sh b/.github/scripts/capture_cloud_run_service_state.sh new file mode 100755 index 000000000..14dc67c4b --- /dev/null +++ b/.github/scripts/capture_cloud_run_service_state.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +# Capture the stable URL and sole 100%-serving revision before a candidate +# deployment. Promotion consumes this revision as its optimistic concurrency +# guard; rollback consumes it as the exact restoration target. + +set -euo pipefail + +source .github/scripts/cloud_run_env.sh +cloud_run_set_defaults + +cloud_run_require_env \ + CLOUD_RUN_PROJECT \ + CLOUD_RUN_REGION \ + CLOUD_RUN_SERVICE + +if [[ "${CLOUD_RUN_DRY_RUN:-0}" == "1" ]]; then + echo "stable_url=https://${CLOUD_RUN_SERVICE}-dry-run.a.run.app" + echo "revision=${CLOUD_RUN_SERVICE}-00001-dry" + exit 0 +fi + +gcloud_bin="${GCLOUD_BIN:-gcloud}" +service_json="$("${gcloud_bin}" run services describe "${CLOUD_RUN_SERVICE}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)" + +stable_url="$(jq -er ' + .status.url + | select(type == "string" and length > 0) +' <<<"${service_json}")" +revision="$(jq -er ' + [ + .status.traffic[]? + | select((.percent // 0) == 100) + | .revisionName + | select(type == "string" and length > 0) + ] + | if length == 1 then .[0] + else error("service must have exactly one revision at 100 percent") + end +' <<<"${service_json}")" + +printf 'stable_url=%s\nrevision=%s\n' "${stable_url}" "${revision}" diff --git a/.github/scripts/get_cloud_run_service_url.sh b/.github/scripts/get_cloud_run_service_url.sh deleted file mode 100644 index 1dc193cc3..000000000 --- a/.github/scripts/get_cloud_run_service_url.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source .github/scripts/cloud_run_env.sh -cloud_run_set_defaults - -if [[ "${CLOUD_RUN_DRY_RUN:-0}" == "1" ]]; then - echo "https://${CLOUD_RUN_SERVICE}-dry-run.a.run.app" - exit 0 -fi - -gcloud run services describe "${CLOUD_RUN_SERVICE}" \ - --project "${CLOUD_RUN_PROJECT}" \ - --region "${CLOUD_RUN_REGION}" \ - --platform managed \ - --format 'value(status.url)' diff --git a/.github/scripts/get_cloud_run_tag_url.sh b/.github/scripts/get_cloud_run_tag_url.sh deleted file mode 100755 index e91d91462..000000000 --- a/.github/scripts/get_cloud_run_tag_url.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source .github/scripts/cloud_run_env.sh -cloud_run_set_defaults - -if [[ "${CLOUD_RUN_DRY_RUN:-0}" == "1" ]]; then - echo "https://${CLOUD_RUN_TAG}---${CLOUD_RUN_SERVICE}-dry-run.a.run.app" - exit 0 -fi - -gcloud run services describe "${CLOUD_RUN_SERVICE}" \ - --project "${CLOUD_RUN_PROJECT}" \ - --region "${CLOUD_RUN_REGION}" \ - --platform managed \ - --format json | python -c ' -import json -import os -import sys - -service = json.load(sys.stdin) -tag = os.environ["CLOUD_RUN_TAG"] -for traffic_target in service.get("status", {}).get("traffic", []): - if traffic_target.get("tag") == tag and traffic_target.get("url"): - print(traffic_target["url"]) - raise SystemExit(0) - -print(f"Failed to determine Cloud Run URL for tag {tag}", file=sys.stderr) -raise SystemExit(1) -' diff --git a/.github/scripts/promote_cloud_run_tag.sh b/.github/scripts/promote_cloud_run_tag.sh deleted file mode 100644 index 751cc6b26..000000000 --- a/.github/scripts/promote_cloud_run_tag.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source .github/scripts/cloud_run_env.sh -cloud_run_set_defaults - -cloud_run_require_env \ - CLOUD_RUN_PROJECT \ - CLOUD_RUN_REGION \ - CLOUD_RUN_SERVICE \ - CLOUD_RUN_TAG - -cloud_run_run gcloud run services update-traffic "${CLOUD_RUN_SERVICE}" \ - --project "${CLOUD_RUN_PROJECT}" \ - --region "${CLOUD_RUN_REGION}" \ - --platform managed \ - --to-tags "${CLOUD_RUN_TAG}=100" diff --git a/.github/scripts/ramp_simulation_entrypoint.sh b/.github/scripts/ramp_simulation_entrypoint.sh deleted file mode 100644 index c22ccaa16..000000000 --- a/.github/scripts/ramp_simulation_entrypoint.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/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/resolve_cloud_run_candidate_state.sh b/.github/scripts/resolve_cloud_run_candidate_state.sh new file mode 100755 index 000000000..4bfe73868 --- /dev/null +++ b/.github/scripts/resolve_cloud_run_candidate_state.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Resolve a no-traffic tag once, then pin all later testing and promotion to +# the exact ready revision and immutable image represented by that tag. + +set -euo pipefail + +source .github/scripts/cloud_run_env.sh +cloud_run_set_defaults + +cloud_run_require_env \ + CLOUD_RUN_PROJECT \ + CLOUD_RUN_REGION \ + CLOUD_RUN_SERVICE \ + CLOUD_RUN_TAG + +if [[ "${CLOUD_RUN_DRY_RUN:-0}" == "1" ]]; then + echo "url=https://${CLOUD_RUN_TAG}---${CLOUD_RUN_SERVICE}-dry-run.a.run.app" + echo "revision=${CLOUD_RUN_SERVICE}-00002-dry" + echo "image=${CLOUD_RUN_IMAGE_URI%@*}@sha256:dry-run" + exit 0 +fi + +gcloud_bin="${GCLOUD_BIN:-gcloud}" +service_json="$("${gcloud_bin}" run services describe "${CLOUD_RUN_SERVICE}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)" +candidate_json="$(jq -cer --arg tag "${CLOUD_RUN_TAG}" ' + [ + .status.traffic[]? + | select(.tag == $tag) + | { + url: ( + .url + | select(type == "string" and length > 0) + ), + revision: ( + .revisionName + | select(type == "string" and length > 0) + ) + } + ] + | if length == 1 then .[0] + else error("candidate tag must resolve to exactly one traffic target") + end +' <<<"${service_json}")" +url="$(jq -er '.url' <<<"${candidate_json}")" +revision="$(jq -er '.revision' <<<"${candidate_json}")" + +revision_json="$("${gcloud_bin}" run revisions describe "${revision}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)" +revision_service="$(jq -r \ + '.metadata.labels["serving.knative.dev/service"] // empty' \ + <<<"${revision_json}")" + +if [[ "${revision_service}" != "${CLOUD_RUN_SERVICE}" ]]; then + printf 'Revision %s belongs to %s, not %s\n' \ + "${revision}" "${revision_service:-an unknown service}" \ + "${CLOUD_RUN_SERVICE}" >&2 + exit 2 +fi + +if ! jq -e ' + .status.conditions[]? + | select(.type == "Ready" and .status == "True") +' >/dev/null <<<"${revision_json}"; then + printf 'Revision %s is not Ready\n' "${revision}" >&2 + exit 2 +fi + +image="$(jq -er ' + (.status.imageDigest // .spec.containers[0].image) + | select(type == "string" and contains("@sha256:")) +' <<<"${revision_json}")" + +if [[ -n "${CLOUD_RUN_EXPECTED_REVISION:-}" \ + && "${revision}" != "${CLOUD_RUN_EXPECTED_REVISION}" ]]; then + printf 'Candidate tag %s moved: expected revision %s, found %s\n' \ + "${CLOUD_RUN_TAG}" "${CLOUD_RUN_EXPECTED_REVISION}" "${revision}" >&2 + exit 2 +fi + +if [[ -n "${CLOUD_RUN_EXPECTED_IMAGE:-}" \ + && "${image}" != "${CLOUD_RUN_EXPECTED_IMAGE}" ]]; then + printf 'Candidate image changed: expected %s, found %s\n' \ + "${CLOUD_RUN_EXPECTED_IMAGE}" "${image}" >&2 + exit 2 +fi + +printf 'url=%s\nrevision=%s\nimage=%s\n' "${url}" "${revision}" "${image}" diff --git a/.github/scripts/set_cloud_run_revision.sh b/.github/scripts/set_cloud_run_revision.sh new file mode 100755 index 000000000..df8518e65 --- /dev/null +++ b/.github/scripts/set_cloud_run_revision.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash + +# Assign all stable Cloud Run service traffic to one exact, ready revision. +# The expected-current guard prevents this workflow from overwriting a traffic +# change made after its candidate deployment. Rollback uses the same script +# with the previous and candidate revisions swapped. + +set -euo pipefail + +source .github/scripts/cloud_run_env.sh +cloud_run_set_defaults + +cloud_run_require_env \ + CLOUD_RUN_PROJECT \ + CLOUD_RUN_REGION \ + CLOUD_RUN_SERVICE \ + CLOUD_RUN_TARGET_REVISION \ + CLOUD_RUN_EXPECTED_CURRENT_REVISION + +target_revision="${CLOUD_RUN_TARGET_REVISION}" +expected_current_revision="${CLOUD_RUN_EXPECTED_CURRENT_REVISION}" + +for revision in "${target_revision}" "${expected_current_revision}"; do + case "${revision}" in + [Ll][Aa][Tt][Ee][Ss][Tt]) + echo "Cloud Run traffic targets must be exact; LATEST is not allowed" >&2 + exit 2 + ;; + esac +done + +gcloud_bin="${GCLOUD_BIN:-gcloud}" + +if [[ "${CLOUD_RUN_DRY_RUN:-0}" == "1" ]]; then + cloud_run_run "${gcloud_bin}" run services update-traffic \ + "${CLOUD_RUN_SERVICE}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --to-revisions "${target_revision}=100" + exit 0 +fi + +active_revision() { + jq -er ' + [ + .status.traffic[]? + | select((.percent // 0) == 100) + | .revisionName + | select(type == "string" and length > 0) + ] + | if length == 1 then .[0] + else error("service must have exactly one revision at 100 percent") + end + ' +} + +service_json="$("${gcloud_bin}" run services describe "${CLOUD_RUN_SERVICE}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)" +current_revision="$(active_revision <<<"${service_json}")" + +if [[ "${current_revision}" != "${expected_current_revision}" ]]; then + printf 'Stable traffic changed after deployment: expected %s, found %s\n' \ + "${expected_current_revision}" "${current_revision}" >&2 + exit 2 +fi + +revision_json="$("${gcloud_bin}" run revisions describe "${target_revision}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)" +revision_service="$(jq -r \ + '.metadata.labels["serving.knative.dev/service"] // empty' \ + <<<"${revision_json}")" + +if [[ "${revision_service}" != "${CLOUD_RUN_SERVICE}" ]]; then + printf 'Revision %s belongs to %s, not %s\n' \ + "${target_revision}" "${revision_service:-an unknown service}" \ + "${CLOUD_RUN_SERVICE}" >&2 + exit 2 +fi + +if ! jq -e ' + .status.conditions[]? + | select(.type == "Ready" and .status == "True") +' >/dev/null <<<"${revision_json}"; then + printf 'Revision %s is not Ready\n' "${target_revision}" >&2 + exit 2 +fi + +"${gcloud_bin}" run services update-traffic "${CLOUD_RUN_SERVICE}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --to-revisions "${target_revision}=100" + +updated_service_json="$("${gcloud_bin}" run services describe \ + "${CLOUD_RUN_SERVICE}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)" +updated_revision="$(active_revision <<<"${updated_service_json}")" + +if [[ "${updated_revision}" != "${target_revision}" ]]; then + printf 'Traffic update did not activate %s; found %s\n' \ + "${target_revision}" "${updated_revision}" >&2 + exit 2 +fi diff --git a/.github/scripts/validate_app_engine_deploy_env.sh b/.github/scripts/validate_app_engine_deploy_env.sh index fa49b8e0f..60393abcd 100644 --- a/.github/scripts/validate_app_engine_deploy_env.sh +++ b/.github/scripts/validate_app_engine_deploy_env.sh @@ -4,6 +4,8 @@ set -euo pipefail required=( SIMULATION_ENTRYPOINT_URL + OLD_SIMULATION_GATEWAY_URL + SIM_ENTRYPOINT GATEWAY_AUTH_ISSUER GATEWAY_AUTH_AUDIENCE GATEWAY_AUTH_CLIENT_ID diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 8daf5acc9..f3d4f8c58 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -10,6 +10,7 @@ env: concurrency: group: deploy + cancel-in-progress: false jobs: Lint: @@ -69,7 +70,7 @@ jobs: run: | pip install towncrier python .github/bump_version.py - towncrier build --yes --version $(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") + towncrier build --yes --version "$(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))")" - name: Preview changelog update run: ".github/get-changelog-diff.sh" - name: Update changelog @@ -152,6 +153,8 @@ jobs: # by gcp/export.py. Long-term target is a generic image plus runtime # config / Secret Manager lookups instead of image bake-in. SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + 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 }} @@ -169,6 +172,8 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + 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 }} @@ -187,6 +192,8 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + 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 }} @@ -222,7 +229,11 @@ jobs: id-token: write outputs: tag: ${{ steps.cloud_run.outputs.revision_tag }} - url: ${{ steps.cloud_run_url.outputs.url }} + url: ${{ steps.candidate.outputs.url }} + revision: ${{ steps.candidate.outputs.revision }} + image: ${{ steps.candidate.outputs.image }} + stable_url: ${{ steps.previous.outputs.stable_url }} + previous_revision: ${{ steps.previous.outputs.revision }} steps: - name: Checkout repo uses: actions/checkout@v4 @@ -233,6 +244,11 @@ jobs: service_account: "${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }}" - name: Set up GCloud uses: "google-github-actions/setup-gcloud@v2" + - name: Install jq + run: sudo apt-get install -y jq + - name: Capture current Cloud Run staging state + id: previous + run: bash .github/scripts/capture_cloud_run_service_state.sh >> "$GITHUB_OUTPUT" - name: Compute Cloud Run staging metadata id: cloud_run run: | @@ -254,21 +270,18 @@ jobs: POLICYENGINE_DB_NAME: ${{ vars.POLICYENGINE_DB_NAME }} SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} - SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT || 'old_gateway_direct' }} + 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 }} GATEWAY_AUTH_CLIENT_SECRET_RESOURCE: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET_RESOURCE }} - - name: Resolve Cloud Run staging URL - id: cloud_run_url - run: | - url="$(bash .github/scripts/get_cloud_run_tag_url.sh)" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "Cloud Run staging URL: ${url}" + - name: Resolve exact Cloud Run staging candidate + id: candidate + run: bash .github/scripts/resolve_cloud_run_candidate_state.sh >> "$GITHUB_OUTPUT" env: CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} - name: Wait for Cloud Run staging health - run: bash .github/scripts/health_check.sh "${{ steps.cloud_run_url.outputs.url }}/readiness-check" + run: bash .github/scripts/health_check.sh "${{ steps.candidate.outputs.url }}/readiness-check" integration-tests-staging: name: Run App Engine staging integration tests @@ -340,7 +353,7 @@ jobs: - name: Install staging test dependencies run: pip install pytest httpx - name: Run staging smoke test - run: python -m pytest tests/integration/test_live_calculate.py tests/integration/test_live_economy.py tests/integration/test_live_budget_window_cache.py -v + run: python -m pytest tests/integration/test_cloud_run_candidate.py tests/integration/test_live_calculate.py tests/integration/test_live_economy.py tests/integration/test_live_budget_window_cache.py -v env: API_BASE_URL: ${{ needs.deploy-cloud-run-staging.outputs.url }} STAGING_API_TEST_PROBE_ID: cloud-run-${{ needs.deploy-cloud-run-staging.outputs.tag }} @@ -361,8 +374,6 @@ jobs: permissions: contents: read id-token: write - outputs: - url: ${{ steps.cloud_run_service_url.outputs.url }} steps: - name: Checkout repo uses: actions/checkout@v4 @@ -373,18 +384,37 @@ jobs: service_account: "${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }}" - name: Set up GCloud uses: "google-github-actions/setup-gcloud@v2" - - name: Promote Cloud Run staging candidate - run: bash .github/scripts/promote_cloud_run_tag.sh + - name: Install jq + run: sudo apt-get install -y jq + - name: Verify exact tested Cloud Run staging candidate + run: bash .github/scripts/resolve_cloud_run_candidate_state.sh env: CLOUD_RUN_TAG: ${{ needs.deploy-cloud-run-staging.outputs.tag }} - - name: Resolve Cloud Run staging service URL - id: cloud_run_service_url - run: | - url="$(bash .github/scripts/get_cloud_run_service_url.sh)" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "Cloud Run staging service URL: ${url}" + CLOUD_RUN_EXPECTED_REVISION: ${{ needs.deploy-cloud-run-staging.outputs.revision }} + CLOUD_RUN_EXPECTED_IMAGE: ${{ needs.deploy-cloud-run-staging.outputs.image }} + - name: Promote Cloud Run staging candidate + id: promote + continue-on-error: true + run: bash .github/scripts/set_cloud_run_revision.sh + env: + CLOUD_RUN_TARGET_REVISION: ${{ needs.deploy-cloud-run-staging.outputs.revision }} + CLOUD_RUN_EXPECTED_CURRENT_REVISION: ${{ needs.deploy-cloud-run-staging.outputs.previous_revision }} - name: Wait for Cloud Run staging service health - run: bash .github/scripts/health_check.sh "${{ steps.cloud_run_service_url.outputs.url }}/readiness-check" + id: stable_health + if: ${{ steps.promote.outcome == 'success' }} + continue-on-error: true + run: bash .github/scripts/health_check.sh "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/readiness-check" + - name: Restore previous Cloud Run staging revision + if: ${{ steps.promote.outcome == 'failure' || steps.stable_health.outcome == 'failure' }} + run: | + bash .github/scripts/set_cloud_run_revision.sh + bash .github/scripts/health_check.sh "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/readiness-check" + env: + CLOUD_RUN_TARGET_REVISION: ${{ needs.deploy-cloud-run-staging.outputs.previous_revision }} + CLOUD_RUN_EXPECTED_CURRENT_REVISION: ${{ needs.deploy-cloud-run-staging.outputs.revision }} + - name: Fail after unsuccessful Cloud Run staging promotion + if: ${{ always() && (steps.promote.outcome == 'failure' || steps.stable_health.outcome == 'failure') }} + run: exit 1 ensure-production-model-version-aligns-with-sim-api: name: Ensure production model version aligns with simulation API @@ -409,7 +439,7 @@ jobs: deploy-production-candidate: name: Deploy production App Engine candidate runs-on: ubuntu-latest - needs: deploy-staging + needs: ensure-production-model-version-aligns-with-sim-api if: | (github.repository == 'PolicyEngine/policyengine-api') && (github.event.head_commit.message == 'Update PolicyEngine API') @@ -445,6 +475,8 @@ jobs: # by gcp/export.py. Long-term target is a generic image plus runtime # config / Secret Manager lookups instead of image bake-in. SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + 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 }} @@ -463,6 +495,8 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} + OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} + 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 }} @@ -561,6 +595,11 @@ jobs: service_account: "${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }}" - name: Set up GCloud uses: "google-github-actions/setup-gcloud@v2" + - name: Install jq + run: sudo apt-get install -y jq + - name: Capture current Cloud Run production state + id: previous + run: bash .github/scripts/capture_cloud_run_service_state.sh >> "$GITHUB_OUTPUT" - name: Compute Cloud Run candidate metadata id: cloud_run run: | @@ -576,41 +615,54 @@ jobs: POLICYENGINE_DB_NAME: ${{ vars.POLICYENGINE_DB_NAME }} SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} OLD_SIMULATION_GATEWAY_URL: ${{ secrets.OLD_SIMULATION_GATEWAY_URL }} - SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT || 'old_gateway_direct' }} + 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 }} GATEWAY_AUTH_CLIENT_SECRET_RESOURCE: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET_RESOURCE }} - - name: Resolve Cloud Run candidate URL - id: cloud_run_url - run: | - url="$(bash .github/scripts/get_cloud_run_tag_url.sh)" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "Cloud Run candidate URL: ${url}" + - name: Resolve exact Cloud Run production candidate + id: candidate + run: bash .github/scripts/resolve_cloud_run_candidate_state.sh >> "$GITHUB_OUTPUT" env: CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} - name: Wait for Cloud Run candidate health - run: bash .github/scripts/health_check.sh "${{ steps.cloud_run_url.outputs.url }}/readiness-check" + run: bash .github/scripts/health_check.sh "${{ steps.candidate.outputs.url }}/readiness-check" - name: Install Cloud Run smoke test dependencies run: pip install pytest httpx - name: Run Cloud Run candidate smoke tests run: python -m pytest tests/integration/test_cloud_run_candidate.py -v env: - API_BASE_URL: ${{ steps.cloud_run_url.outputs.url }} + API_BASE_URL: ${{ steps.candidate.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 + - name: Verify exact tested Cloud Run production candidate + run: bash .github/scripts/resolve_cloud_run_candidate_state.sh env: CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} - - name: Resolve Cloud Run production service URL - id: cloud_run_service_url - run: | - url="$(bash .github/scripts/get_cloud_run_service_url.sh)" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "Cloud Run production service URL: ${url}" + CLOUD_RUN_EXPECTED_REVISION: ${{ steps.candidate.outputs.revision }} + CLOUD_RUN_EXPECTED_IMAGE: ${{ steps.candidate.outputs.image }} + - name: Promote Cloud Run production candidate + id: promote + continue-on-error: true + run: bash .github/scripts/set_cloud_run_revision.sh + env: + CLOUD_RUN_TARGET_REVISION: ${{ steps.candidate.outputs.revision }} + CLOUD_RUN_EXPECTED_CURRENT_REVISION: ${{ steps.previous.outputs.revision }} - name: Wait for Cloud Run production service health - run: bash .github/scripts/health_check.sh "${{ steps.cloud_run_service_url.outputs.url }}/readiness-check" + id: stable_health + if: ${{ steps.promote.outcome == 'success' }} + continue-on-error: true + run: bash .github/scripts/health_check.sh "${{ steps.previous.outputs.stable_url }}/readiness-check" + - name: Restore previous Cloud Run production revision + if: ${{ steps.promote.outcome == 'failure' || steps.stable_health.outcome == 'failure' }} + run: | + bash .github/scripts/set_cloud_run_revision.sh + bash .github/scripts/health_check.sh "${{ steps.previous.outputs.stable_url }}/readiness-check" + env: + CLOUD_RUN_TARGET_REVISION: ${{ steps.previous.outputs.revision }} + CLOUD_RUN_EXPECTED_CURRENT_REVISION: ${{ steps.candidate.outputs.revision }} + - name: Fail after unsuccessful Cloud Run production promotion + if: ${{ always() && (steps.promote.outcome == 'failure' || steps.stable_health.outcome == 'failure') }} + run: exit 1 cleanup-cloud-run-images: name: Clean up old Cloud Run images diff --git a/README.md b/README.md index 0f59990e4..41bf22f83 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ 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: +- `SIM_ENTRYPOINT` (`old_gateway_direct` or `cloud_run_simulation_entrypoint`) +- `OLD_SIMULATION_GATEWAY_URL` - `SIMULATION_ENTRYPOINT_URL` - `GATEWAY_AUTH_REQUIRED` - `GATEWAY_AUTH_ISSUER` diff --git a/changelog.d/stage5-simulation-entrypoint.added.md b/changelog.d/stage5-simulation-entrypoint.added.md index 4ac61e529..9a8d23c61 100644 --- a/changelog.d/stage5-simulation-entrypoint.added.md +++ b/changelog.d/stage5-simulation-entrypoint.added.md @@ -1 +1 @@ -Add a selectable Cloud Run Simulation Entrypoint for API v1 simulation calls while retaining direct Modal routing for controlled rollout and rollback. +Add a Git-selected Cloud Run Simulation Entrypoint for API v1 calls while retaining direct Modal routing, with exact-revision deployment promotion and automatic immediate rollback. diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index caa332527..eb6e7f1ef 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -57,16 +57,18 @@ fail-fast behavior rather than any managed Redis integration. Staging deployment checks should run the same live integration suite against both the App Engine staging URL and the tagged Cloud Run staging URL before -promoting the tested Cloud Run tag to the service URL. App Engine production -candidate deploys may run before the staging integration jobs finish, but must -use `APP_ENGINE_PROMOTE=0`; the traffic promotion job must remain gated on the -staging checks and production model-version alignment. Production Cloud Run -promotion should happen only after tagged candidate smoke tests pass, and should -health-check the Cloud Run service URL after promotion. Live Cloud Run candidate -checks must be explicit deployed probes. Production candidate smoke tests -require `API_BASE_URL` and should not run as part of ordinary local test -commands. These checks should stay read-only and avoid depending on specific -production data fixtures: +promoting the exact tested Cloud Run revision to the service URL. No production +deployment may begin until the staging integrations, exact-revision promotion, +and stable-URL health check pass. Production Cloud Run promotion should happen +only after tagged candidate smoke tests pass. Both environments must capture +the previously serving revision, guard against concurrent traffic changes, +re-resolve the tested tag to require the same exact revision and immutable +image, promote with `--to-revisions`, health-check the stable URL, and +automatically restore the captured revision if promotion or stable verification +fails. Live Cloud Run candidate checks must be explicit deployed probes. +Production candidate smoke tests require `API_BASE_URL` and should not run as +part of ordinary local test commands. These checks should stay read-only and +avoid depending on specific production data fixtures: ```bash API_BASE_URL=https://candidate-url python -m pytest tests/integration/test_cloud_run_candidate.py -v diff --git a/docs/migration/cloud-run-operations.md b/docs/migration/cloud-run-operations.md index 4dd8b0e97..40d983aef 100644 --- a/docs/migration/cloud-run-operations.md +++ b/docs/migration/cloud-run-operations.md @@ -20,8 +20,9 @@ intentionally not duplicated here; this document explains the **semantics**. in the planning folder). - Two Cloud Run services in `policyengine-api` / `us-central1`: - **`policyengine-api`** — the production candidate. Every push to master deploys a - tagged `--no-traffic` revision (`stage3-prod-*`), which is promoted to the service - URL after integration tests. Only CI and its own health checks call this URL today. + tagged `--no-traffic` revision (`stage3-prod-*`). CI resolves the tag once, tests + its exact revision, then assigns the stable service URL to that revision after + integration tests. - **`policyengine-api-staging`** — the staging track's service (split from the production service in migration PR 4, Stage 1). Staging jobs pin `CLOUD_RUN_SERVICE: policyengine-api-staging`; unit tests enforce that every @@ -196,6 +197,28 @@ Consequences to keep in mind: `/readiness-check` (defaults: 900s budget, 5s interval, 15s per-request `--max-time`; all env-overridable). +## Automated revision promotion and rollback + +The push workflow serializes deployments and never promotes `LATEST` or a +mutable tag. For both staging and production it: + +1. captures the stable URL and sole 100%-serving revision; +2. deploys a tagged revision with `--no-traffic`; +3. resolves the tag to an exact ready revision and immutable image digest; +4. tests the tagged URL; +5. re-resolves the tag and requires its exact revision and image digest to + remain unchanged; +6. verifies that stable traffic still matches the captured revision; +7. assigns 100% to the exact tested revision with `--to-revisions`; and +8. health-checks the stable URL. + +Promotion and stable verification are allowed to report failure long enough for +the workflow to run its rollback step. That step uses the same guarded command +with the revisions swapped, restores the captured revision, verifies both +control-plane traffic and stable health, and then leaves the deployment red. +This rollback only covers immediate deployment failures; later operational +rollback remains an explicit incident-response action. + ## IAM and bootstrap constraints - The GitHub deploy service account holds `roles/run.developer`: it can deploy to diff --git a/gcp/export.py b/gcp/export.py index 5bc0907a5..025d9d390 100644 --- a/gcp/export.py +++ b/gcp/export.py @@ -6,6 +6,8 @@ OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] HUGGING_FACE_TOKEN = os.environ["HUGGING_FACE_TOKEN"] SIMULATION_ENTRYPOINT_URL = os.environ["SIMULATION_ENTRYPOINT_URL"] +OLD_SIMULATION_GATEWAY_URL = os.environ["OLD_SIMULATION_GATEWAY_URL"] +SIM_ENTRYPOINT = os.environ["SIM_ENTRYPOINT"] 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"] @@ -31,6 +33,10 @@ dockerfile = dockerfile.replace( ".simulation_entrypoint_url", SIMULATION_ENTRYPOINT_URL ) + dockerfile = dockerfile.replace( + ".old_simulation_gateway_url", OLD_SIMULATION_GATEWAY_URL + ) + dockerfile = dockerfile.replace(".sim_entrypoint", SIM_ENTRYPOINT) 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 c92b684ba..56298b847 100644 --- a/gcp/policyengine_api/Dockerfile +++ b/gcp/policyengine_api/Dockerfile @@ -8,6 +8,8 @@ ENV ANTHROPIC_API_KEY .anthropic_api_key ENV OPENAI_API_KEY .openai_api_key ENV HUGGING_FACE_TOKEN .hugging_face_token ENV SIMULATION_ENTRYPOINT_URL .simulation_entrypoint_url +ENV OLD_SIMULATION_GATEWAY_URL .old_simulation_gateway_url +ENV SIM_ENTRYPOINT .sim_entrypoint ENV CREDENTIALS_JSON_API_V2 .credentials_json_api_v2 ENV GATEWAY_AUTH_REQUIRED 1 ENV GATEWAY_AUTH_ISSUER .gateway_auth_issuer diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index a9a67f760..af230e3e5 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -15,9 +15,9 @@ STAGING_CLOUD_RUN_SERVICE = "policyengine-api-staging" CLOUD_RUN_SERVICE_SCRIPTS = ( "scripts/deploy_cloud_run_candidate.sh", - "scripts/promote_cloud_run_tag.sh", - "scripts/get_cloud_run_tag_url.sh", - "scripts/get_cloud_run_service_url.sh", + "scripts/capture_cloud_run_service_state.sh", + "scripts/resolve_cloud_run_candidate_state.sh", + "scripts/set_cloud_run_revision.sh", ) DEDICATED_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT = ( "policyengine-api-cr-runtime@policyengine-api.iam.gserviceaccount.com" @@ -80,6 +80,109 @@ def _run_script(path: str, env: dict[str, str]) -> subprocess.CompletedProcess[s ) +def _fake_gcloud(tmp_path: Path) -> tuple[Path, Path]: + state_path = tmp_path / "cloud-run-state.json" + state_path.write_text( + json.dumps( + { + "service": PRODUCTION_CLOUD_RUN_SERVICE, + "stable_url": "https://policyengine-api.example.test", + "active_revision": "policyengine-api-00001-old", + "candidate_revision": "policyengine-api-00002-new", + "candidate_tag": "stage3-test", + "candidate_url": "https://stage3-test.example.test", + "updates": [], + } + ), + encoding="utf-8", + ) + gcloud_path = tmp_path / "gcloud" + gcloud_path.write_text( + """#!/usr/bin/env python3 +import json +import os +import sys +from pathlib import Path + +state_path = Path(os.environ["FAKE_GCLOUD_STATE"]) +state = json.loads(state_path.read_text(encoding="utf-8")) +args = sys.argv[1:] + +if args[:3] == ["run", "services", "describe"]: + traffic = [{"revisionName": state["active_revision"], "percent": 100}] + if state["candidate_revision"] != state["active_revision"]: + traffic.append( + { + "revisionName": state["candidate_revision"], + "tag": state["candidate_tag"], + "url": state["candidate_url"], + } + ) + print(json.dumps({"status": {"url": state["stable_url"], "traffic": traffic}})) +elif args[:3] == ["run", "revisions", "describe"]: + revision = args[3] + revision_service = state.get("revision_services", {}).get( + revision, state["service"] + ) + ready = revision not in state.get("not_ready_revisions", []) + print( + json.dumps( + { + "metadata": { + "labels": {"serving.knative.dev/service": revision_service} + }, + "spec": { + "containers": [ + { + "image": ( + "us-central1-docker.pkg.dev/project/repo/api" + "@sha256:candidate" + ) + } + ] + }, + "status": { + "conditions": [ + { + "type": "Ready", + "status": "True" if ready else "False", + } + ], + "imageDigest": ( + "us-central1-docker.pkg.dev/project/repo/api" + "@sha256:candidate" + ), + }, + } + ) + ) +elif args[:3] == ["run", "services", "update-traffic"]: + target = args[args.index("--to-revisions") + 1] + revision, percent = target.rsplit("=", 1) + if percent != "100": + raise SystemExit("fake gcloud only accepts 100 percent") + state["active_revision"] = revision + state["updates"].append(target) + state_path.write_text(json.dumps(state), encoding="utf-8") +else: + raise SystemExit(f"unexpected gcloud arguments: {args}") +""", + encoding="utf-8", + ) + gcloud_path.chmod(0o755) + return gcloud_path, state_path + + +def _fake_gcloud_env(gcloud_path: Path, state_path: Path) -> dict[str, str]: + return _script_env( + CLOUD_RUN_DRY_RUN="0", + CLOUD_RUN_SERVICE=PRODUCTION_CLOUD_RUN_SERVICE, + CLOUD_RUN_TAG="stage3-test", + GCLOUD_BIN=str(gcloud_path), + FAKE_GCLOUD_STATE=str(state_path), + ) + + def _run_simulation_version_guard( versions_response: dict, *args: str ) -> subprocess.CompletedProcess[str]: @@ -307,6 +410,29 @@ def test_validate_cloud_run_deploy_env_reports_missing_runtime_config(): assert "POLICYENGINE_DB_PASSWORD" not in result.stderr +def test_validate_app_engine_deploy_env_requires_both_simulation_urls_and_selector(): + result = _run_script( + ".github/scripts/validate_app_engine_deploy_env.sh", + _script_env(), + ) + + assert result.returncode == 1 + assert "SIMULATION_ENTRYPOINT_URL" in result.stderr + assert "OLD_SIMULATION_GATEWAY_URL" in result.stderr + assert "SIM_ENTRYPOINT" in result.stderr + + +def test_app_engine_image_contains_git_controlled_simulation_routing_placeholders(): + dockerfile = (REPO / "gcp/policyengine_api/Dockerfile").read_text(encoding="utf-8") + export_script = (REPO / "gcp/export.py").read_text(encoding="utf-8") + + assert "ENV SIMULATION_ENTRYPOINT_URL .simulation_entrypoint_url" in dockerfile + assert "ENV OLD_SIMULATION_GATEWAY_URL .old_simulation_gateway_url" in dockerfile + assert "ENV SIM_ENTRYPOINT .sim_entrypoint" in dockerfile + assert '".old_simulation_gateway_url", OLD_SIMULATION_GATEWAY_URL' in export_script + assert '".sim_entrypoint", SIM_ENTRYPOINT' in export_script + + def test_build_cloud_run_image_dry_run_uses_cloud_run_dockerfile(): dockerignore = REPO / "gcp/cloud_run/Dockerfile.dockerignore" @@ -367,73 +493,142 @@ def test_deploy_cloud_run_candidate_dry_run_never_shifts_traffic(): 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 +def test_manual_simulation_entrypoint_ramp_is_removed(): + assert not (REPO / ".github/scripts/ramp_simulation_entrypoint.sh").exists() + assert not (REPO / ".github/workflows/ramp-simulation-entrypoint.yml").exists() + + +def test_capture_cloud_run_service_state_records_stable_url_and_exact_revision( + tmp_path, ): + gcloud_path, state_path = _fake_gcloud(tmp_path) + 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, - ), + ".github/scripts/capture_cloud_run_service_state.sh", + _fake_gcloud_env(gcloud_path, state_path), ) 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 + assert result.stdout.splitlines() == [ + "stable_url=https://policyengine-api.example.test", + "revision=policyengine-api-00001-old", + ] -def test_simulation_entrypoint_ramp_rejects_unapproved_percentage(): +def test_resolve_cloud_run_candidate_records_exact_ready_revision_and_image(tmp_path): + gcloud_path, state_path = _fake_gcloud(tmp_path) + 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", - ), + ".github/scripts/resolve_cloud_run_candidate_state.sh", + _fake_gcloud_env(gcloud_path, state_path), ) - assert result.returncode == 1 - assert "must be one of 0, 5, 25, 50, or 100" in result.stderr + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + "url=https://stage3-test.example.test", + "revision=policyengine-api-00002-new", + ("image=us-central1-docker.pkg.dev/project/repo/api@sha256:candidate"), + ] -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_resolve_cloud_run_candidate_rejects_changed_revision(tmp_path): + gcloud_path, state_path = _fake_gcloud(tmp_path) + + result = _run_script( + ".github/scripts/resolve_cloud_run_candidate_state.sh", + { + **_fake_gcloud_env(gcloud_path, state_path), + "CLOUD_RUN_EXPECTED_REVISION": "policyengine-api-00003-unexpected", + "CLOUD_RUN_EXPECTED_IMAGE": ( + "us-central1-docker.pkg.dev/project/repo/api@sha256:candidate" + ), + }, + ) + assert result.returncode == 2 + assert "Candidate tag stage3-test moved" in result.stderr -def test_simulation_entrypoint_ramp_validates_revision_modes_before_traffic(): - script = (REPO / ".github/scripts/ramp_simulation_entrypoint.sh").read_text( - encoding="utf-8" + +def test_resolve_cloud_run_candidate_rejects_changed_image(tmp_path): + gcloud_path, state_path = _fake_gcloud(tmp_path) + + result = _run_script( + ".github/scripts/resolve_cloud_run_candidate_state.sh", + { + **_fake_gcloud_env(gcloud_path, state_path), + "CLOUD_RUN_EXPECTED_REVISION": "policyengine-api-00002-new", + "CLOUD_RUN_EXPECTED_IMAGE": ( + "us-central1-docker.pkg.dev/project/repo/api@sha256:unexpected" + ), + }, ) - assert ( - 'validate_revision_entrypoint "${new_revision}" cloud_run_simulation_entrypoint' - in script + assert result.returncode == 2 + assert "Candidate image changed" in result.stderr + + +def test_set_cloud_run_revision_promotes_and_rolls_back_exact_revisions(tmp_path): + gcloud_path, state_path = _fake_gcloud(tmp_path) + env = _fake_gcloud_env(gcloud_path, state_path) + + promote = _run_script( + ".github/scripts/set_cloud_run_revision.sh", + { + **env, + "CLOUD_RUN_TARGET_REVISION": "policyengine-api-00002-new", + "CLOUD_RUN_EXPECTED_CURRENT_REVISION": "policyengine-api-00001-old", + }, ) - assert ( - 'validate_revision_entrypoint "${direct_revision}" old_gateway_direct' in script + rollback = _run_script( + ".github/scripts/set_cloud_run_revision.sh", + { + **env, + "CLOUD_RUN_TARGET_REVISION": "policyengine-api-00001-old", + "CLOUD_RUN_EXPECTED_CURRENT_REVISION": "policyengine-api-00002-new", + }, + ) + + assert promote.returncode == 0, promote.stderr + assert rollback.returncode == 0, rollback.stderr + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["active_revision"] == "policyengine-api-00001-old" + assert state["updates"] == [ + "policyengine-api-00002-new=100", + "policyengine-api-00001-old=100", + ] + + +def test_set_cloud_run_revision_rejects_stale_expected_revision(tmp_path): + gcloud_path, state_path = _fake_gcloud(tmp_path) + + result = _run_script( + ".github/scripts/set_cloud_run_revision.sh", + { + **_fake_gcloud_env(gcloud_path, state_path), + "CLOUD_RUN_TARGET_REVISION": "policyengine-api-00002-new", + "CLOUD_RUN_EXPECTED_CURRENT_REVISION": "policyengine-api-00000-stale", + }, ) - assert script.index("validate_revision_entrypoint") < script.index( - "gcloud run services update-traffic" + + assert result.returncode == 2 + assert "Stable traffic changed after deployment" in result.stderr + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["updates"] == [] + + +@pytest.mark.parametrize("revision", ["LATEST", "latest"]) +def test_set_cloud_run_revision_rejects_latest_alias(revision): + result = _run_script( + ".github/scripts/set_cloud_run_revision.sh", + _script_env( + CLOUD_RUN_TARGET_REVISION=revision, + CLOUD_RUN_EXPECTED_CURRENT_REVISION="policyengine-api-00001-old", + ), ) + assert result.returncode == 2 + assert "must be exact; LATEST is not allowed" in result.stderr + def test_deploy_cloud_run_candidate_pins_runtime_shape(): result = _run_script( @@ -526,37 +721,45 @@ def test_push_workflow_pins_cloud_run_scaling_per_job(): assert "CLOUD_RUN_WEB_CONCURRENCY" not in workflow -def test_get_cloud_run_tag_url_dry_run_uses_candidate_tag(): +def test_resolve_cloud_run_candidate_state_dry_run_uses_candidate_tag(): result = _run_script( - ".github/scripts/get_cloud_run_tag_url.sh", + ".github/scripts/resolve_cloud_run_candidate_state.sh", _script_env(CLOUD_RUN_TAG="stage3-test", CLOUD_RUN_SERVICE="policyengine-api"), ) assert result.returncode == 0, result.stderr - assert result.stdout.strip() == ( - "https://stage3-test---policyengine-api-dry-run.a.run.app" + assert ( + "url=https://stage3-test---policyengine-api-dry-run.a.run.app" in result.stdout ) + assert "revision=policyengine-api-00002-dry" in result.stdout + assert "@sha256:dry-run" in result.stdout -def test_get_cloud_run_service_url_dry_run_uses_service_url(): +def test_capture_cloud_run_service_state_dry_run_uses_service_url(): result = _run_script( - ".github/scripts/get_cloud_run_service_url.sh", + ".github/scripts/capture_cloud_run_service_state.sh", _script_env(CLOUD_RUN_SERVICE="policyengine-api"), ) assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "https://policyengine-api-dry-run.a.run.app" + assert "stable_url=https://policyengine-api-dry-run.a.run.app" in result.stdout + assert "revision=policyengine-api-00001-dry" in result.stdout -def test_promote_cloud_run_tag_dry_run_shifts_service_traffic_to_tag(): +def test_set_cloud_run_revision_dry_run_shifts_service_traffic_to_exact_revision(): result = _run_script( - ".github/scripts/promote_cloud_run_tag.sh", - _script_env(CLOUD_RUN_TAG="stage3-test", CLOUD_RUN_SERVICE="policyengine-api"), + ".github/scripts/set_cloud_run_revision.sh", + _script_env( + CLOUD_RUN_SERVICE="policyengine-api", + CLOUD_RUN_TARGET_REVISION="policyengine-api-00002-new", + CLOUD_RUN_EXPECTED_CURRENT_REVISION="policyengine-api-00001-old", + ), ) assert result.returncode == 0, result.stderr assert "gcloud run services update-traffic policyengine-api" in result.stdout - assert "--to-tags stage3-test=100" in result.stdout + assert "--to-revisions policyengine-api-00002-new=100" in result.stdout + assert "--to-tags" not in result.stdout assert "--to-latest" not in result.stdout @@ -596,7 +799,7 @@ def test_only_production_job_promotes_the_production_cloud_run_service(): for job_name in _workflow_job_names(workflow): block = _workflow_job_block(workflow, job_name) - if "scripts/promote_cloud_run_tag.sh" not in block: + if "scripts/set_cloud_run_revision.sh" not in block: continue if job_name == "deploy-cloud-run-candidate": expected = f"CLOUD_RUN_SERVICE: {PRODUCTION_CLOUD_RUN_SERVICE}\n" @@ -641,12 +844,13 @@ def test_deploy_cloud_run_candidate_dry_run_targets_service_override(): assert f"gcloud run deploy {STAGING_CLOUD_RUN_SERVICE}" in result.stdout -def test_promote_cloud_run_tag_dry_run_targets_service_override(): +def test_set_cloud_run_revision_dry_run_targets_service_override(): result = _run_script( - ".github/scripts/promote_cloud_run_tag.sh", + ".github/scripts/set_cloud_run_revision.sh", _script_env( - CLOUD_RUN_TAG="stage3-test", CLOUD_RUN_SERVICE=STAGING_CLOUD_RUN_SERVICE, + CLOUD_RUN_TARGET_REVISION="policyengine-api-staging-00002-new", + CLOUD_RUN_EXPECTED_CURRENT_REVISION="policyengine-api-staging-00001-old", ), ) @@ -674,9 +878,15 @@ def test_push_workflow_tests_app_engine_and_cloud_run_staging_tracks(): "tests/integration/test_live_economy.py " "tests/integration/test_live_budget_window_cache.py -v" ) + cloud_run_test_command = ( + "python -m pytest tests/integration/test_cloud_run_candidate.py " + "tests/integration/test_live_calculate.py " + "tests/integration/test_live_economy.py " + "tests/integration/test_live_budget_window_cache.py -v" + ) assert live_test_command in app_engine_tests - assert live_test_command in cloud_run_tests + assert cloud_run_test_command in cloud_run_tests assert "API_BASE_URL: ${{ needs.deploy-staging.outputs.url }}" in app_engine_tests assert ( "API_BASE_URL: ${{ needs.deploy-cloud-run-staging.outputs.url }}" @@ -687,18 +897,42 @@ def test_push_workflow_tests_app_engine_and_cloud_run_staging_tracks(): assert "- integration-tests-staging-cloud-run" not in production_gate assert "- integration-tests-staging" in cloud_run_promotion assert "- integration-tests-staging-cloud-run" in cloud_run_promotion - assert "bash .github/scripts/promote_cloud_run_tag.sh" in cloud_run_promotion - assert "bash .github/scripts/get_cloud_run_service_url.sh" in cloud_run_promotion + assert "bash .github/scripts/set_cloud_run_revision.sh" in cloud_run_promotion + assert ( + "CLOUD_RUN_TARGET_REVISION: " + "${{ needs.deploy-cloud-run-staging.outputs.revision }}" in cloud_run_promotion + ) + assert "Restore previous Cloud Run staging revision" in cloud_run_promotion + assert ( + "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/readiness-check" + in cloud_run_promotion + ) + verify_index = cloud_run_promotion.index( + "Verify exact tested Cloud Run staging candidate" + ) + promote_index = cloud_run_promotion.index("Promote Cloud Run staging candidate") + assert verify_index < promote_index + assert ( + "CLOUD_RUN_EXPECTED_REVISION: " + "${{ needs.deploy-cloud-run-staging.outputs.revision }}" in cloud_run_promotion + ) + assert ( + "CLOUD_RUN_EXPECTED_IMAGE: " + "${{ needs.deploy-cloud-run-staging.outputs.image }}" in cloud_run_promotion + ) -def test_push_workflow_deploys_app_engine_production_candidate_before_staging_gate(): +def test_push_workflow_staging_fully_gates_all_production_deployments(): workflow = _push_workflow() app_engine_candidate = _workflow_job_block(workflow, "deploy-production-candidate") app_engine_promotion = _workflow_job_block(workflow, "promote-production") docker_publish = _workflow_job_block(workflow, "docker") cloud_run_production = _workflow_job_block(workflow, "deploy-cloud-run-candidate") - assert "needs: deploy-staging" in app_engine_candidate + production_gate_dependency = ( + "needs: ensure-production-model-version-aligns-with-sim-api" + ) + assert production_gate_dependency in app_engine_candidate assert 'APP_ENGINE_PROMOTE: "0"' in app_engine_candidate assert ( "bash .github/scripts/promote_app_engine_version.sh" not in app_engine_candidate @@ -713,15 +947,42 @@ def test_push_workflow_deploys_app_engine_production_candidate_before_staging_ga "${{ needs.deploy-production-candidate.outputs.version }}" in app_engine_promotion ) - assert ( - "needs: ensure-production-model-version-aligns-with-sim-api" - in cloud_run_production - ) + assert production_gate_dependency in cloud_run_production assert "needs: promote-production" in docker_publish assert "stage3-prod-" in cloud_run_production assert "Build and push Cloud Run image" not in cloud_run_production +def test_push_workflow_serializes_deployments_without_cancelling_in_progress_run(): + workflow = _push_workflow() + + assert "concurrency:\n group: deploy\n cancel-in-progress: false" in workflow + + +def test_push_workflow_pins_direct_modal_selector_in_git_for_initial_release(): + workflow = _push_workflow() + staging_deploy = _workflow_job_block(workflow, "deploy-cloud-run-staging") + production_deploy = _workflow_job_block(workflow, "deploy-cloud-run-candidate") + app_engine_staging = _workflow_job_block(workflow, "deploy-staging") + app_engine_production = _workflow_job_block( + workflow, + "deploy-production-candidate", + ) + + for job in ( + staging_deploy, + production_deploy, + app_engine_staging, + app_engine_production, + ): + assert "SIM_ENTRYPOINT: old_gateway_direct" in job + assert "${{ vars.SIM_ENTRYPOINT" not in job + assert ( + "OLD_SIMULATION_GATEWAY_URL: " + "${{ secrets.OLD_SIMULATION_GATEWAY_URL }}" in job + ) + + def test_push_workflow_uses_dedicated_cloud_run_runtime_service_account(): workflow = _push_workflow() cloud_run_staging = _workflow_job_block(workflow, "deploy-cloud-run-staging") @@ -837,11 +1098,45 @@ def test_push_workflow_promotes_production_cloud_run_after_candidate_smoke(): "python -m pytest tests/integration/test_cloud_run_candidate.py -v" ) promote_index = cloud_run_production.index( - "bash .github/scripts/promote_cloud_run_tag.sh" + "bash .github/scripts/set_cloud_run_revision.sh" ) assert smoke_index < promote_index - assert "if: ${{ vars.SIM_ENTRYPOINT != 'cloud_run_simulation_entrypoint' }}" in ( - cloud_run_production + assert "${{ vars.SIM_ENTRYPOINT" not in cloud_run_production + assert ( + "CLOUD_RUN_TARGET_REVISION: ${{ steps.candidate.outputs.revision }}" + in cloud_run_production + ) + assert ( + "CLOUD_RUN_EXPECTED_CURRENT_REVISION: " + "${{ steps.previous.outputs.revision }}" in cloud_run_production + ) + assert "Restore previous Cloud Run production revision" in cloud_run_production + assert ( + "${{ steps.previous.outputs.stable_url }}/readiness-check" + in cloud_run_production + ) + verify_index = cloud_run_production.index( + "Verify exact tested Cloud Run production candidate" ) - assert "bash .github/scripts/get_cloud_run_service_url.sh" in cloud_run_production + assert smoke_index < verify_index < promote_index + assert ( + "CLOUD_RUN_EXPECTED_REVISION: ${{ steps.candidate.outputs.revision }}" + in cloud_run_production + ) + assert ( + "CLOUD_RUN_EXPECTED_IMAGE: ${{ steps.candidate.outputs.image }}" + in cloud_run_production + ) + + +def test_push_workflow_never_uses_latest_or_tag_alias_for_cloud_run_traffic(): + workflow = _push_workflow() + promotion_script = (REPO / ".github/scripts/set_cloud_run_revision.sh").read_text( + encoding="utf-8" + ) + + assert "--to-tags" not in workflow + assert "--to-latest" not in workflow.lower() + assert "--to-revisions" in promotion_script + assert '"${target_revision}=100"' in promotion_script