Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions .github/scripts/cloud-run-simulation-entry-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ previous_revision="${3:-}"
# and model.
# This is the identity the gateway reports from the routing registry; the
# numerical release sentinel checks what the worker computes.
# REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION=true (candidate checks only):
# /ready must report the entrypoint's own database reachable and at the
# migration revision the image expects, which shows that the runtime secret
# reached the revision and that the migration job ran. Stable and rollback
# checks leave it unset, because an older image may face a newer schema.
require_database_revision="${REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION:-}"
expected_identity="${EXPECTED_IDENTITY:-none}"
expected_policyengine_version="${EXPECTED_POLICYENGINE_VERSION:-}"
expected_app_name="${EXPECTED_APP_NAME:-}"
Expand All @@ -39,6 +45,14 @@ case "${expected_identity}" in
exit 2
;;
esac
case "${require_database_revision}" in
"" | true) ;;
*)
printf 'REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION must be true or empty, not %s\n' \
"${require_database_revision}" >&2
exit 2
;;
esac
case "${expected_latest}" in
"" | true) ;;
*)
Expand Down Expand Up @@ -131,8 +145,18 @@ while true; do
attempt=$((attempt + 1))
done

curl --fail --silent --show-error "${base_url}/ready" |
jq -e '.status == "ready"' >/dev/null
ready_body="$(curl --fail --silent --show-error "${base_url}/ready")"
jq -e '.status == "ready"' <<<"${ready_body}" >/dev/null
if [ "${require_database_revision}" = "true" ] &&
! jq -e '
.simulation_database.reachable == true
and (.simulation_database.revision | type == "string")
and .simulation_database.revision == .simulation_database.expected_revision
' <<<"${ready_body}" >/dev/null; then
printf '%s: the simulation database is not at the expected revision: %s\n' \
"${base_url}" "$(jq -c '.simulation_database' <<<"${ready_body}")" >&2
exit 1
fi
curl --fail --silent --show-error "${base_url}/versions" --output "${versions_body}"
jq -e 'type == "object"' "${versions_body}" >/dev/null

Expand Down
6 changes: 6 additions & 0 deletions .github/scripts/deploy-cloud-run-simulation-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ required_variables=(
OLD_GATEWAY_AUTH_AUDIENCE_VALUE
OLD_GATEWAY_AUTH_CLIENT_ID_VALUE
OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME
SIMULATION_DATABASE_URL_SECRET_NAME
OBSERVABILITY_SERVICE_NAMESPACE
OBSERVABILITY_TRACE_PROJECT_ID
OTEL_EXPORTER_OTLP_ENDPOINT
Expand Down Expand Up @@ -115,10 +116,15 @@ export SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE="${SIMULATION_ENTRYPOINT_
[[ "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "0" || "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "1" ]] \
|| fail "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE must be 0 or 1"

# The runtime role's URL for the entrypoint's own simulation schema. The
# migration job, which uses a separate migrator URL, runs before this deploy.
runtime_secrets=(
"OLD_GATEWAY_AUTH_CLIENT_SECRET=$(
resolve_secret_reference "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}"
)"
"SIMULATION_DATABASE_URL=$(
resolve_secret_reference "${SIMULATION_DATABASE_URL_SECRET_NAME}"
)"
)

if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then
Expand Down
19 changes: 19 additions & 0 deletions .github/scripts/simulation-database-migrate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash
# Apply the simulation schema's Alembic chain, then require the database to be
# at the chain's head. Run from libs/policyengine-simulation-persistence, whose
# locked environment (with the migrations extra) provides Alembic.
# migrations/env.py reads the migrator URL from SIMULATION_MIGRATION_DATABASE_URL;
# this script never prints it.

set -euo pipefail
set +x

if [[ -z "${SIMULATION_MIGRATION_DATABASE_URL:-}" ]]; then
printf 'SIMULATION_MIGRATION_DATABASE_URL must name the simulation database\n' >&2
exit 1
fi

alembic_config="${SIMULATION_ALEMBIC_CONFIG:-../../migrations/alembic.ini}"

uv run --frozen --extra migrations alembic -c "${alembic_config}" upgrade head
uv run --frozen --extra migrations alembic -c "${alembic_config}" current --check-heads
50 changes: 49 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
# every job passed. See docs/engineering/skills/github-prs.md.
name: CI complete
if: ${{ always() }}
needs: [test, lint, quality-guards, docker-build, test-integration]
needs: [test, simulation-database, lint, quality-guards, docker-build, test-integration]
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -51,6 +51,7 @@ jobs:
- projects/policyengine-simulation-gateway
- libs/policyengine-simulation-contract
- libs/policyengine-simulation-observability
- libs/policyengine-simulation-persistence
- libs/policyengine-stage12-persistence
- libs/policyengine-fastapi

Expand All @@ -75,6 +76,53 @@ jobs:
working-directory: ${{ matrix.project }}
run: uv run pytest tests/ -v

simulation-database:
name: Simulation database
# Runs the simulation schema's migration chain (upgrade, alembic check,
# downgrade, upgrade) and the persistence tests on a fresh PostgreSQL.
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: simulation_ci
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=5s
--health-timeout=5s
--health-retries=20
env:
SIMULATION_TEST_DATABASE_URL: postgresql://postgres:simulation_ci@127.0.0.1:5432/postgres
# A missing server fails the tests instead of skipping them.
SIMULATION_REQUIRE_DATABASE_TESTS: "1"

steps:
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'

- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
enable-cache: true

- name: Install dependencies
working-directory: libs/policyengine-simulation-persistence
run: uv sync --extra test --frozen

- name: Run the migration chain and persistence tests on PostgreSQL
working-directory: libs/policyengine-simulation-persistence
run: uv run pytest tests/ -v -m postgres

lint:
name: Lint
# Full CI runs only on the lowest unmerged pull request in a stack.
Expand Down
34 changes: 32 additions & 2 deletions .github/workflows/simulation-deploy.reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,37 @@ jobs:
GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }}
run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}"

migrate_simulation_database:
# Applies the simulation schema's Alembic chain before the entrypoint
# candidate deploys. The previous revision keeps serving meanwhile, so
# every migration must work with the release before it.
name: Migrate the simulation database schema
needs: prepare
runs-on: ubuntu-latest
environment: ${{ inputs.release_environment }}
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: "3.13"

- uses: astral-sh/setup-uv@v8.1.0

- name: Install migration dependencies
working-directory: libs/policyengine-simulation-persistence
run: uv sync --frozen --extra migrations

- name: Apply simulation schema migrations
working-directory: libs/policyengine-simulation-persistence
env:
SIMULATION_MIGRATION_DATABASE_URL: ${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }}
run: ../../.github/scripts/simulation-database-migrate.sh

deploy_entrypoint:
name: Deploy and unit test Cloud Run entrypoint
if: ${{ always() && needs.prepare.result == 'success' && ((inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'success') || (!inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'skipped')) }}
needs: [prepare, configure_stage12_v2]
if: ${{ always() && needs.prepare.result == 'success' && needs.migrate_simulation_database.result == 'success' && ((inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'success') || (!inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'skipped')) }}
needs: [prepare, configure_stage12_v2, migrate_simulation_database]
runs-on: ubuntu-latest
environment: ${{ inputs.release_environment }}
permissions:
Expand Down Expand Up @@ -237,6 +264,7 @@ jobs:
OLD_GATEWAY_AUTH_AUDIENCE_VALUE: ${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}
OLD_GATEWAY_AUTH_CLIENT_ID_VALUE: ${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}
OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME: ${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}
SIMULATION_DATABASE_URL_SECRET_NAME: ${{ vars.SIMULATION_DATABASE_URL_SECRET_NAME }}
STAGE12_ENABLED_VALUE: ${{ vars.STAGE12_ENABLED }}
STAGE12_ARTIFACT_BUCKET_VALUE: ${{ vars.STAGE12_ARTIFACT_BUCKET }}
STAGE12_DATABASE_URL_SECRET_NAME: ${{ vars.STAGE12_DATABASE_URL_SECRET_NAME }}
Expand Down Expand Up @@ -744,6 +772,8 @@ jobs:
EXPECTED_UK_DATASET_SHA256: ${{ inputs.deploy_existing_stack && needs.prepare.outputs.uk_dataset_sha256 || '' }}
# Latest must route to this release only when it should advance it.
EXPECTED_LATEST: ${{ inputs.deploy_existing_stack && needs.update_routing.outputs.advances_latest == 'true' && 'true' || '' }}
# The candidate reaches its own database at the migrated revision.
REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION: "true"
run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ needs.deploy_entrypoint.outputs.candidate_url }}" "${{ needs.deploy_entrypoint.outputs.revision }}"

- name: Download the numerical release sentinel
Expand Down
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,19 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.

- `sim.stage12.deployment-scope`: Main-branch deploys roll out Stage 12 in both environments; a Stage 12-only deploy runs candidate tests and the requested promotion but never redeploys existing Modal resources or runs the existing stack's complete integration suite; the Stage 12 worker can request a Google OIDC token. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`)
- `sim.stage12.deployment-uses-api-schema`: Stage 12 deployment uses the API-owned schema directly, and the Stage 12 runtime it deploys uses typed DML without raw SQL or schema changes. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`)
- `sim.stage12.no-migration-chain`: This repository has no schema migration chain (no Alembic); Stage 12 tables are migrated by policyengine-api. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py`)
- `sim.stage12.no-production-user-associations`: Stage 12 runtime modules never create production user associations (user_simulations or user_reports). (blocking: pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py`)
- `sim.stage12.no-schema-ddl`: Simulation runtimes and the Stage 12 persistence library contain no schema DDL (CREATE, ALTER, DROP or TRUNCATE of tables, types, schemas or databases, or create_all); policyengine-api owns the schema. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py`)
- `sim.stage12.persistence-typed-dml-only`: The Stage 12 persistence library and runtime use typed SQLAlchemy Core DML only: no raw SQL text, driver-level SQL or schema operations. (blocking: pytest `libs/policyengine-stage12-persistence/tests/test_source_boundaries.py`)
- `sim.stage12.production-lock-has-db-runtime`: The production lock file includes the direct Stage 12 database runtime dependencies. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`)

### Simulation persistence

- `sim.persistence.executor-independent`: The Modal executor and gateway never declare, lock or import policyengine-simulation-persistence; only the Simulation Entrypoint and the migration job install it. (blocking: pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py`)
- `sim.persistence.migrate-before-entrypoint`: Each deployment applies the simulation chain in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py`)
- `sim.persistence.migration-cycle`: On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema. (blocking: pytest `libs/policyengine-simulation-persistence/tests/test_migration_cycle.py`)
- `sim.persistence.no-modal-database-credentials`: No Modal secret, Modal deployment or Modal application receives SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL; the Stage 12 comparison's own database secret is the only database credential Modal holds. (blocking: pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py`)
- `sim.persistence.one-migration-chain`: The simulation schema changes only through the one Alembic chain at migrations/ (configured by migrations/alembic.ini, versioned in simulation.alembic_version); no other Alembic configuration, environment or revision directory exists, and runtime source code contains no schema DDL. (blocking: pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py`)

### Deployment pipeline

- `sim.deploy.container-context-minimal`: Container builds exclude local environments and unrelated projects, and the entrypoint container builds for the requested target platform. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`)
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ test:
projects/policyengine-simulation-gateway \
libs/policyengine-simulation-contract \
libs/policyengine-simulation-observability \
libs/policyengine-simulation-persistence \
libs/policyengine-stage12-persistence \
libs/policyengine-fastapi; do \
echo "Testing $$proj..."; \
Expand Down
Loading
Loading