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