Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
763e66f
Promote each staging deployment's tested entrypoint revision
anth-volk Oct 21, 2026
2b7cde8
Document that a manual staging run from another branch promotes that …
anth-volk Oct 21, 2026
e9e8de9
Document that production waits for the staging revision clean-up
anth-volk Oct 21, 2026
4f34bf0
State in the entrypoint deployment guide that production waits for th…
anth-volk Oct 22, 2026
6b1fcf4
Add a versioned contract for household calculations on Modal
anth-volk Oct 21, 2026
03cda9b
Record each country's capabilities in v2 manifest schema version 2
anth-volk Oct 22, 2026
0485255
Port the Public API's household input preparation
anth-volk Oct 21, 2026
7aeb914
Resolve household SPM selections and receipts like the Public API
anth-volk Oct 21, 2026
bf9d779
Register observability stages for the v2 household functions
anth-volk Oct 22, 2026
d52d956
Calculate a household under several policies as the Public API does
anth-volk Oct 22, 2026
389c6cc
Add calculate_household_us and calculate_household_uk to the v2 Modal…
anth-volk Oct 22, 2026
60d778e
Validate the household functions at release and record them in the ma…
anth-volk Oct 22, 2026
00b3409
Build every v2 image in the pre-merge image check
anth-volk Oct 22, 2026
9063f10
Word uncertifiable SPM receipts exactly as the Public API does
anth-volk Oct 22, 2026
d9462a3
Check a household's SPM settings exactly as sent, with the Public API…
anth-volk Oct 22, 2026
db61091
Load the household SPM forecast without the main path's SPM module
anth-volk Oct 22, 2026
85afc4e
List every known difference from the Public API in one place
anth-volk Oct 22, 2026
5c700a1
Test publishing after a publisher released before schema version 2
anth-volk Oct 22, 2026
fd45e26
Resolve a comparison report's simulation callable as a typed text
anth-volk Oct 22, 2026
615955f
Test a chosen US SPM selection and the validation household with real…
anth-volk Oct 22, 2026
7455b24
Measure the whole country model load in its own stage
anth-volk Oct 22, 2026
b760140
Keep the shared SPM receipt model's wording and translate it for hous…
anth-volk Oct 22, 2026
08f1301
Test the manifest with released document shapes and document rollback…
anth-volk Oct 22, 2026
5cc4ce9
Add the simulation persistence library with its schema names and engine
anth-volk Oct 21, 2026
1ea2627
Add the simulation schema's Alembic chain with its ownership rules
anth-volk Oct 21, 2026
a037ce2
Report the simulation database's revision in the entrypoint readiness…
anth-volk Oct 21, 2026
5b3d70b
Migrate the simulation schema before each entrypoint deploy
anth-volk Oct 21, 2026
c459890
Stop the entrypoint deploy when any runtime secret cannot be resolved
anth-volk Oct 21, 2026
b8fe2a0
Refuse every database URL in Modal except the comparison path's
anth-volk Oct 22, 2026
e0a883f
Refuse a bare DATABASE_URL in Modal too
anth-volk Oct 22, 2026
5500c5e
Apply migrations only from main; elsewhere require the database at th…
anth-volk Oct 22, 2026
2233d4c
Resolve the Stage 12 secrets with the Stage 12 block's own helper
anth-volk Oct 22, 2026
baffc50
Ask a cold candidate again until it reaches its database
anth-volk Oct 22, 2026
5851cb6
Prove the migration-cycle and migrate-before-entrypoint rules reject …
anth-volk Oct 22, 2026
3795e19
Fail a migration that waits more than ten seconds for a lock
anth-volk Oct 22, 2026
997268d
Prove the workflow-shell-safety and container-context-minimal rules
anth-volk Oct 22, 2026
d121830
Close two gaps in the ownership checks and show why a migration cycle…
anth-volk Oct 22, 2026
433f44e
Correct documentation drift and share one revision helper in the tests
anth-volk Oct 22, 2026
c4009dc
Add simulation.simulations: what to run, independent of model versions
anth-volk Oct 21, 2026
3a2dd8f
Add simulation.reports: which two simulations a report compares
anth-volk Oct 21, 2026
3a15fb6
Accept a simulation or report kind given as text
anth-volk Oct 22, 2026
e2dd362
Test every definition check in the database and correct the roles table
anth-volk Oct 22, 2026
c7404a1
Name the check constraints as their table definitions do
anth-volk Oct 22, 2026
f4bf51c
Add simulation runs and report runs, found or created per catalog ver…
anth-volk Oct 22, 2026
289ff98
Change run statuses only with conditional updates, so a second writer…
anth-volk Oct 22, 2026
06204f5
Start each run's Modal call exactly once from the Simulation Entrypoint
anth-volk Oct 22, 2026
6002eba
Record Modal call outcomes on runs in a background loop in each instance
anth-volk Oct 22, 2026
58b992d
Keep CPU allocated and always mount sim's Modal token for the recordi…
anth-volk Oct 22, 2026
a50d081
Load-check the recording loop's conditional writes on PostgreSQL
anth-volk Oct 22, 2026
a418cd5
Cancel a Modal call whose runs cannot record it
anth-volk Oct 22, 2026
6839761
Fail a comparison step whose simulation run failed
anth-volk Oct 22, 2026
6ce4d6e
Leave a finished call of a kind without a recorder for a later check
anth-volk Oct 22, 2026
4f7e51c
Leave database connections for requests, and log comparisons as compa…
anth-volk Oct 22, 2026
e03f440
Load-check household pairs and stalled dispatches too
anth-volk Oct 22, 2026
6e9a45a
Contain a failed check within its pass, and retry client-side Modal e…
anth-volk Oct 22, 2026
06f5531
Index pending comparison steps for the recording pass
anth-volk Oct 22, 2026
4ad7605
Read a call's outcome by where Modal raised it, not by the exception'…
anth-volk Oct 22, 2026
3060b9d
Keep the recording loop running whatever a check or pass raises
anth-volk Oct 22, 2026
3f7c117
Retry a failed output download instead of failing the run
anth-volk Oct 26, 2026
58aec6a
Keep a call whose record committed although its reply was lost
anth-volk Oct 26, 2026
106518d
Pass on a shutdown that arrives while the recording loop stops
anth-volk Oct 26, 2026
7eddb15
Keep backing off for a call checked more than 46 times
anth-volk Oct 26, 2026
a1fee05
Store a failed dispatch's error without claiming the call was cancelled
anth-volk Oct 26, 2026
d64da40
Stop recording outcomes in a background loop in each instance
anth-volk Oct 27, 2026
7856197
Define the report a Modal call sends sim when it ends
anth-volk Oct 27, 2026
3e0e4db
Check that a request comes from sim's call-report Modal function
anth-volk Oct 27, 2026
fe94717
Check a running call again when its claim's lease ends, without backoff
anth-volk Oct 27, 2026
9bc28bb
Record a call's outcome when its Modal function reports that it ended
anth-volk Oct 27, 2026
25cd7a5
Accept call-ended reports from sim's call-report Modal function
anth-volk Oct 27, 2026
9958515
Add the safety check: one recording pass per execution of a job
anth-volk Oct 27, 2026
41315cc
Deploy the entrypoint with the Modal caller its reports must come from
anth-volk Oct 27, 2026
4a33fbc
Deploy the safety check from the promoted revision, on a schedule
anth-volk Oct 27, 2026
8164f55
Add the sender a v2 Modal call uses to report that it ended
anth-volk Oct 27, 2026
46fd2cd
Declare report_call_ended in the v2 Modal app with sim's main URL
anth-volk Oct 27, 2026
68afe8c
Store dispatch errors that say only what is known
anth-volk Oct 27, 2026
fe1f5e8
Describe the load check and the conditional writes for reported calls
anth-volk Oct 27, 2026
42a0c93
Answer a report with a wait whenever Modal's keys cannot be read
anth-volk Oct 27, 2026
2ab3e3a
Pause the safety check rather than run an image without it
anth-volk Oct 27, 2026
dc5eb07
Log the safety check's passes through the observability runtime
anth-volk Oct 27, 2026
76bec96
Describe the dispatch, the report function and other_call as they work
anth-volk Oct 27, 2026
283ac6d
Let modal deploy import the v2 app: drop the check of report_call_end…
anth-volk Oct 27, 2026
b53ea03
Store economy results as rows and rebuild them exactly
anth-volk Oct 22, 2026
373e5e2
Keep each UK area's disclosure tier and 90% intervals
anth-volk Oct 26, 2026
40451a9
Name where every field of the economy result model is stored
anth-volk Oct 22, 2026
44d4904
Store a computed household as one JSON value on its simulation run
anth-volk Oct 22, 2026
ab69d95
Link each run to the files it stored, offered for 30 days
anth-volk Oct 22, 2026
a56ee3e
Measure writing, reading and storing the result sample
anth-volk Oct 22, 2026
55e3ede
Rebuild every result exactly: per-comparison figures, fallback shapes…
anth-volk Oct 22, 2026
d9cf7a7
Store labour supply with a negative-income decile, and a run compared…
anth-volk Oct 22, 2026
29d95b2
Store the model and data versions a result reports
anth-volk Oct 26, 2026
cafd9d7
Declare pydantic for the household writer and describe the labour sup…
anth-volk Oct 26, 2026
8cfdc57
Describe the result tables' privileges and write the disclosure tiers…
anth-volk Oct 26, 2026
9f163a2
Describe the comparison recorder without the removed loop
anth-volk Oct 27, 2026
2715b51
Split the economy result module into its storage map, writer and reader
anth-volk Oct 27, 2026
5a693fb
Merge remote-tracking branch 'origin/v2plan/1d-111-household-function…
anth-volk Oct 27, 2026
16ca9a7
Merge remote-tracking branch 'origin/v2plan/1j-139-staging-promotion'…
anth-volk Oct 27, 2026
dfd9b65
Find household simulations by their household, policy and options
anth-volk Oct 27, 2026
78aef64
Store a computed household's warnings, SPM choice and bundle with it
anth-volk Oct 27, 2026
445cf82
Record a failure's detail document with the run it failed
anth-volk Oct 27, 2026
df5d669
Record a household function's outcome on its runs
anth-volk Oct 27, 2026
c82152a
Read a household calculation with its run, simulation and household
anth-volk Oct 27, 2026
317806d
Submit and poll household calculations on the Simulation Entrypoint
anth-volk Oct 27, 2026
bdda635
Report a household call's end to sim as the household functions' last…
anth-volk Oct 27, 2026
39c59e9
Store household reports and read them with both calculations
anth-volk Oct 27, 2026
81f6b04
Create, run again and read household reports on the Simulation Entryp…
anth-volk Oct 27, 2026
cf1a8a9
Calculate a household on every promoted staging revision
anth-volk Oct 27, 2026
78b1511
Fail household runs at once when their call's outcome lacks one of them
anth-volk Oct 27, 2026
c875d6b
Read a saved household's null SPM selection as no choice
anth-volk Oct 27, 2026
b166568
Start a household report's runs before creating the report
anth-volk Oct 27, 2026
ff1bd3f
Correct the household path's documentation
anth-volk Oct 27, 2026
6c6689e
Reuse a succeeded household run in the tests' store only within 28 days
anth-volk Oct 27, 2026
ff76bdc
Calculate the staging test's household as stored, and retry a busy se…
anth-volk Oct 27, 2026
85dcceb
Say that a succeeded household run is reused for 28 days only
anth-volk Oct 27, 2026
b7f5a39
Check a household report's run against the report's policies directly
anth-volk Oct 27, 2026
cdce071
Keep the staging household test to one limit and one copy of its hous…
anth-volk Oct 27, 2026
2f0406e
Describe the household calculation path in the changelog entry
anth-volk Oct 27, 2026
9170b41
Require the calculation year in a household request
anth-volk Oct 28, 2026
63f9199
Give the test copies of the Public API's tables their real column types
anth-volk Oct 28, 2026
c3a841e
Read the Public API's household, policy and catalog rows a household …
anth-volk Oct 28, 2026
f784b75
Read axes and values without a period as the request's year
anth-volk Oct 28, 2026
c537332
Convert a stored v2 household to the household functions' situation i…
anth-volk Oct 28, 2026
de06b1a
Take household and policy IDs, and read their rows, on the household …
anth-volk Oct 28, 2026
d4e3be2
Describe the request year as the periods it sets, and name its helper…
anth-volk Oct 28, 2026
1efa886
Merge remote-tracking branch 'origin/v2plan/1d-111-household-function…
anth-volk Oct 28, 2026
3e1a2fc
Refuse caller data the contract refuses with 422, before writing anyt…
anth-volk Oct 28, 2026
4fed38b
Require every column the household reads select to be recorded
anth-volk Oct 28, 2026
3a2bbe0
Cap refusals, and name a missing privilege only for the Public API's …
anth-volk Oct 28, 2026
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
46 changes: 44 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 All @@ -63,6 +77,9 @@ if [ "${expected_identity}" = "required" ] ||
fi
routing_max_attempts="${SIMULATION_ENTRYPOINT_ROUTING_MAX_ATTEMPTS:-24}"
routing_retry_delay_seconds="${SIMULATION_ENTRYPOINT_ROUTING_RETRY_DELAY_SECONDS:-5}"
# A cold candidate may not reach its database within /ready's three-second
# wait; an unreachable database is asked about again this many times.
database_ready_max_attempts="${SIMULATION_DATABASE_READY_MAX_ATTEMPTS:-6}"

if ! [[ "${routing_max_attempts}" =~ ^[1-9][0-9]*$ ]]; then
printf 'SIMULATION_ENTRYPOINT_ROUTING_MAX_ATTEMPTS must be a positive integer\n' >&2
Expand All @@ -72,6 +89,10 @@ if ! [[ "${routing_retry_delay_seconds}" =~ ^[0-9]+$ ]]; then
printf 'SIMULATION_ENTRYPOINT_ROUTING_RETRY_DELAY_SECONDS must be a non-negative integer\n' >&2
exit 2
fi
if ! [[ "${database_ready_max_attempts}" =~ ^[1-9][0-9]*$ ]]; then
printf 'SIMULATION_DATABASE_READY_MAX_ATTEMPTS must be a positive integer\n' >&2
exit 2
fi

health_headers="$(mktemp)"
health_body="$(mktemp)"
Expand Down Expand Up @@ -131,8 +152,29 @@ 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" ]; then
database_attempt=1
while [ "${database_attempt}" -lt "${database_ready_max_attempts}" ] &&
jq -e '.simulation_database.reachable == false' <<<"${ready_body}" >/dev/null; do
printf '%s: the simulation database was not reached; asking again (%s/%s)\n' \
"${base_url}" "${database_attempt}" "${database_ready_max_attempts}" >&2
sleep "${routing_retry_delay_seconds}"
ready_body="$(curl --fail --silent --show-error "${base_url}/ready")"
database_attempt=$((database_attempt + 1))
done
fi
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
55 changes: 43 additions & 12 deletions .github/scripts/deploy-cloud-run-simulation-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ resolve_secret_reference() {
printf '%s:%s' "${reference}" "${highest_enabled_version}"
}

runtime_secrets=()

# Resolve one secret per assignment: an array assignment takes its exit status
# from its last command substitution only, so resolving several secrets in
# one would let an earlier failure pass unnoticed.
add_runtime_secret() {
local variable_name="$1"
local resolved_reference
resolved_reference="$(resolve_secret_reference "$2")"
runtime_secrets+=("${variable_name}=${resolved_reference}")
}

# The Stage 12 comparison path's own wrapper, removed with it, so the main
# path's wrapper can change without touching the Stage 12 block. Both use the
# secret lookups above, which predate the split.
add_stage12_runtime_secret() {
local variable_name="$1"
local resolved_reference
resolved_reference="$(resolve_secret_reference "$2")"
runtime_secrets+=("${variable_name}=${resolved_reference}")
}

required_variables=(
PROJECT_ID
REGION
Expand All @@ -77,6 +99,10 @@ 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
MODAL_TOKEN_ID_SECRET_NAME
MODAL_TOKEN_SECRET_SECRET_NAME
MODAL_WORKSPACE_ID_VALUE
OBSERVABILITY_SERVICE_NAMESPACE
OBSERVABILITY_TRACE_PROJECT_ID
OTEL_EXPORTER_OTLP_ENDPOINT
Expand All @@ -103,6 +129,10 @@ esac
|| fail "TAG is invalid"
[[ "${ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.gserviceaccount\.com$ ]] \
|| fail "ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT is invalid"
# The Modal workspace whose call-report function may report that calls
# ended (the entrypoint also checks the environment, MODAL_ENVIRONMENT).
[[ "${MODAL_WORKSPACE_ID_VALUE}" =~ ^ac-[A-Za-z0-9]+$ ]] \
|| fail "MODAL_WORKSPACE_ID_VALUE must be a Modal workspace ID"
[[ "${ENTRYPOINT_MIN_INSTANCES}" =~ ^[0-9]+$ ]] \
|| fail "ENTRYPOINT_MIN_INSTANCES must be a non-negative integer"
[[ "${ENTRYPOINT_MAX_INSTANCES}" =~ ^[0-9]+$ ]] \
Expand All @@ -115,18 +145,21 @@ 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"

runtime_secrets=(
"OLD_GATEWAY_AUTH_CLIENT_SECRET=$(
resolve_secret_reference "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}"
)"
)
add_runtime_secret OLD_GATEWAY_AUTH_CLIENT_SECRET \
"${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}"
# 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.
add_runtime_secret SIMULATION_DATABASE_URL "${SIMULATION_DATABASE_URL_SECRET_NAME}"
# sim's own Modal token, on every deploy: the entrypoint starts runs' Modal
# calls and asks Modal about a call when Modal reports that it ended. Until
# the Stage 12 comparison path is removed, its Modal calls use the same token.
add_runtime_secret MODAL_TOKEN_ID "${MODAL_TOKEN_ID_SECRET_NAME}"
add_runtime_secret MODAL_TOKEN_SECRET "${MODAL_TOKEN_SECRET_SECRET_NAME}"

if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then
stage12_variables=(
STAGE12_ENABLED_VALUE
STAGE12_ARTIFACT_BUCKET_VALUE
MODAL_TOKEN_ID_SECRET_NAME
MODAL_TOKEN_SECRET_SECRET_NAME
STAGE12_DATABASE_URL_SECRET_NAME
)
for variable_name in "${stage12_variables[@]}"; do
Expand All @@ -138,11 +171,7 @@ if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then
|| fail "STAGE12_ARTIFACT_BUCKET_VALUE is invalid"
validate_secret_reference "${STAGE12_DATABASE_URL_SECRET_NAME}"

runtime_secrets+=(
"MODAL_TOKEN_ID=$(resolve_secret_reference "${MODAL_TOKEN_ID_SECRET_NAME}")"
"MODAL_TOKEN_SECRET=$(resolve_secret_reference "${MODAL_TOKEN_SECRET_SECRET_NAME}")"
"STAGE12_DATABASE_URL=$(resolve_secret_reference "${STAGE12_DATABASE_URL_SECRET_NAME}")"
)
add_stage12_runtime_secret STAGE12_DATABASE_URL "${STAGE12_DATABASE_URL_SECRET_NAME}"
fi

runtime_environment_file="${RUNNER_TEMP}/simulation-entry-runtime-environment.yaml"
Expand All @@ -167,6 +196,8 @@ jq -n '
OTEL_METRICS_EXPORTER: "otlp",
OTEL_TRACES_SAMPLER_ARG: "1.0",
POLICYENGINE_OTEL_GOOGLE_AUDIENCE: env.POLICYENGINE_OTEL_GOOGLE_AUDIENCE,
SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID: env.MODAL_WORKSPACE_ID_VALUE,
SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT: env.MODAL_ENVIRONMENT,
STAGE12_ENABLED: (
if env.DEPLOY_STAGE12_V2 == "true" then env.STAGE12_ENABLED_VALUE else "0" end
)
Expand Down
132 changes: 132 additions & 0 deletions .github/scripts/deploy-cloud-run-simulation-safety-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Point the safety-check Cloud Run job at the entrypoint revision that serves
# traffic, and keep its Cloud Scheduler entry.
#
# The job runs `python -m policyengine_simulation_entry.safety_check`: one
# recording pass per execution. It is updated only from a revision that was
# promoted (or restored by a rollback), so a rejected candidate never records
# anything. It copies the revision's image digest, service account, Modal
# token and database URL (at the secret versions the revision pins) and its
# plain telemetry settings; nothing else. A revision deployed before the
# safety check existed has no job code, so its schedule is paused instead.
#
# With the argument `pause`, only pause the schedule: a workflow does this
# when pointing the job at a revision failed part way, so the job never keeps
# running the image traffic just moved away from.
#
# Environment: PROJECT_ID, REGION, SERVICE, and unless pausing,
# SAFETY_CHECK_REVISION, SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT, RUNNER_TEMP.
set -euo pipefail

fail() {
printf '%s\n' "$*" >&2
exit 1
}

pause_schedule() {
if gcloud scheduler jobs describe "${job}" "${scheduler_location[@]}" >/dev/null 2>&1; then
gcloud scheduler jobs pause "${job}" "${scheduler_location[@]}" --quiet
fi
}

# Every five minutes: SAFETY_CHECK_INTERVAL in
# policyengine_simulation_entry.run_recording.
SCHEDULE="*/5 * * * *"
SECRETS=(SIMULATION_DATABASE_URL MODAL_TOKEN_ID MODAL_TOKEN_SECRET)
PLAIN_VARIABLES_FILTER='^(APP_ENVIRONMENT|CLOUD_RUN_REGION|OBSERVABILITY_[A-Z_]+|OTEL_[A-Z_]+|POLICYENGINE_OTEL_GOOGLE_AUDIENCE)$'

for variable_name in PROJECT_ID REGION SERVICE; do
[[ -n "${!variable_name:-}" ]] || fail "${variable_name} is required"
done
[[ "${PROJECT_ID}" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]] || fail "PROJECT_ID is invalid"
[[ "${REGION}" =~ ^[a-z]+-[a-z]+[0-9]+$ ]] || fail "REGION is invalid"
[[ "${SERVICE}" =~ ^[a-z]([a-z0-9-]{0,47}[a-z0-9])?$ ]] || fail "SERVICE is invalid"

job="${SERVICE}-safety-check"
location=(--project "${PROJECT_ID}" --region "${REGION}")
scheduler_location=(--project "${PROJECT_ID}" --location "${REGION}")

if [[ "${1:-}" == "pause" ]]; then
pause_schedule
printf 'The safety check schedule for %s is paused.\n' "${SERVICE}"
exit 0
fi

for variable_name in SAFETY_CHECK_REVISION SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT RUNNER_TEMP; do
[[ -n "${!variable_name:-}" ]] || fail "${variable_name} is required"
done
[[ "${SAFETY_CHECK_REVISION}" =~ ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \
|| fail "SAFETY_CHECK_REVISION is invalid"
[[ "${SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.gserviceaccount\.com$ ]] \
|| fail "SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT is invalid"

revision_json="$(gcloud run revisions describe "${SAFETY_CHECK_REVISION}" "${location[@]}" --format=json)"
revision_service="$(jq -r '.metadata.labels["serving.knative.dev/service"] // ""' <<<"${revision_json}")"
[[ "${revision_service}" == "${SERVICE}" ]] \
|| fail "${SAFETY_CHECK_REVISION} belongs to ${revision_service:-no service}, not ${SERVICE}"

# Every revision deployed with the safety check has the simulation database
# and the expected Modal caller (SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID,
# which the deploy has required since then); an older one has no job code.
if ! jq -e '
.spec.containers[0].env // []
| any(.name == "SIMULATION_DATABASE_URL")
and any(.name == "SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID")
' >/dev/null <<<"${revision_json}"; then
pause_schedule
printf '%s predates the safety check, so its schedule is paused.\n' "${SAFETY_CHECK_REVISION}"
exit 0
fi

image="$(jq -er '.status.imageDigest // .spec.containers[0].image' <<<"${revision_json}")"
service_account="$(jq -er '.spec.serviceAccountName' <<<"${revision_json}")"

job_secrets=()
for secret_variable in "${SECRETS[@]}"; do
reference="$(jq -er --arg name "${secret_variable}" '
[.spec.containers[0].env[] | select(.name == $name) | .valueFrom.secretKeyRef
| select(.name != null and .key != null) | "\(.name):\(.key)"]
| if length == 1 then .[0] else error("not one secret reference") end
' <<<"${revision_json}")" || fail "${SAFETY_CHECK_REVISION} has no pinned secret for ${secret_variable}"
job_secrets+=("${secret_variable}=${reference}")
done
job_secrets_csv="$(IFS=,; printf '%s' "${job_secrets[*]}")"

environment_file="${RUNNER_TEMP}/simulation-safety-check-environment.yaml"
trap 'rm -f "${environment_file}"' EXIT
jq --arg filter "${PLAIN_VARIABLES_FILTER}" '
[.spec.containers[0].env[] | select(.value != null and (.name | test($filter)))
| {(.name): .value}] | add // {}
' <<<"${revision_json}" > "${environment_file}"

gcloud run jobs deploy "${job}" "${location[@]}" \
--image "${image}" \
--service-account "${service_account}" \
--set-secrets "${job_secrets_csv}" \
--env-vars-file "${environment_file}" \
--command /opt/venv/bin/python \
--args=-m,policyengine_simulation_entry.safety_check \
--tasks 1 \
--max-retries 0 \
--task-timeout 600s \
--cpu 1 \
--memory 512Mi \
--labels "serving-revision=${SAFETY_CHECK_REVISION}" \
--quiet

schedule_action=create
if gcloud scheduler jobs describe "${job}" "${scheduler_location[@]}" >/dev/null 2>&1; then
schedule_action=update
fi
gcloud scheduler jobs "${schedule_action}" http "${job}" "${scheduler_location[@]}" \
--schedule "${SCHEDULE}" \
--time-zone Etc/UTC \
--uri "https://${REGION}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${PROJECT_ID}/jobs/${job}:run" \
--http-method POST \
--oauth-service-account-email "${SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT}" \
--attempt-deadline 60s \
--quiet
if [[ "${schedule_action}" == "update" ]]; then
gcloud scheduler jobs resume "${job}" "${scheduler_location[@]}" --quiet
fi
printf 'The safety check runs %s from %s.\n' "${image}" "${SAFETY_CHECK_REVISION}"
37 changes: 37 additions & 0 deletions .github/scripts/migrate-simulation-database.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Bring the simulation schema to the chain's head, or require it to be there.
# 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.
#
# SIMULATION_MIGRATION_MODE:
# upgrade apply every pending revision, then require the database at the
# head (deployments from the default branch);
# verify apply nothing and only require the database at this checkout's
# head, so a deployment dispatched from another branch can never
# apply that branch's revisions.

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

mode="${SIMULATION_MIGRATION_MODE:-}"
case "${mode}" in
upgrade | verify) ;;
*)
printf 'SIMULATION_MIGRATION_MODE must be upgrade or verify, not %s\n' "${mode:-empty}" >&2
exit 1
;;
esac

alembic_config="../../migrations/alembic.ini"

if [[ "${mode}" == "upgrade" ]]; then
uv run --frozen --extra migrations alembic -c "${alembic_config}" upgrade head
fi
uv run --frozen --extra migrations alembic -c "${alembic_config}" current --check-heads
12 changes: 10 additions & 2 deletions .github/scripts/modal-image-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
# Usage: ./modal-image-smoke.sh <modal-environment>
#
# Each smoke imports the true app entrypoints inside the real (or, for
# the executor, prefix-identical) Modal image — catching in-image
# dependency breakage (issue #602's class) before merge.
# the executor and the v2 US worker, prefix-identical) Modal image —
# catching in-image dependency breakage (issue #602's class) before merge.
# The v2 image smoke builds every image of the v2 application: the report
# coordinator, the country simulation workers and the household functions.

set -euo pipefail

Expand Down Expand Up @@ -47,4 +49,10 @@ echo "=== Stage 12 static runtime file smoke (env: $MODAL_ENV) ==="
src/modal/v2_static_runtime_files_smoke.py
)

echo "=== v2 image smoke: every v2 image and its entry points (env: $MODAL_ENV) ==="
(
cd "$REPO_ROOT/projects/policyengine-simulation-executor"
uv run --frozen modal run --env="$MODAL_ENV" src/modal/v2_image_smoke.py
)

echo "=== Image smokes passed ==="
Loading
Loading