Skip to content

Commit 3b29778

Browse files
committed
Merge remote-tracking branch 'origin/main' into enable-modal-memory-snapshot
# Conflicts: # tests/unit/modal_release/test_gateway.py # tests/unit/modal_release/test_worker_app.py
2 parents 9fdd92b + b56dd03 commit 3b29778

22 files changed

Lines changed: 1276 additions & 132 deletions

.github/scripts/modal-cleanup-apps.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,12 @@ for app_name in payload.get("app_names", []):
1616
if [[ -z "${app_name}" ]]; then
1717
continue
1818
fi
19-
uv run modal app stop --env "${environment}" "${app_name}"
19+
if ! output="$(uv run modal app stop --env "${environment}" "${app_name}" 2>&1)"; then
20+
echo "${output}"
21+
if [[ "${output}" == *"already stopped"* ]]; then
22+
continue
23+
fi
24+
exit 1
25+
fi
26+
echo "${output}"
2027
done

.github/scripts/modal-deploy-release.sh

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
config_json="${1:?Usage: modal-deploy-release.sh CONFIG_JSON}"
4+
config_json="${1:?Usage: modal-deploy-release.sh CONFIG_JSON [DEPLOY_MODE]}"
5+
deploy_mode="${2:-release}"
56
output_file="${GITHUB_OUTPUT:-}"
7+
modal_extract_versions_script="${MODAL_EXTRACT_VERSIONS_SCRIPT:-.github/scripts/modal_extract_versions.py}"
8+
modal_sync_secrets_script="${MODAL_SYNC_SECRETS_SCRIPT:-.github/scripts/modal-sync-secrets.sh}"
9+
modal_active_worker_apps_script="${MODAL_ACTIVE_WORKER_APPS_SCRIPT:-.github/scripts/modal_active_worker_apps.py}"
10+
modal_cleanup_apps_script="${MODAL_CLEANUP_APPS_SCRIPT:-.github/scripts/modal-cleanup-apps.sh}"
11+
modal_get_url_script="${MODAL_GET_URL_SCRIPT:-.github/scripts/modal-get-url.sh}"
612

713
require_env() {
814
local missing=()
@@ -34,6 +40,18 @@ github_output() {
3440
fi
3541
}
3642

43+
deploy_worker_app() {
44+
local app_name="${1:?app name is required}"
45+
local package_versions_json="${2:-}"
46+
47+
HOUSEHOLD_MODAL_WORKER_APP_NAME="${app_name}" \
48+
HOUSEHOLD_MODAL_PACKAGE_VERSIONS_JSON="${package_versions_json}" \
49+
MODAL_ENVIRONMENT="${modal_environment}" \
50+
uv run modal deploy \
51+
--env "${modal_environment}" \
52+
-m policyengine_household_api.modal_release.worker_app
53+
}
54+
3755
require_env \
3856
MODAL_ENVIRONMENT \
3957
USER_ANALYTICS_DB_USERNAME \
@@ -42,6 +60,15 @@ require_env \
4260

4361
modal_environment="${MODAL_ENVIRONMENT}"
4462

63+
case "${deploy_mode}" in
64+
code|release)
65+
;;
66+
*)
67+
echo "::error::Unsupported Modal deploy mode: ${deploy_mode}"
68+
exit 1
69+
;;
70+
esac
71+
4572
uv run alembic upgrade head
4673
analytics_database_revision="$(
4774
uv run python -m policyengine_household_api.modal_release.analytics_revision
@@ -54,7 +81,7 @@ github_output "analytics_database_revision" "${analytics_database_revision}"
5481

5582
versions_output="$(mktemp)"
5683
trap 'rm -f "${versions_output}"' EXIT
57-
uv run python .github/scripts/modal_extract_versions.py \
84+
uv run python "${modal_extract_versions_script}" \
5885
--github-output "${versions_output}"
5986
if [ -n "${output_file}" ]; then
6087
cat "${versions_output}" >> "${output_file}"
@@ -64,34 +91,47 @@ worker_app_name="$(
6491
"${versions_output}"
6592
)"
6693

67-
bash .github/scripts/modal-sync-secrets.sh
94+
bash "${modal_sync_secrets_script}"
6895

69-
new_app_target="$(config_value new_app_target)"
70-
if [ "${new_app_target}" != "none" ]; then
71-
HOUSEHOLD_MODAL_WORKER_APP_NAME="${worker_app_name}" \
72-
MODAL_ENVIRONMENT="${modal_environment}" \
73-
uv run modal deploy \
74-
--env "${modal_environment}" \
75-
-m policyengine_household_api.modal_release.worker_app
76-
fi
96+
if [ "${deploy_mode}" = "code" ]; then
97+
active_apps_tsv="$(mktemp)"
98+
trap 'rm -f "${versions_output}" "${active_apps_tsv}"' EXIT
99+
100+
uv run python "${modal_active_worker_apps_script}" \
101+
--modal-environment "${modal_environment}" \
102+
--output-tsv "${active_apps_tsv}"
77103

78-
uv run python -m policyengine_household_api.modal_release.update_manifest \
79-
--config-json "${config_json}" \
80-
--new-app-name "${worker_app_name}" \
81-
--source-commit "${GITHUB_SHA}" \
82-
--analytics-database-revision "${analytics_database_revision}" \
83-
--modal-environment "${modal_environment}" \
84-
--cleanup-output modal-cleanup.json \
85-
--manifest-output modal-manifest.json
104+
while IFS=$'\t' read -r active_app_name package_versions_json; do
105+
if [ -z "${active_app_name}" ]; then
106+
continue
107+
fi
108+
deploy_worker_app "${active_app_name}" "${package_versions_json}"
109+
done < "${active_apps_tsv}"
110+
else
111+
new_app_target="$(config_value new_app_target)"
112+
if [ "${new_app_target}" != "none" ]; then
113+
deploy_worker_app "${worker_app_name}" ""
114+
fi
115+
116+
uv run python -m policyengine_household_api.modal_release.update_manifest \
117+
--config-json "${config_json}" \
118+
--new-app-name "${worker_app_name}" \
119+
--analytics-database-revision "${analytics_database_revision}" \
120+
--modal-environment "${modal_environment}" \
121+
--cleanup-output modal-cleanup.json \
122+
--manifest-output modal-manifest.json
123+
fi
86124

87125
uv run modal deploy \
88126
--env "${modal_environment}" \
89127
-m policyengine_household_api.modal_release.gateway_app
90128

91-
cleanup_target="$(config_value cleanup_target)"
92-
if [ "${cleanup_target}" != "none" ]; then
93-
bash .github/scripts/modal-cleanup-apps.sh modal-cleanup.json
129+
if [ "${deploy_mode}" = "release" ]; then
130+
cleanup_target="$(config_value cleanup_target)"
131+
if [ "${cleanup_target}" != "none" ]; then
132+
bash "${modal_cleanup_apps_script}" modal-cleanup.json
133+
fi
94134
fi
95135

96-
gateway_url="$(bash .github/scripts/modal-get-url.sh)"
136+
gateway_url="$(bash "${modal_get_url_script}")"
97137
curl -fsS "${gateway_url}/liveness_check"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
from pathlib import Path
6+
7+
import modal
8+
9+
from policyengine_household_api.modal_release.manifest import (
10+
MANIFEST_DICT_KEY,
11+
MANIFEST_DICT_NAME,
12+
active_app_deployments,
13+
)
14+
15+
16+
def main() -> None:
17+
args = _parse_args()
18+
manifest_dict = modal.Dict.from_name(
19+
MANIFEST_DICT_NAME,
20+
create_if_missing=False,
21+
environment_name=args.modal_environment,
22+
)
23+
deployments = active_app_deployments(manifest_dict.get(MANIFEST_DICT_KEY))
24+
25+
if args.output_json:
26+
Path(args.output_json).write_text(
27+
json.dumps(deployments, indent=2, sort_keys=True) + "\n"
28+
)
29+
if args.output_tsv:
30+
lines = [
31+
"\t".join(
32+
(
33+
deployment["app_name"],
34+
json.dumps(
35+
deployment["package_versions"],
36+
sort_keys=True,
37+
separators=(",", ":"),
38+
),
39+
)
40+
)
41+
for deployment in deployments
42+
]
43+
Path(args.output_tsv).write_text("\n".join(lines) + "\n")
44+
45+
print(json.dumps(deployments, indent=2, sort_keys=True))
46+
47+
48+
def _parse_args() -> argparse.Namespace:
49+
parser = argparse.ArgumentParser(
50+
description="Emit active Modal worker apps from the release manifest."
51+
)
52+
parser.add_argument("--modal-environment", required=True)
53+
parser.add_argument("--output-json")
54+
parser.add_argument("--output-tsv")
55+
return parser.parse_args()
56+
57+
58+
if __name__ == "__main__":
59+
main()

.github/scripts/resolve_modal_release_config.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@
2626
class ResolvedModalRelease:
2727
should_deploy: bool
2828
source: str
29+
deploy_mode: str
2930
config: ModalReleaseConfig | None = None
3031

3132
def to_dict(self) -> dict[str, Any]:
3233
data = {
3334
"should_deploy": self.should_deploy,
3435
"source": self.source,
36+
"deploy_mode": self.deploy_mode,
3537
"config": None,
3638
}
3739
if self.config:
@@ -82,15 +84,16 @@ def resolve_release_from_event(
8284
return ResolvedModalRelease(
8385
True,
8486
"workflow-dispatch-inputs",
87+
"release",
8588
workflow_dispatch_config_from_inputs(event.get("inputs") or {}),
8689
)
8790

8891
if "head_commit" not in event:
89-
return ResolvedModalRelease(False, "unsupported-event")
92+
return ResolvedModalRelease(False, "unsupported-event", "none")
9093

9194
message = (event.get("head_commit") or {}).get("message")
9295
if message != WEEKLY_UPDATE_COMMIT_MESSAGE:
93-
return ResolvedModalRelease(False, "push-not-release-commit")
96+
return ResolvedModalRelease(False, "push-not-release-commit", "none")
9497

9598
repository = (event.get("repository") or {}).get("full_name")
9699
source_sha = event.get("before") or event.get("after")
@@ -109,8 +112,8 @@ def resolve_release_from_event(
109112

110113
return ResolvedModalRelease(
111114
True,
112-
"weekly-default",
113-
default_weekly_config(),
115+
"code-only",
116+
"code",
114117
)
115118

116119

@@ -121,10 +124,15 @@ def resolve_release_from_body(
121124
deploy_when_missing: bool,
122125
) -> ResolvedModalRelease:
123126
if not body_contains_modal_release_config(body):
124-
return ResolvedModalRelease(deploy_when_missing, f"{source}-missing")
127+
deploy_mode = "code" if deploy_when_missing else "none"
128+
return ResolvedModalRelease(
129+
deploy_when_missing,
130+
f"{source}-missing",
131+
deploy_mode,
132+
)
125133

126134
config = parse_modal_release_config_from_body(body)
127-
return ResolvedModalRelease(True, source, config)
135+
return ResolvedModalRelease(True, source, "release", config)
128136

129137

130138
def workflow_dispatch_config_from_inputs(
@@ -200,6 +208,7 @@ def write_github_outputs(
200208
outputs = {
201209
"should_deploy": str(result["should_deploy"]).lower(),
202210
"source": result["source"],
211+
"deploy_mode": result["deploy_mode"],
203212
"new_app_target": config.get("new_app_target", "none"),
204213
"promote_existing_frontier": str(
205214
config.get("promote_existing_frontier", False)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import subprocess
5+
6+
7+
def test_modal_cleanup_treats_already_stopped_app_as_success(tmp_path):
8+
cleanup_file = tmp_path / "cleanup.json"
9+
cleanup_file.write_text('{"app_names":["old-app"]}\n')
10+
_write_fake_uv(tmp_path, exit_code=1, output="App is already stopped.")
11+
12+
result = subprocess.run(
13+
[
14+
"bash",
15+
".github/scripts/modal-cleanup-apps.sh",
16+
str(cleanup_file),
17+
],
18+
capture_output=True,
19+
env={
20+
**os.environ,
21+
"PATH": f"{tmp_path}:{os.environ['PATH']}",
22+
"MODAL_ENVIRONMENT": "testing",
23+
},
24+
text=True,
25+
)
26+
27+
assert result.returncode == 0
28+
assert "already stopped" in result.stdout
29+
30+
31+
def test_modal_cleanup_fails_on_other_stop_errors(tmp_path):
32+
cleanup_file = tmp_path / "cleanup.json"
33+
cleanup_file.write_text('{"app_names":["old-app"]}\n')
34+
_write_fake_uv(tmp_path, exit_code=1, output="Permission denied.")
35+
36+
result = subprocess.run(
37+
[
38+
"bash",
39+
".github/scripts/modal-cleanup-apps.sh",
40+
str(cleanup_file),
41+
],
42+
capture_output=True,
43+
env={
44+
**os.environ,
45+
"PATH": f"{tmp_path}:{os.environ['PATH']}",
46+
"MODAL_ENVIRONMENT": "testing",
47+
},
48+
text=True,
49+
)
50+
51+
assert result.returncode == 1
52+
assert "Permission denied" in result.stdout
53+
54+
55+
def _write_fake_uv(tmp_path, *, exit_code: int, output: str) -> None:
56+
uv = tmp_path / "uv"
57+
uv.write_text(
58+
f"""#!/usr/bin/env bash
59+
echo "{output}" >&2
60+
exit {exit_code}
61+
"""
62+
)
63+
uv.chmod(0o755)

0 commit comments

Comments
 (0)