diff --git a/.github/request-simulation-model-versions.sh b/.github/request-simulation-model-versions.sh index 454574745..27a68062c 100755 --- a/.github/request-simulation-model-versions.sh +++ b/.github/request-simulation-model-versions.sh @@ -2,13 +2,17 @@ set -euo pipefail -# Modal gateway version check script. +# Simulation gateway version check script. # Verifies that the PolicyEngine .py bundle used by API v1 is deployed in the -# simulation gateway before allowing API v1 deployment to proceed. US/UK -# versions are optional compatibility checks that should resolve to the same -# gateway app as the .py bundle route. +# selected simulation gateway before allowing API v1 deployment to proceed. +# US/UK versions are optional compatibility checks that should resolve to the +# same gateway app as the .py bundle route. -GATEWAY_URL="${SIMULATION_ENTRYPOINT_URL:-https://policyengine--policyengine-simulation-gateway-web-app.modal.run}" +source .github/scripts/simulation_entrypoint_env.sh + +GATEWAY_URL="$( + simulation_entrypoint_selected_url "${SIM_ENTRYPOINT:-}" +)" usage() { echo "Usage: $0 -py [-us ] [-uk ]" @@ -55,7 +59,7 @@ if [ -z "$POLICYENGINE_VERSION" ]; then usage fi -echo "Checking Modal simulation API versions..." +echo "Checking selected simulation API versions..." echo " Gateway: $GATEWAY_URL" echo " Expected PolicyEngine .py bundle: $POLICYENGINE_VERSION" if [ -n "$US_VERSION" ]; then diff --git a/.github/scripts/deploy_cloud_run_candidate.sh b/.github/scripts/deploy_cloud_run_candidate.sh index eb2fc5fc0..4f1a0dcf1 100755 --- a/.github/scripts/deploy_cloud_run_candidate.sh +++ b/.github/scripts/deploy_cloud_run_candidate.sh @@ -11,20 +11,25 @@ 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_ENTRYPOINT_URL=${SIMULATION_ENTRYPOINT_URL}" - "OLD_SIMULATION_GATEWAY_URL=${OLD_SIMULATION_GATEWAY_URL}" "GATEWAY_AUTH_REQUIRED=1" "GATEWAY_AUTH_ISSUER=${GATEWAY_AUTH_ISSUER}" "GATEWAY_AUTH_AUDIENCE=${GATEWAY_AUTH_AUDIENCE}" "GATEWAY_AUTH_CLIENT_ID=${GATEWAY_AUTH_CLIENT_ID}" "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE=${GATEWAY_AUTH_CLIENT_SECRET_RESOURCE}" "API_HOST_BACKEND=cloud_run" - "SIM_ENTRYPOINT=${SIM_ENTRYPOINT:-old_gateway_direct}" + "SIM_ENTRYPOINT=${SIM_ENTRYPOINT}" "SIM_COMPUTE_ECONOMY=old_gateway" "CLOUD_RUN_REVISION_TAG=${CLOUD_RUN_TAG}" "WEB_CONCURRENCY=${CLOUD_RUN_WEB_CONCURRENCY}" ) +if [[ -n "${OLD_SIMULATION_GATEWAY_URL:-}" ]]; then + env_vars+=("OLD_SIMULATION_GATEWAY_URL=${OLD_SIMULATION_GATEWAY_URL}") +fi +if [[ -n "${SIMULATION_ENTRYPOINT_URL:-}" ]]; then + env_vars+=("SIMULATION_ENTRYPOINT_URL=${SIMULATION_ENTRYPOINT_URL}") +fi + secret_vars=( "POLICYENGINE_DB_PASSWORD=${CLOUD_RUN_POLICYENGINE_DB_PASSWORD_SECRET}" "POLICYENGINE_GITHUB_MICRODATA_AUTH_TOKEN=${CLOUD_RUN_GITHUB_MICRODATA_TOKEN_SECRET}" diff --git a/.github/scripts/simulation_entrypoint_env.sh b/.github/scripts/simulation_entrypoint_env.sh new file mode 100644 index 000000000..a3e8389ee --- /dev/null +++ b/.github/scripts/simulation_entrypoint_env.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Shared selected-mode helpers for deployment and compatibility checks. +# The legacy SIMULATION_API_URL secret is intentionally unsupported: its value +# is opaque and must not influence which upstream API v1 calls. + +simulation_entrypoint_url_env_name() { + local entrypoint="${1:-}" + + case "${entrypoint}" in + old_gateway_direct) + printf '%s\n' "OLD_SIMULATION_GATEWAY_URL" + ;; + cloud_run_simulation_entrypoint) + printf '%s\n' "SIMULATION_ENTRYPOINT_URL" + ;; + "") + echo "SIM_ENTRYPOINT is required" >&2 + return 1 + ;; + *) + printf 'SIM_ENTRYPOINT=%q is invalid; expected old_gateway_direct or cloud_run_simulation_entrypoint\n' \ + "${entrypoint}" >&2 + return 1 + ;; + esac +} + +simulation_entrypoint_selected_url() { + local url_env_name + local url + + url_env_name="$(simulation_entrypoint_url_env_name "${1:-}")" || return + url="${!url_env_name:-}" + + if [[ -z "${url}" ]]; then + printf '%s is required when SIM_ENTRYPOINT=%s\n' \ + "${url_env_name}" "${1:-}" >&2 + return 1 + fi + + printf '%s\n' "${url}" +} diff --git a/.github/scripts/validate_app_engine_deploy_env.sh b/.github/scripts/validate_app_engine_deploy_env.sh index 60393abcd..4e081a4b0 100644 --- a/.github/scripts/validate_app_engine_deploy_env.sh +++ b/.github/scripts/validate_app_engine_deploy_env.sh @@ -2,10 +2,15 @@ set -euo pipefail +source .github/scripts/simulation_entrypoint_env.sh + +selected_url_env="$( + simulation_entrypoint_url_env_name "${SIM_ENTRYPOINT:-}" +)" + required=( - SIMULATION_ENTRYPOINT_URL - OLD_SIMULATION_GATEWAY_URL SIM_ENTRYPOINT + "${selected_url_env}" 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 f69463c85..23b2654e9 100755 --- a/.github/scripts/validate_cloud_run_deploy_env.sh +++ b/.github/scripts/validate_cloud_run_deploy_env.sh @@ -3,6 +3,7 @@ set -euo pipefail source .github/scripts/cloud_run_env.sh +source .github/scripts/simulation_entrypoint_env.sh cloud_run_set_defaults # Cloud Run rejects deploys where the traffic tag and service name together @@ -28,9 +29,13 @@ cloud_run_require_env \ CLOUD_RUN_ANTHROPIC_API_KEY_SECRET \ CLOUD_RUN_OPENAI_API_KEY_SECRET \ CLOUD_RUN_HUGGING_FACE_TOKEN_SECRET \ - SIMULATION_ENTRYPOINT_URL \ - OLD_SIMULATION_GATEWAY_URL \ + SIM_ENTRYPOINT \ GATEWAY_AUTH_ISSUER \ GATEWAY_AUTH_AUDIENCE \ GATEWAY_AUTH_CLIENT_ID \ GATEWAY_AUTH_CLIENT_SECRET_RESOURCE + +selected_url_env="$( + simulation_entrypoint_url_env_name "${SIM_ENTRYPOINT}" +)" +cloud_run_require_env "${selected_url_env}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index cb2cc45b5..2dd9b5235 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,11 +4,16 @@ on: pull_request env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true + OLD_SIMULATION_GATEWAY_URL: ${{ vars.OLD_SIMULATION_GATEWAY_URL }} jobs: ensure-policyengine-bundle-supported-by-simulation-api: name: Ensure PolicyEngine bundle is supported by simulation API runs-on: ubuntu-latest + environment: staging + env: + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} permissions: contents: read steps: @@ -19,8 +24,6 @@ jobs: - name: Install jq run: sudo apt-get install -y jq - name: Check simulation API supports updated PolicyEngine bundle - env: - 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 04f53ffee..2c64e3b21 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -7,6 +7,7 @@ on: env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true + OLD_SIMULATION_GATEWAY_URL: ${{ vars.OLD_SIMULATION_GATEWAY_URL }} concurrency: group: deploy @@ -35,14 +36,15 @@ jobs: (github.repository == 'PolicyEngine/policyengine-api') && (github.event.head_commit.message == 'Update PolicyEngine API') environment: staging + env: + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} steps: - name: Checkout repo uses: actions/checkout@v4 - name: Install jq run: sudo apt-get install -y jq - name: Check simulation API supports PolicyEngine bundle - env: - SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} run: bash .github/check-policyengine-bundle-supported.sh versioning: @@ -108,6 +110,9 @@ jobs: (github.repository == 'PolicyEngine/policyengine-api') && (github.event.head_commit.message == 'Update PolicyEngine API') environment: staging + env: + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} permissions: contents: read id-token: write @@ -152,9 +157,6 @@ 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_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 }} @@ -171,9 +173,6 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 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 }} @@ -191,9 +190,6 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 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 }} @@ -219,6 +215,8 @@ jobs: && (github.event.head_commit.message == 'Update PolicyEngine API') environment: staging env: + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} CLOUD_RUN_SERVICE: policyengine-api-staging # Staging stays scale-to-zero, single instance: it exists for per-push # validation, not capacity. Both the revision-level (--min-instances) and @@ -270,9 +268,6 @@ 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_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 }} @@ -428,14 +423,15 @@ jobs: (github.repository == 'PolicyEngine/policyengine-api') && (github.event.head_commit.message == 'Update PolicyEngine API') environment: production + env: + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} steps: - name: Checkout repo uses: actions/checkout@v4 - name: Install jq run: sudo apt-get install -y jq - name: Check simulation API supports PolicyEngine bundle - env: - SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} run: bash .github/check-policyengine-bundle-supported.sh deploy-production-candidate: @@ -446,6 +442,9 @@ jobs: (github.repository == 'PolicyEngine/policyengine-api') && (github.event.head_commit.message == 'Update PolicyEngine API') environment: production + env: + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} permissions: contents: read id-token: write @@ -476,9 +475,6 @@ 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_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 }} @@ -496,9 +492,6 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 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 }} @@ -579,6 +572,8 @@ jobs: && (github.event.head_commit.message == 'Update PolicyEngine API') environment: production env: + SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }} + SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }} CLOUD_RUN_SERVICE: policyengine-api # Sized by the Stage 2 qualification and the PR 4 host cutover — rationale # and numbers in docs/migration/cloud-run-operations.md ("Runtime shape and @@ -620,9 +615,6 @@ 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_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 }} diff --git a/README.md b/README.md index 41bf22f83..724a0e408 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,9 @@ 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` +- the URL selected by `SIM_ENTRYPOINT`: + - `OLD_SIMULATION_GATEWAY_URL` for `old_gateway_direct`; or + - `SIMULATION_ENTRYPOINT_URL` for `cloud_run_simulation_entrypoint` - `GATEWAY_AUTH_REQUIRED` - `GATEWAY_AUTH_ISSUER` - `GATEWAY_AUTH_AUDIENCE` diff --git a/changelog.d/conditional-simulation-entrypoint-config.fixed.md b/changelog.d/conditional-simulation-entrypoint-config.fixed.md new file mode 100644 index 000000000..bc7354b79 --- /dev/null +++ b/changelog.d/conditional-simulation-entrypoint-config.fixed.md @@ -0,0 +1 @@ +Require only the simulation upstream URL selected by each deployment environment, without relying on the inaccessible legacy `SIMULATION_API_URL` secret. diff --git a/docs/migration/cloud-run-operations.md b/docs/migration/cloud-run-operations.md index 6e844ada1..d4c2b3321 100644 --- a/docs/migration/cloud-run-operations.md +++ b/docs/migration/cloud-run-operations.md @@ -241,6 +241,28 @@ 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. +## Simulation entrypoint deployment configuration + +`SIM_ENTRYPOINT` is a GitHub Actions Environment variable configured separately +in the existing `staging` and `production` environments. Environment-bound +jobs expose it as the same-named environment variable. Deployment validation, +App Engine packaging, Cloud Run configuration, and the model-version +compatibility guard all require only the URL selected by that value: + +- `old_gateway_direct` requires `OLD_SIMULATION_GATEWAY_URL`; +- `cloud_run_simulation_entrypoint` requires + `SIMULATION_ENTRYPOINT_URL`. + +`OLD_SIMULATION_GATEWAY_URL` is a non-secret repository-level GitHub Actions +variable. `SIMULATION_ENTRYPOINT_URL` is a GitHub Actions Environment secret +configured separately in those same environments. Each environment-bound job +exposes both values under their same-named environment variables and passes +them to the deployed service as runtime configuration. If both URLs are +configured, both are retained on a revision, but an unselected future URL does +not block a direct-Modal deployment. The legacy `SIMULATION_API_URL` secret is +intentionally unsupported: its value is opaque, cannot be audited, and must +not silently choose a deployment upstream. + ## 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 025d9d390..a56bcbf39 100644 --- a/gcp/export.py +++ b/gcp/export.py @@ -5,9 +5,26 @@ 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_ENTRYPOINT_URL = os.environ["SIMULATION_ENTRYPOINT_URL"] -OLD_SIMULATION_GATEWAY_URL = os.environ["OLD_SIMULATION_GATEWAY_URL"] SIM_ENTRYPOINT = os.environ["SIM_ENTRYPOINT"] +SIMULATION_URL_ENV_BY_ENTRYPOINT = { + "old_gateway_direct": "OLD_SIMULATION_GATEWAY_URL", + "cloud_run_simulation_entrypoint": "SIMULATION_ENTRYPOINT_URL", +} +try: + selected_url_env = SIMULATION_URL_ENV_BY_ENTRYPOINT[SIM_ENTRYPOINT] +except KeyError as error: + raise ValueError( + "SIM_ENTRYPOINT must be old_gateway_direct or cloud_run_simulation_entrypoint" + ) from error + +selected_url = os.environ.get(selected_url_env, "") +if not selected_url: + raise ValueError( + f"{selected_url_env} is required when SIM_ENTRYPOINT={SIM_ENTRYPOINT}" + ) + +SIMULATION_ENTRYPOINT_URL = os.environ.get("SIMULATION_ENTRYPOINT_URL", "") +OLD_SIMULATION_GATEWAY_URL = os.environ.get("OLD_SIMULATION_GATEWAY_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"] diff --git a/gcp/policyengine_api/Dockerfile b/gcp/policyengine_api/Dockerfile index 56298b847..87697c4d0 100644 --- a/gcp/policyengine_api/Dockerfile +++ b/gcp/policyengine_api/Dockerfile @@ -7,9 +7,9 @@ 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_ENTRYPOINT_URL .simulation_entrypoint_url -ENV OLD_SIMULATION_GATEWAY_URL .old_simulation_gateway_url -ENV SIM_ENTRYPOINT .sim_entrypoint +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 bcc72f75a..e46e486f4 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -4,7 +4,9 @@ import os import re import shlex +import shutil import subprocess +import sys from pathlib import Path import pytest @@ -50,6 +52,17 @@ def _script_env(**overrides: str) -> dict[str, str]: return env +def _gateway_auth_env() -> dict[str, str]: + return { + "GATEWAY_AUTH_ISSUER": "https://issuer.example.test", + "GATEWAY_AUTH_AUDIENCE": "simulation-gateway", + "GATEWAY_AUTH_CLIENT_ID": "client-id", + "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE": ( + "projects/policyengine-api/secrets/gateway-client-secret/versions/latest" + ), + } + + def _required_runtime_env() -> dict[str, str]: return { "POLICYENGINE_DB_PASSWORD": "raw-db-secret-value", @@ -60,12 +73,7 @@ def _required_runtime_env() -> dict[str, str]: "SIMULATION_ENTRYPOINT_URL": "https://simulation.example.test", "OLD_SIMULATION_GATEWAY_URL": "https://old-gateway.example.test", "SIM_ENTRYPOINT": "cloud_run_simulation_entrypoint", - "GATEWAY_AUTH_ISSUER": "https://issuer.example.test", - "GATEWAY_AUTH_AUDIENCE": "simulation-gateway", - "GATEWAY_AUTH_CLIENT_ID": "client-id", - "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE": ( - "projects/policyengine-api/secrets/gateway-client-secret/versions/latest" - ), + **_gateway_auth_env(), } @@ -184,7 +192,9 @@ def _fake_gcloud_env(gcloud_path: Path, state_path: Path) -> dict[str, str]: def _run_simulation_version_guard( - versions_response: dict, *args: str + versions_response: dict, + *args: str, + entrypoint: str = "old_gateway_direct", ) -> subprocess.CompletedProcess[str]: versions_json = json.dumps(versions_response) command = ( @@ -195,7 +205,11 @@ def _run_simulation_version_guard( return subprocess.run( ["bash", "-c", command, "request-simulation-model-versions.sh", *args], cwd=REPO, - env=_script_env(SIMULATION_ENTRYPOINT_URL="https://simulation.example.test"), + env=_script_env( + SIM_ENTRYPOINT=entrypoint, + SIMULATION_ENTRYPOINT_URL="https://simulation.example.test", + OLD_SIMULATION_GATEWAY_URL="https://old-gateway.example.test", + ), text=True, capture_output=True, check=False, @@ -206,6 +220,10 @@ def _push_workflow() -> str: return (REPO / ".github/workflows/push.yml").read_text(encoding="utf-8") +def _pr_workflow() -> str: + return (REPO / ".github/workflows/pr.yml").read_text(encoding="utf-8") + + def _sync_secrets_workflow() -> str: return (REPO / ".github/workflows/sync-cloud-run-secrets.yml").read_text( encoding="utf-8" @@ -315,6 +333,61 @@ def test_simulation_version_guard_accepts_policyengine_bundle_route_only(): assert "SUCCESS: PolicyEngine bundle route is deployed and ready" in result.stdout +@pytest.mark.parametrize( + ("entrypoint", "expected_url"), + [ + ("old_gateway_direct", "https://old-gateway.example.test"), + ( + "cloud_run_simulation_entrypoint", + "https://simulation.example.test", + ), + ], +) +def test_simulation_version_guard_uses_selected_endpoint(entrypoint, expected_url): + result = _run_simulation_version_guard( + { + "policyengine": { + "4.18.5": "policyengine-simulation-py4-18-5", + }, + }, + "-py", + "4.18.5", + entrypoint=entrypoint, + ) + + assert result.returncode == 0, result.stderr + assert f"Gateway: {expected_url}" in result.stdout + + +@pytest.mark.parametrize( + ("entrypoint", "missing_url"), + [ + ("old_gateway_direct", "OLD_SIMULATION_GATEWAY_URL"), + ("cloud_run_simulation_entrypoint", "SIMULATION_ENTRYPOINT_URL"), + ], +) +def test_simulation_version_guard_rejects_missing_selected_endpoint( + entrypoint, + missing_url, +): + command = ( + "curl() { printf '{}'; }; . .github/request-simulation-model-versions.sh \"$@\"" + ) + result = subprocess.run( + ["bash", "-c", command, "request-simulation-model-versions.sh", "-py", "1.0"], + cwd=REPO, + env=_script_env(SIM_ENTRYPOINT=entrypoint), + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 1 + assert ( + f"{missing_url} is required when SIM_ENTRYPOINT={entrypoint}" in result.stderr + ) + + def test_policyengine_bundle_support_check_passes_pyproject_pin_to_guard(tmp_path): capture_path = tmp_path / "guard-args.txt" guard_script = tmp_path / "request-simulation-model-versions.sh" @@ -396,43 +469,200 @@ def test_cloud_run_startup_supervises_redis_and_server_children(): assert re.search(r"(?m)^ *wait 2>/dev/null", start_script) is None -def test_validate_cloud_run_deploy_env_reports_missing_runtime_config(): +def test_validate_cloud_run_deploy_env_requires_selector_environment_variable(): result = _run_script( ".github/scripts/validate_cloud_run_deploy_env.sh", - _script_env(), + _script_env( + OLD_SIMULATION_GATEWAY_URL="https://old-gateway.example.test", + **_gateway_auth_env(), + ), ) assert result.returncode == 1 - assert "Missing required Cloud Run deployment configuration" 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 + assert "SIM_ENTRYPOINT" in result.stderr + + +def test_validate_cloud_run_deploy_env_accepts_direct_mode_from_environment(): + result = _run_script( + ".github/scripts/validate_cloud_run_deploy_env.sh", + _script_env( + SIM_ENTRYPOINT="old_gateway_direct", + OLD_SIMULATION_GATEWAY_URL="https://old-gateway.example.test", + **_gateway_auth_env(), + ), + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("entrypoint", "selected_url_env", "selected_url"), + [ + ( + "old_gateway_direct", + "OLD_SIMULATION_GATEWAY_URL", + "https://old-gateway.example.test", + ), + ( + "cloud_run_simulation_entrypoint", + "SIMULATION_ENTRYPOINT_URL", + "https://simulation.example.test", + ), + ], +) +def test_validate_cloud_run_deploy_env_requires_only_selected_url( + entrypoint, + selected_url_env, + selected_url, +): + env = _script_env( + SIM_ENTRYPOINT=entrypoint, + **_gateway_auth_env(), + ) + missing_result = _run_script( + ".github/scripts/validate_cloud_run_deploy_env.sh", + env, + ) + valid_result = _run_script( + ".github/scripts/validate_cloud_run_deploy_env.sh", + {**env, selected_url_env: selected_url}, + ) + assert missing_result.returncode == 1 + assert selected_url_env in missing_result.stderr + assert valid_result.returncode == 0, valid_result.stderr -def test_validate_app_engine_deploy_env_requires_both_simulation_urls_and_selector(): + +def test_validate_app_engine_deploy_env_requires_selector_environment_variable(): result = _run_script( ".github/scripts/validate_app_engine_deploy_env.sh", - _script_env(), + _script_env( + OLD_SIMULATION_GATEWAY_URL="https://old-gateway.example.test", + **_gateway_auth_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 + assert "SIM_ENTRYPOINT is required" in result.stderr -def test_app_engine_image_contains_git_controlled_simulation_routing_placeholders(): +def test_validate_app_engine_deploy_env_accepts_direct_mode_from_environment(): + result = _run_script( + ".github/scripts/validate_app_engine_deploy_env.sh", + _script_env( + SIM_ENTRYPOINT="old_gateway_direct", + OLD_SIMULATION_GATEWAY_URL="https://old-gateway.example.test", + **_gateway_auth_env(), + ), + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("entrypoint", "selected_url_env", "selected_url"), + [ + ( + "old_gateway_direct", + "OLD_SIMULATION_GATEWAY_URL", + "https://old-gateway.example.test", + ), + ( + "cloud_run_simulation_entrypoint", + "SIMULATION_ENTRYPOINT_URL", + "https://simulation.example.test", + ), + ], +) +def test_validate_app_engine_deploy_env_requires_only_selected_url( + entrypoint, + selected_url_env, + selected_url, +): + env = _script_env( + SIM_ENTRYPOINT=entrypoint, + **_gateway_auth_env(), + ) + missing_result = _run_script( + ".github/scripts/validate_app_engine_deploy_env.sh", + env, + ) + valid_result = _run_script( + ".github/scripts/validate_app_engine_deploy_env.sh", + {**env, selected_url_env: selected_url}, + ) + + assert missing_result.returncode == 1 + assert selected_url_env in missing_result.stderr + assert valid_result.returncode == 0, valid_result.stderr + + +def test_app_engine_image_contains_simulation_routing_environment_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 '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 +@pytest.mark.parametrize( + ("entrypoint", "selected_url_env", "selected_url", "unselected_url_env"), + [ + ( + "old_gateway_direct", + "OLD_SIMULATION_GATEWAY_URL", + "https://old-gateway.example.test", + "SIMULATION_ENTRYPOINT_URL", + ), + ( + "cloud_run_simulation_entrypoint", + "SIMULATION_ENTRYPOINT_URL", + "https://simulation.example.test", + "OLD_SIMULATION_GATEWAY_URL", + ), + ], +) +def test_app_engine_export_requires_only_selected_url( + tmp_path, + entrypoint, + selected_url_env, + selected_url, + unselected_url_env, +): + (tmp_path / "gcp/policyengine_api").mkdir(parents=True) + shutil.copy2(REPO / "gcp/export.py", tmp_path / "gcp/export.py") + shutil.copy2( + REPO / "gcp/policyengine_api/Dockerfile", + tmp_path / "gcp/policyengine_api/Dockerfile", + ) + env = { + **_script_env(), + **_required_runtime_env(), + "SIM_ENTRYPOINT": entrypoint, + selected_url_env: selected_url, + } + env.pop(unselected_url_env) + + result = subprocess.run( + [sys.executable, "gcp/export.py"], + cwd=tmp_path, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + rendered = (tmp_path / "gcp/policyengine_api/Dockerfile").read_text( + encoding="utf-8" + ) + assert f'ENV {selected_url_env}="{selected_url}"' in rendered + assert f'ENV {unselected_url_env}=""' in rendered + + def test_build_cloud_run_image_dry_run_uses_cloud_run_dockerfile(): dockerignore = REPO / "gcp/cloud_run/Dockerfile.dockerignore" @@ -493,6 +723,69 @@ def test_deploy_cloud_run_candidate_dry_run_never_shifts_traffic(): assert "SIM_ENTRYPOINT=cloud_run_simulation_entrypoint" in result.stdout +@pytest.mark.parametrize( + ("entrypoint", "selected_url_env", "selected_url", "unselected_url_env"), + [ + ( + "old_gateway_direct", + "OLD_SIMULATION_GATEWAY_URL", + "https://old-gateway.example.test", + "SIMULATION_ENTRYPOINT_URL", + ), + ( + "cloud_run_simulation_entrypoint", + "SIMULATION_ENTRYPOINT_URL", + "https://simulation.example.test", + "OLD_SIMULATION_GATEWAY_URL", + ), + ], +) +def test_deploy_cloud_run_candidate_passes_only_configured_simulation_urls( + entrypoint, + selected_url_env, + selected_url, + unselected_url_env, +): + env = { + **_script_env(), + **_required_runtime_env(), + "SIM_ENTRYPOINT": entrypoint, + selected_url_env: selected_url, + "CLOUD_RUN_IMAGE_URI": ("us-central1-docker.pkg.dev/project/repo/api:sha"), + "CLOUD_RUN_TAG": "stage3-test", + } + env.pop(unselected_url_env) + + result = _run_script( + ".github/scripts/deploy_cloud_run_candidate.sh", + env, + ) + + assert result.returncode == 0, result.stderr + assert f"{selected_url_env}={selected_url}" in result.stdout + assert f"{unselected_url_env}=" not in result.stdout + + +def test_deploy_cloud_run_candidate_requires_selector_environment_variable(): + env = { + **_script_env(), + **_required_runtime_env(), + "OLD_SIMULATION_GATEWAY_URL": "https://old-gateway.example.test", + "CLOUD_RUN_IMAGE_URI": "us-central1-docker.pkg.dev/project/repo/api:sha", + "CLOUD_RUN_TAG": "stage3-test", + } + env.pop("SIM_ENTRYPOINT") + env.pop("SIMULATION_ENTRYPOINT_URL") + + result = _run_script( + ".github/scripts/deploy_cloud_run_candidate.sh", + env, + ) + + assert result.returncode == 1 + assert "SIM_ENTRYPOINT" in result.stderr + + 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() @@ -644,7 +937,7 @@ def test_deploy_cloud_run_candidate_pins_runtime_shape(): # Stage 2-qualified values, pinned on every deploy — rationale in # docs/migration/cloud-run-operations.md ("Runtime shape and scaling"). assert "--concurrency 6 " in result.stdout - assert "WEB_CONCURRENCY=2 " in result.stdout + assert "WEB_CONCURRENCY=2" in result.stdout # Revision-level floor (--min-instances) stays 0; the warm floor is applied # service-level (--min), defaulting to 0 unless a job overrides it. assert "--min-instances 0 " in result.stdout @@ -989,28 +1282,86 @@ def test_push_workflow_serializes_deployments_without_cancelling_in_progress_run 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(): +def test_workflows_do_not_store_simulation_selector_in_git(): 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", - ) + pr_workflow = _pr_workflow() - 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 not (REPO / ".github/simulation-entrypoint-mode").exists() + for workflow_text in (workflow, pr_workflow): assert ( - "OLD_SIMULATION_GATEWAY_URL: " - "${{ secrets.OLD_SIMULATION_GATEWAY_URL }}" in job + workflow_text.count( + "OLD_SIMULATION_GATEWAY_URL: ${{ vars.OLD_SIMULATION_GATEWAY_URL }}" + ) + == 1 ) + assert ( + "SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }}" + not in workflow_text.split("jobs:", maxsplit=1)[0] + ) + + +def test_workflows_scope_simulation_routing_config_to_github_environments(): + selector_env = "SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }}" + secret_env = "SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }}" + pr_workflow = _pr_workflow() + push_workflow = _push_workflow() + + assert secret_env not in pr_workflow.split("jobs:", maxsplit=1)[0] + assert secret_env not in push_workflow.split("jobs:", maxsplit=1)[0] + assert selector_env not in pr_workflow.split("jobs:", maxsplit=1)[0] + assert selector_env not in push_workflow.split("jobs:", maxsplit=1)[0] + + environment_jobs = ( + ( + pr_workflow, + "ensure-policyengine-bundle-supported-by-simulation-api", + "staging", + ), + ( + push_workflow, + "ensure-staging-model-version-aligns-with-sim-api", + "staging", + ), + (push_workflow, "deploy-staging", "staging"), + (push_workflow, "deploy-cloud-run-staging", "staging"), + ( + push_workflow, + "ensure-production-model-version-aligns-with-sim-api", + "production", + ), + (push_workflow, "deploy-production-candidate", "production"), + (push_workflow, "deploy-cloud-run-candidate", "production"), + ) + for workflow_text, job_name, environment in environment_jobs: + job = _workflow_job_block(workflow_text, job_name) + assert f"environment: {environment}" in job + assert selector_env in job + assert secret_env in job + + +def test_deployment_consumers_require_selector_from_environment(): + consumers = ( + ".github/request-simulation-model-versions.sh", + ".github/scripts/validate_app_engine_deploy_env.sh", + ".github/scripts/validate_cloud_run_deploy_env.sh", + ) + + for consumer in consumers: + script = (REPO / consumer).read_text(encoding="utf-8") + assert "source .github/scripts/simulation_entrypoint_env.sh" in script + assert "simulation_entrypoint_load_git_selection" not in script + + +def test_workflows_never_depend_on_opaque_legacy_simulation_url_secret(): + workflows = "\n".join( + workflow.read_text(encoding="utf-8") + for workflow in (REPO / ".github/workflows").glob("*.y*ml") + ) + + assert "SIMULATION_API_URL" not in workflows + assert "vars.SIMULATION_ENTRYPOINT_URL" not in workflows + assert "secrets.SIMULATION_ENTRYPOINT_URL" in workflows + assert "secrets.OLD_SIMULATION_GATEWAY_URL" not in workflows def test_push_workflow_uses_dedicated_cloud_run_runtime_service_account(): @@ -1132,7 +1483,7 @@ def test_push_workflow_promotes_production_cloud_run_after_candidate_smoke(): ) assert smoke_index < promote_index - assert "${{ vars.SIM_ENTRYPOINT" not in cloud_run_production + assert "SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }}" in cloud_run_production assert ( "CLOUD_RUN_TARGET_REVISION: ${{ steps.candidate.outputs.revision }}" in cloud_run_production