Skip to content

Commit afc6ef5

Browse files
authored
Merge pull request #201 from PolicyEngine/feat/versioned-modal-deployments
Versioned Modal deployments with coexisting country package versions
2 parents b46ed10 + f6facbb commit afc6ef5

27 files changed

Lines changed: 885 additions & 253 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/bin/bash
2+
# Deploy versioned simulation app to Modal
3+
# Usage: ./modal-deploy-versioned.sh <modal-environment>
4+
# Required env vars: POLICYENGINE_US_VERSION, POLICYENGINE_UK_VERSION
5+
#
6+
# Deploys a versioned app named policyengine-us{X}-uk{Y} and updates
7+
# the Modal Dict version registries so Cloud Run can route to it.
8+
# No separate gateway app — Cloud Run handles routing directly.
9+
10+
set -euo pipefail
11+
12+
MODAL_ENV="${1:?Modal environment required (staging or main)}"
13+
14+
# Validate required env vars
15+
: "${POLICYENGINE_US_VERSION:?POLICYENGINE_US_VERSION must be set}"
16+
: "${POLICYENGINE_UK_VERSION:?POLICYENGINE_UK_VERSION must be set}"
17+
18+
# Generate versioned app name (dots replaced with dashes)
19+
US_VERSION_SAFE="${POLICYENGINE_US_VERSION//./-}"
20+
UK_VERSION_SAFE="${POLICYENGINE_UK_VERSION//./-}"
21+
APP_NAME="policyengine-v2-us${US_VERSION_SAFE}-uk${UK_VERSION_SAFE}"
22+
23+
echo "========================================"
24+
echo "Deploying versioned Modal simulation app"
25+
echo " Environment: $MODAL_ENV"
26+
echo " App name: $APP_NAME"
27+
echo " US version: ${POLICYENGINE_US_VERSION}"
28+
echo " UK version: ${POLICYENGINE_UK_VERSION}"
29+
echo "========================================"
30+
31+
# 1. Deploy the versioned app
32+
echo ""
33+
echo "Step 1: Deploying versioned app..."
34+
export MODAL_APP_NAME="$APP_NAME"
35+
uv run modal deploy --env="$MODAL_ENV" src/policyengine_api/modal/deploy.py
36+
37+
# 2. Update version registries
38+
echo ""
39+
echo "Step 2: Updating version registries..."
40+
uv run python scripts/update_version_registry.py \
41+
--app-name "$APP_NAME" \
42+
--us-version "${POLICYENGINE_US_VERSION}" \
43+
--uk-version "${POLICYENGINE_UK_VERSION}" \
44+
--environment "$MODAL_ENV"
45+
46+
echo ""
47+
echo "========================================"
48+
echo "Deployment complete: $APP_NAME"
49+
echo "========================================"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/bash
2+
# Extract policyengine-us and policyengine-uk versions from uv.lock
3+
# Usage: ./modal-extract-versions.sh [project-dir]
4+
# Outputs: Sets us_version and uk_version in GITHUB_OUTPUT
5+
6+
set -euo pipefail
7+
8+
PROJECT_DIR="${1:-.}"
9+
10+
cd "$PROJECT_DIR"
11+
12+
US_VERSION=$(grep -A1 'name = "policyengine-us"' uv.lock | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/')
13+
UK_VERSION=$(grep -A1 'name = "policyengine-uk"' uv.lock | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/')
14+
15+
if [ -z "$US_VERSION" ] || [ -z "$UK_VERSION" ]; then
16+
echo "ERROR: Could not extract versions from uv.lock"
17+
echo " US_VERSION=$US_VERSION"
18+
echo " UK_VERSION=$UK_VERSION"
19+
exit 1
20+
fi
21+
22+
echo "us_version=$US_VERSION" >> "$GITHUB_OUTPUT"
23+
echo "uk_version=$UK_VERSION" >> "$GITHUB_OUTPUT"
24+
echo "Extracted versions: policyengine-us=$US_VERSION, policyengine-uk=$UK_VERSION"

.github/workflows/deploy.yml

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,27 @@ jobs:
241241
chmod +x .github/scripts/*.sh
242242
.github/scripts/modal-sync-secrets.sh staging staging
243243
244-
- name: Deploy Modal functions to staging
244+
- name: Extract package versions
245+
id: versions
246+
run: |
247+
chmod +x .github/scripts/modal-extract-versions.sh
248+
.github/scripts/modal-extract-versions.sh .
249+
250+
- name: Deploy versioned Modal simulation app to staging
251+
env:
252+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
253+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
254+
POLICYENGINE_US_VERSION: ${{ steps.versions.outputs.us_version }}
255+
POLICYENGINE_UK_VERSION: ${{ steps.versions.outputs.uk_version }}
256+
run: |
257+
chmod +x .github/scripts/modal-deploy-versioned.sh
258+
.github/scripts/modal-deploy-versioned.sh staging
259+
260+
- name: Deploy agent sandbox to staging
245261
env:
246262
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
247263
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
248264
run: |
249-
uv run modal deploy --env=staging src/policyengine_api/modal_app.py
250265
uv run modal deploy --env=staging src/policyengine_api/agent_sandbox.py
251266
252267
deploy-staging-cloudrun:
@@ -360,23 +375,43 @@ jobs:
360375
chmod +x .github/scripts/*.sh
361376
.github/scripts/modal-sync-secrets.sh main prod
362377
363-
- name: Deploy Modal functions to production
378+
- name: Extract package versions
379+
id: prod-versions
380+
run: |
381+
chmod +x .github/scripts/modal-extract-versions.sh
382+
.github/scripts/modal-extract-versions.sh .
383+
384+
- name: Deploy versioned Modal simulation app to production
385+
env:
386+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
387+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
388+
POLICYENGINE_US_VERSION: ${{ steps.prod-versions.outputs.us_version }}
389+
POLICYENGINE_UK_VERSION: ${{ steps.prod-versions.outputs.uk_version }}
390+
run: |
391+
chmod +x .github/scripts/modal-deploy-versioned.sh
392+
.github/scripts/modal-deploy-versioned.sh main
393+
394+
- name: Deploy agent sandbox to production
364395
env:
365396
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
366397
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
367398
run: |
368-
uv run modal deploy --env=main src/policyengine_api/modal_app.py
369399
uv run modal deploy --env=main src/policyengine_api/agent_sandbox.py
370400
371401
- name: Validate Modal secrets
372402
env:
373403
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
374404
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
405+
POLICYENGINE_US_VERSION: ${{ steps.prod-versions.outputs.us_version }}
406+
POLICYENGINE_UK_VERSION: ${{ steps.prod-versions.outputs.uk_version }}
375407
run: |
376-
echo "Validating Modal secrets..."
408+
US_SAFE="${POLICYENGINE_US_VERSION//./-}"
409+
UK_SAFE="${POLICYENGINE_UK_VERSION//./-}"
410+
APP_NAME="policyengine-v2-us${US_SAFE}-uk${UK_SAFE}"
411+
echo "Validating Modal secrets on ${APP_NAME}..."
377412
result=$(uv run python -c "
378413
import modal
379-
f = modal.Function.from_name('policyengine', 'validate_secrets')
414+
f = modal.Function.from_name('${APP_NAME}', 'validate_secrets')
380415
result = f.remote()
381416
import json
382417
print(json.dumps(result))

CLAUDE.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,25 @@ See [docs/DESIGN.md](docs/DESIGN.md) for the full design including future endpoi
1515
## How it works
1616

1717
1. Client submits request to FastAPI (Cloud Run)
18-
2. API creates job record in Supabase and triggers Modal.com function
19-
3. Modal runs calculation with pre-loaded PolicyEngine models (sub-1s cold start)
20-
4. Modal writes results directly to Supabase
21-
5. Client polls API until job status = "completed"
18+
2. API resolves the country package version → versioned Modal app name via Modal Dicts
19+
3. API creates job record in Supabase and spawns a function on the versioned Modal app
20+
4. Modal runs calculation with pre-loaded PolicyEngine models (sub-1s cold start)
21+
5. Modal writes results directly to Supabase
22+
6. Client polls API until job status = "completed"
23+
24+
## Versioned Modal deployments
25+
26+
Each deploy creates a versioned Modal app named `policyengine-v2-us{X}-uk{Y}` (e.g., `policyengine-v2-us1-592-4-uk2-75-1`). Old versions remain deployed and accessible. Cloud Run routes to the correct version via Modal Dict registries (`simulation-api-us-versions`, `simulation-api-uk-versions`).
27+
28+
**Key files:**
29+
- `src/policyengine_api/modal/app.py` — Versioned app definition (dynamic name from env vars)
30+
- `src/policyengine_api/modal/images.py` — Country images with exact version pins (`==`)
31+
- `src/policyengine_api/modal/deploy.py` — Entry point for `modal deploy`
32+
- `src/policyengine_api/version_resolver.py` — Resolves country+version to Modal app name
33+
- `scripts/update_version_registry.py` — Updates Modal Dicts after deploy
34+
- `.github/scripts/modal-deploy-versioned.sh` — Deploy script (generates app name, deploys, updates registry)
35+
36+
**Deploy:** `POLICYENGINE_US_VERSION=X POLICYENGINE_UK_VERSION=Y .github/scripts/modal-deploy-versioned.sh <environment>`
2237

2338
## Modal functions
2439

@@ -58,7 +73,8 @@ make modal-deploy # deploy Modal.com serverless functions
5873
- `src/policyengine_api/api/` - FastAPI routers
5974
- `src/policyengine_api/models/` - SQLModel database models
6075
- `src/policyengine_api/services/` - database and storage services
61-
- `src/policyengine_api/modal_app.py` - Modal.com serverless functions
76+
- `src/policyengine_api/modal/` - Versioned Modal.com serverless functions
77+
- `src/policyengine_api/version_resolver.py` - Version → Modal app name resolution
6278
- `supabase/migrations/` - SQL migrations
6379
- `terraform/` - GCP Cloud Run infrastructure
6480
- `docs/` - Next.js docs site + DESIGN.md

Makefile

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,19 +143,21 @@ db-reseed-prod:
143143
fi
144144

145145
modal-deploy:
146-
@echo "Deploying Modal functions..."
146+
@echo "Deploying versioned Modal simulation app..."
147147
@set -a && . .env.prod && set +a && \
148148
uv run modal secret create policyengine-db \
149149
"DATABASE_URL=$$SUPABASE_POOLER_URL" \
150150
"SUPABASE_URL=$$SUPABASE_URL" \
151151
"SUPABASE_KEY=$$SUPABASE_KEY" \
152152
"STORAGE_BUCKET=$$STORAGE_BUCKET" \
153153
--force
154-
uv run modal deploy src/policyengine_api/modal_app.py
154+
@export POLICYENGINE_US_VERSION=$$(grep -A1 'name = "policyengine-us"' uv.lock | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/') && \
155+
export POLICYENGINE_UK_VERSION=$$(grep -A1 'name = "policyengine-uk"' uv.lock | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/') && \
156+
.github/scripts/modal-deploy-versioned.sh main
155157

156158
modal-serve:
157159
@echo "Running Modal functions locally..."
158-
uv run modal serve src/policyengine_api/modal_app.py
160+
uv run modal serve src/policyengine_api/modal/deploy.py
159161

160162
docs:
161163
@echo "Building docs site..."

changelog.d/201.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Versioned Modal deployments: each deploy creates a named app (`policyengine-v2-us{X}-uk{Y}`) with exact country package version pins, allowing multiple versions to coexist. Cloud Run routes to the correct version via Modal Dict registries.

changelog.d/201.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Refactored monolithic `modal_app.py` into `modal/` package with separate modules for app definition, images, shared utilities, and functions.

changelog.d/201.removed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Deleted monolithic `modal_app.py` (3,450 lines), replaced by `modal/` package.

scripts/update_version_registry.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
Update Modal version registries after deployment.
3+
4+
Each deployment creates a versioned app (e.g., policyengine-v2-us1-592-4-uk2-75-1).
5+
This script updates the version dicts to map package versions to app names.
6+
7+
The dicts allow Cloud Run to route requests for specific versions to the
8+
correct versioned Modal app. Multiple versions coexist — old deployments
9+
remain accessible via their version numbers.
10+
11+
Usage:
12+
uv run python scripts/update_version_registry.py \
13+
--app-name policyengine-v2-us1-592-4-uk2-75-1 \
14+
--us-version 1.592.4 \
15+
--uk-version 2.75.1 \
16+
--environment staging
17+
"""
18+
19+
import argparse
20+
21+
import modal
22+
23+
24+
def _upsert_entry(
25+
version_dict: modal.Dict,
26+
dict_name: str,
27+
key: str,
28+
value: str,
29+
) -> None:
30+
"""Insert or update a single Dict entry, logging the change."""
31+
try:
32+
previous = version_dict[key]
33+
if previous != value:
34+
print(f" {dict_name}[{key}]: {previous} -> {value}")
35+
else:
36+
print(f" {dict_name}[{key}]: {value} (unchanged)")
37+
except KeyError:
38+
print(f" {dict_name}[{key}]: (new) -> {value}")
39+
40+
version_dict[key] = value
41+
42+
43+
def _update_latest(
44+
version_dict: modal.Dict,
45+
dict_name: str,
46+
version: str,
47+
) -> None:
48+
"""Update the 'latest' pointer, logging the change."""
49+
_upsert_entry(version_dict, dict_name, "latest", version)
50+
51+
52+
def update_version_dict(
53+
dict_name: str,
54+
environment: str,
55+
version: str,
56+
app_name: str,
57+
) -> None:
58+
"""Update a version dict: set version → app_name and latest → version.
59+
60+
Args:
61+
dict_name: Name of the Modal Dict (e.g., "simulation-api-us-versions")
62+
environment: Modal environment (staging or main)
63+
version: Package version (e.g., "1.592.4")
64+
app_name: App name to map this version to
65+
"""
66+
version_dict = modal.Dict.from_name(
67+
dict_name,
68+
environment_name=environment,
69+
create_if_missing=True,
70+
)
71+
72+
_upsert_entry(version_dict, dict_name, version, app_name)
73+
_update_latest(version_dict, dict_name, version)
74+
75+
76+
def main():
77+
parser = argparse.ArgumentParser(
78+
description="Update version registries after Modal deployment"
79+
)
80+
parser.add_argument(
81+
"--app-name",
82+
required=True,
83+
help="Versioned app name (e.g., policyengine-v2-us1-592-4-uk2-75-1)",
84+
)
85+
parser.add_argument(
86+
"--us-version",
87+
required=True,
88+
help="US package version (e.g., 1.592.4)",
89+
)
90+
parser.add_argument(
91+
"--uk-version",
92+
required=True,
93+
help="UK package version (e.g., 2.75.1)",
94+
)
95+
parser.add_argument(
96+
"--environment",
97+
required=True,
98+
help="Modal environment (staging or main)",
99+
)
100+
args = parser.parse_args()
101+
102+
print(f"Updating version registries in Modal environment: {args.environment}")
103+
print(f" App name: {args.app_name}")
104+
print(f" US version: {args.us_version}")
105+
print(f" UK version: {args.uk_version}")
106+
print()
107+
108+
print("US version registry:")
109+
update_version_dict(
110+
"simulation-api-us-versions",
111+
args.environment,
112+
args.us_version,
113+
args.app_name,
114+
)
115+
print()
116+
117+
print("UK version registry:")
118+
update_version_dict(
119+
"simulation-api-uk-versions",
120+
args.environment,
121+
args.uk_version,
122+
args.app_name,
123+
)
124+
print()
125+
126+
print("Version registries updated successfully.")
127+
128+
129+
if __name__ == "__main__":
130+
main()

0 commit comments

Comments
 (0)