Skip to content

Commit fdb6125

Browse files
authored
Merge pull request #1536 from PolicyEngine/codex/enable-modal-custom-domain
Enable Modal custom domain for household API gateway
2 parents 0268529 + 010ccd5 commit fdb6125

15 files changed

Lines changed: 399 additions & 13 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
modal_environment="${MODAL_ENVIRONMENT:-main}"
5+
modal_get_url_script="${MODAL_GET_URL_SCRIPT:-.github/scripts/modal-get-url.sh}"
6+
custom_domain_url="${HOUSEHOLD_MODAL_GATEWAY_CUSTOM_DOMAIN_URL:-https://household.api.policyengine.org}"
7+
8+
if [ "${modal_environment}" != "main" ]; then
9+
echo "Skipping custom-domain smoke check outside the main Modal environment."
10+
exit 0
11+
fi
12+
13+
gateway_url="$(bash "${modal_get_url_script}")"
14+
custom_domain_url="${custom_domain_url%/}"
15+
gateway_url="${gateway_url%/}"
16+
17+
tmpdir="$(mktemp -d)"
18+
trap 'rm -rf "${tmpdir}"' EXIT
19+
20+
gateway_versions="${tmpdir}/gateway-versions.json"
21+
custom_versions="${tmpdir}/custom-domain-versions.json"
22+
23+
echo "Checking generated Modal gateway at ${gateway_url}"
24+
curl -fsS "${gateway_url}/liveness_check" >/dev/null
25+
curl -fsS "${gateway_url}/versions/us" > "${gateway_versions}"
26+
27+
echo "Checking production custom domain at ${custom_domain_url}"
28+
curl -fsS "${custom_domain_url}/liveness_check" >/dev/null
29+
curl -fsS "${custom_domain_url}/versions/us" > "${custom_versions}"
30+
31+
python - "${gateway_versions}" "${custom_versions}" <<'PY'
32+
import json
33+
import sys
34+
from pathlib import Path
35+
36+
37+
def load_versions(path: str, label: str) -> dict[str, str]:
38+
try:
39+
data = json.loads(Path(path).read_text())
40+
except json.JSONDecodeError as e:
41+
sys.exit(f"{label} /versions/us did not return JSON: {e}")
42+
43+
if not isinstance(data, dict):
44+
sys.exit(f"{label} /versions/us returned a non-object JSON value")
45+
return data
46+
47+
48+
gateway_versions = load_versions(sys.argv[1], "Generated Modal gateway")
49+
custom_versions = load_versions(sys.argv[2], "Custom domain")
50+
51+
if gateway_versions != custom_versions:
52+
sys.exit(
53+
"Custom domain /versions/us does not match the generated Modal "
54+
f"gateway. generated={gateway_versions!r} custom={custom_versions!r}"
55+
)
56+
57+
print("Custom domain points at the deployed Modal gateway.")
58+
PY
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import os
2+
from pathlib import Path
3+
import subprocess
4+
5+
6+
SCRIPT = ".github/scripts/modal-custom-domain-smoke.sh"
7+
8+
9+
def test_modal_custom_domain_smoke_passes_when_versions_match(tmp_path):
10+
env = _smoke_env(
11+
tmp_path,
12+
gateway_versions='{"current":"1.691.1","frontier":"1.691.1"}',
13+
custom_versions='{"current":"1.691.1","frontier":"1.691.1"}',
14+
)
15+
16+
result = subprocess.run(
17+
["bash", SCRIPT],
18+
capture_output=True,
19+
env=env,
20+
text=True,
21+
)
22+
23+
assert result.returncode == 0, result.stderr
24+
assert (
25+
"Custom domain points at the deployed Modal gateway." in result.stdout
26+
)
27+
28+
29+
def test_modal_custom_domain_smoke_fails_when_custom_domain_is_not_gateway(
30+
tmp_path,
31+
):
32+
env = _smoke_env(
33+
tmp_path,
34+
gateway_versions='{"current":"1.691.1","frontier":"1.691.1"}',
35+
custom_versions="OK",
36+
)
37+
38+
result = subprocess.run(
39+
["bash", SCRIPT],
40+
capture_output=True,
41+
env=env,
42+
text=True,
43+
)
44+
45+
assert result.returncode == 1
46+
assert "Custom domain /versions/us did not return JSON" in result.stderr
47+
48+
49+
def test_modal_custom_domain_smoke_fails_when_versions_differ(tmp_path):
50+
env = _smoke_env(
51+
tmp_path,
52+
gateway_versions='{"current":"1.691.1","frontier":"1.691.1"}',
53+
custom_versions='{"current":"1.690.0","frontier":"1.691.1"}',
54+
)
55+
56+
result = subprocess.run(
57+
["bash", SCRIPT],
58+
capture_output=True,
59+
env=env,
60+
text=True,
61+
)
62+
63+
assert result.returncode == 1
64+
assert "does not match the generated Modal gateway" in result.stderr
65+
66+
67+
def test_modal_custom_domain_smoke_skips_non_main_environments(tmp_path):
68+
curl_log = tmp_path / "curl.log"
69+
env = _smoke_env(
70+
tmp_path,
71+
gateway_versions='{"current":"1.691.1"}',
72+
custom_versions='{"current":"1.691.1"}',
73+
)
74+
env["MODAL_ENVIRONMENT"] = "staging"
75+
env["CURL_LOG"] = str(curl_log)
76+
77+
result = subprocess.run(
78+
["bash", SCRIPT],
79+
capture_output=True,
80+
env=env,
81+
text=True,
82+
)
83+
84+
assert result.returncode == 0
85+
assert "Skipping custom-domain smoke check" in result.stdout
86+
assert not curl_log.exists()
87+
88+
89+
def _smoke_env(
90+
tmp_path: Path,
91+
*,
92+
gateway_versions: str,
93+
custom_versions: str,
94+
) -> dict[str, str]:
95+
get_url_script = tmp_path / "modal-get-url.sh"
96+
get_url_script.write_text(
97+
"#!/usr/bin/env bash\n"
98+
"set -euo pipefail\n"
99+
"echo https://generated-modal.example\n"
100+
)
101+
get_url_script.chmod(0o755)
102+
103+
fake_curl = tmp_path / "curl"
104+
fake_curl.write_text(
105+
"""#!/usr/bin/env bash
106+
set -euo pipefail
107+
url="${@: -1}"
108+
if [ -n "${CURL_LOG:-}" ]; then
109+
printf '%s\\n' "${url}" >> "${CURL_LOG}"
110+
fi
111+
112+
case "${url}" in
113+
https://generated-modal.example/liveness_check|https://custom-domain.example/liveness_check)
114+
echo OK
115+
;;
116+
https://generated-modal.example/versions/us)
117+
printf '%s\\n' "${GATEWAY_VERSIONS}"
118+
;;
119+
https://custom-domain.example/versions/us)
120+
printf '%s\\n' "${CUSTOM_VERSIONS}"
121+
;;
122+
*)
123+
echo "Unexpected URL: ${url}" >&2
124+
exit 22
125+
;;
126+
esac
127+
"""
128+
)
129+
fake_curl.chmod(0o755)
130+
131+
return {
132+
**os.environ,
133+
"PATH": f"{tmp_path}:{os.environ['PATH']}",
134+
"MODAL_ENVIRONMENT": "main",
135+
"MODAL_GET_URL_SCRIPT": str(get_url_script),
136+
"HOUSEHOLD_MODAL_GATEWAY_CUSTOM_DOMAIN_URL": (
137+
"https://custom-domain.example"
138+
),
139+
"GATEWAY_VERSIONS": gateway_versions,
140+
"CUSTOM_VERSIONS": custom_versions,
141+
}

.github/scripts/test_resolve_modal_release_config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,27 @@ def test_resolve_release_uses_workflow_dispatch_inputs():
6262
assert resolved.config.cleanup_target == "frontier"
6363

6464

65+
def test_resolve_release_accepts_both_workflow_dispatch_target():
66+
resolved = resolve_release_from_event(
67+
{
68+
"inputs": {
69+
"new_app_target": "both",
70+
"promote_existing_frontier": "false",
71+
"cleanup_target": "retired",
72+
}
73+
},
74+
fetch_pr_body_for_commit=lambda _repository, _sha: None,
75+
event_name="workflow_dispatch",
76+
)
77+
78+
assert resolved.should_deploy is True
79+
assert resolved.deploy_mode == "release"
80+
assert resolved.config is not None
81+
assert resolved.config.new_app_target == "both"
82+
assert resolved.config.promote_existing_frontier is False
83+
assert resolved.config.cleanup_target == "retired"
84+
85+
6586
def test_resolve_release_uses_weekly_default_for_empty_workflow_dispatch():
6687
resolved = resolve_release_from_event(
6788
{"inputs": {}},

.github/workflows/deploy-staged.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ on:
1414
options:
1515
- frontier
1616
- current
17+
- both
1718
- none
1819
promote_existing_frontier:
1920
description: "Move the existing frontier worker to current before deploying a new frontier"
@@ -265,6 +266,9 @@ jobs:
265266
'${{ needs.resolve-modal-release-config.outputs.config_json }}' \
266267
'${{ needs.resolve-modal-release-config.outputs.deploy_mode }}'
267268
269+
- name: Verify production custom domain
270+
run: bash .github/scripts/modal-custom-domain-smoke.sh
271+
268272
publish:
269273
name: Publish to PyPI
270274
runs-on: ubuntu-latest

changelog.d/1533.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow Modal releases to launch the same newly built worker on both `current` and `frontier`.

changelog.d/1535.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Enable the production Modal gateway custom domain and verify it during deployment.

docs/engineering/skills/modal-release-prs.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ modal_release:
2424
2525
Allowed values:
2626
27-
- `new_app_target`: `frontier`, `current`, or `none`
27+
- `new_app_target`: `frontier`, `current`, `both`, or `none`
2828
- `promote_existing_frontier`: `true` or `false`
2929
- `cleanup_target`: `none`, `retired`, `frontier`, or `current`
3030

@@ -50,6 +50,20 @@ apps in the retired history are stopped after the manifest is updated. Use
5050
`cleanup_target: none` only when the user explicitly asks to preserve retired
5151
worker apps after release.
5252

53+
Use this release shape when the newly built worker must become both `current`
54+
and `frontier` in a single release:
55+
56+
```yaml
57+
modal_release:
58+
new_app_target: both
59+
promote_existing_frontier: false
60+
cleanup_target: retired
61+
```
62+
63+
This deploys one new worker app, writes the same app reference into both
64+
`current` and `frontier`, and moves the previous active workers into the
65+
manifest's `retired` history.
66+
5367
Do not use PR labels, branch names, model-specific tags, or title prefixes to
5468
control Modal release behavior. The PR body YAML block is the source of truth.
5569
The PR-body YAML block is the only automatic signal that a deployment should

policyengine_household_api/modal_release/gateway_app.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,45 @@
1919
"HOUSEHOLD_MODAL_GATEWAY_WEB_ENDPOINT_LABEL",
2020
"household-api-gateway",
2121
)
22+
GATEWAY_CUSTOM_DOMAIN = "household.api.policyengine.org"
23+
GATEWAY_CUSTOM_DOMAINS_ENV = "HOUSEHOLD_MODAL_GATEWAY_CUSTOM_DOMAINS"
2224

2325

24-
def gateway_wsgi_app_options() -> dict[str, object]:
25-
return {"label": GATEWAY_WEB_ENDPOINT_LABEL}
26+
def gateway_custom_domains(
27+
*,
28+
modal_environment: str | None = None,
29+
custom_domains: str | None = None,
30+
) -> tuple[str, ...]:
31+
if custom_domains is None:
32+
custom_domains = os.getenv(GATEWAY_CUSTOM_DOMAINS_ENV)
33+
34+
if custom_domains is not None:
35+
return tuple(
36+
domain.strip()
37+
for domain in custom_domains.split(",")
38+
if domain.strip()
39+
)
40+
41+
environment = modal_environment or os.getenv("MODAL_ENVIRONMENT", "main")
42+
if environment == "main":
43+
return (GATEWAY_CUSTOM_DOMAIN,)
44+
45+
return ()
46+
47+
48+
def gateway_wsgi_app_options(
49+
*,
50+
modal_environment: str | None = None,
51+
custom_domains: str | None = None,
52+
) -> dict[str, object]:
53+
options: dict[str, object] = {"label": GATEWAY_WEB_ENDPOINT_LABEL}
54+
domains = gateway_custom_domains(
55+
modal_environment=modal_environment,
56+
custom_domains=custom_domains,
57+
)
58+
if domains:
59+
options["custom_domains"] = domains
60+
return options
2661

2762

2863
app = modal.App(GATEWAY_APP_NAME)

policyengine_household_api/modal_release/manifest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,22 @@ def apply_release_config(
207207
next_manifest["current"] = deepcopy(dict(new_app or {}))
208208
next_manifest["frontier"] = None
209209

210+
elif config.new_app_target == NewAppTarget.BOTH:
211+
retired = _retire_entry(
212+
retired,
213+
next_manifest.get("current"),
214+
retired_at=retired_at,
215+
reason="replaced-current",
216+
)
217+
retired = _retire_entry(
218+
retired,
219+
next_manifest.get("frontier"),
220+
retired_at=retired_at,
221+
reason="replaced-frontier",
222+
)
223+
next_manifest["current"] = deepcopy(dict(new_app or {}))
224+
next_manifest["frontier"] = deepcopy(dict(new_app or {}))
225+
210226
next_manifest["retired"] = retired
211227
return validate_manifest(next_manifest)
212228

policyengine_household_api/modal_release/release_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class ModalReleaseConfigError(ValueError):
3838
class NewAppTarget(StrEnum):
3939
FRONTIER = "frontier"
4040
CURRENT = "current"
41+
BOTH = "both"
4142
NONE = "none"
4243

4344

0 commit comments

Comments
 (0)