diff --git a/.github/scripts/deploy_cloud_run_candidate.sh b/.github/scripts/deploy_cloud_run_candidate.sh index fee77af4f..ad9bb4a11 100755 --- a/.github/scripts/deploy_cloud_run_candidate.sh +++ b/.github/scripts/deploy_cloud_run_candidate.sh @@ -7,6 +7,10 @@ cloud_run_set_defaults bash .github/scripts/validate_cloud_run_deploy_env.sh +# DEPLOYED_POLICYENGINE_VERSION and POLICYENGINE_VERSION_LIVE_SINCE, reported +# by GET /deployment. +live_since_vars="$(bash .github/scripts/resolve_policyengine_live_since.sh)" + env_vars=( "APP_ENVIRONMENT=${DEPLOYMENT_ENVIRONMENT}" "POLICYENGINE_DB_INSTANCE_CONNECTION_NAME=${POLICYENGINE_DB_INSTANCE_CONNECTION_NAME}" @@ -48,6 +52,9 @@ env_vars=( "PUBLIC_API_AUTH_ISSUER=${PUBLIC_API_AUTH_ISSUER:-}" "PUBLIC_API_AUTH_AUDIENCE=${PUBLIC_API_AUTH_AUDIENCE:-}" ) +while IFS= read -r live_since_var; do + env_vars+=("${live_since_var}") +done <<<"${live_since_vars}" # Optional: account deletion removes the Auth0 user only when the Management # API client is configured. Validation requires all three values or none. diff --git a/.github/scripts/locked_packages.py b/.github/scripts/locked_packages.py index 2391e2b24..55b22f11d 100644 --- a/.github/scripts/locked_packages.py +++ b/.github/scripts/locked_packages.py @@ -11,6 +11,7 @@ locked_packages.py upgradeable LOCK names it may upgrade, one per line locked_packages.py held LOCK "name version" of each package it holds locked_packages.py summary OLD NEW Markdown summary of the changes + locked_packages.py bundle-version LOCK the locked .py bundle version """ from __future__ import annotations @@ -78,6 +79,25 @@ def held_versions(lock_path: Path) -> list[str]: ] +def bundle_version(lock_path: Path) -> str: + """Return the one version of the .py bundle package that ``lock_path`` locks. + + Raises: + SystemExit: when the bundle is locked at more than one version, since + the deploy could not say which one it installs. + + """ + versions, _ = _locked_versions(lock_path) + found = versions[BUNDLE_PACKAGE] + if len(found) != 1: + message = ( + f"{BUNDLE_PACKAGE} is locked at {', '.join(found)} in {lock_path}; " + "expected exactly one version." + ) + raise SystemExit(message) + return found[0] + + def summary(old_lock: Path, new_lock: Path) -> str: """Return a Markdown summary of the version changes from old_lock to new_lock.""" old_versions, _ = _locked_versions(old_lock) @@ -128,6 +148,7 @@ def main() -> None: commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("upgradeable").add_argument("lock", type=Path) commands.add_parser("held").add_argument("lock", type=Path) + commands.add_parser("bundle-version").add_argument("lock", type=Path) summary_parser = commands.add_parser("summary") summary_parser.add_argument("old_lock", type=Path) summary_parser.add_argument("new_lock", type=Path) @@ -137,6 +158,8 @@ def main() -> None: print("\n".join(upgradeable(arguments.lock))) elif arguments.command == "held": print("\n".join(held_versions(arguments.lock))) + elif arguments.command == "bundle-version": + print(bundle_version(arguments.lock)) else: sys.stdout.write(summary(arguments.old_lock, arguments.new_lock)) diff --git a/.github/scripts/resolve_policyengine_live_since.sh b/.github/scripts/resolve_policyengine_live_since.sh new file mode 100644 index 000000000..72204d2f3 --- /dev/null +++ b/.github/scripts/resolve_policyengine_live_since.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Decide when the policyengine.py (.py) version this deploy installs went live. +# A deploy that keeps the serving revision's .py version keeps that revision's +# date; a new .py version goes live with this deploy. The API redeploys several +# times a day without changing .py, so a plain deploy time would not say how +# long the version has been served. +# +# Inputs: PREVIOUS_CLOUD_RUN_REVISION (the revision serving 100% of traffic, +# from capture_cloud_run_service_state.sh; empty on a first deploy) and the +# usual CLOUD_RUN_* settings. POLICYENGINE_LOCK_FILE and +# POLICYENGINE_LIVE_SINCE_NOW override the lock path and the clock for tests. +# +# Prints DEPLOYED_POLICYENGINE_VERSION=... and POLICYENGINE_VERSION_LIVE_SINCE=... +# The date is recorded only when this deploy is the event (a new .py version +# or a first deploy). When the serving revision has the same version but no +# date (deployed before dates were recorded), or cannot be read, the date is +# left empty (unknown) rather than set to a later, wrong time. A failed lookup +# never fails the deploy. + +set -euo pipefail + +source .github/scripts/cloud_run_env.sh +cloud_run_set_defaults + +lock_file="${POLICYENGINE_LOCK_FILE:-uv.lock}" +# The same uv.lock reader the daily relock uses; it fails unless policyengine +# is locked at exactly one version. +version="$(python3 .github/scripts/locked_packages.py bundle-version "${lock_file}")" +if [[ -z "${version}" ]]; then + echo "policyengine is not locked in ${lock_file}" >&2 + exit 1 +fi + +now="${POLICYENGINE_LIVE_SINCE_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +live_since="${now}" +previous="${PREVIOUS_CLOUD_RUN_REVISION:-}" + +if [[ -n "${previous}" && "${CLOUD_RUN_DRY_RUN:-0}" != "1" ]]; then + gcloud_bin="${GCLOUD_BIN:-gcloud}" + if revision_json="$("${gcloud_bin}" run revisions describe "${previous}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)"; then + revision_env() { + jq -r --arg name "$1" ' + [.spec.containers[0].env[]? | select(.name == $name) | .value][0] // "" + ' <<<"${revision_json}" 2>/dev/null || true + } + previous_version="$(revision_env DEPLOYED_POLICYENGINE_VERSION)" + previous_since="$(revision_env POLICYENGINE_VERSION_LIVE_SINCE)" + if [[ "${previous_version}" == "${version}" ]]; then + live_since="${previous_since}" + elif [[ -z "${previous_version}" ]]; then + # A revision from before versions were recorded may already serve this + # version, so its start is unknown. + live_since="" + fi + else + echo "Could not read ${previous}; when .py ${version} went live is unknown" >&2 + live_since="" + fi +fi + +printf 'DEPLOYED_POLICYENGINE_VERSION=%s\nPOLICYENGINE_VERSION_LIVE_SINCE=%s\n' \ + "${version}" "${live_since}" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 255c11e3d..7c525a61f 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -308,6 +308,7 @@ jobs: - name: Deploy tagged Cloud Run staging candidate run: bash .github/scripts/deploy_cloud_run_candidate.sh env: + PREVIOUS_CLOUD_RUN_REVISION: ${{ steps.previous.outputs.revision }} CLOUD_RUN_IMAGE_TAG: ${{ steps.cloud_run.outputs.image_tag }} CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT: policyengine-api-cr-staging@policyengine-api.iam.gserviceaccount.com @@ -577,6 +578,7 @@ jobs: - name: Deploy tagged Cloud Run candidate run: bash .github/scripts/deploy_cloud_run_candidate.sh env: + PREVIOUS_CLOUD_RUN_REVISION: ${{ steps.previous.outputs.revision }} CLOUD_RUN_IMAGE_TAG: ${{ steps.cloud_run.outputs.image_tag }} CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT: ${{ secrets.GCP_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT }} diff --git a/changelog.d/added/4083.md b/changelog.d/added/4083.md new file mode 100644 index 000000000..18289a059 --- /dev/null +++ b/changelog.d/added/4083.md @@ -0,0 +1 @@ +`GET /deployment` reports the policyengine.py version the API serves and when it went live; the candidate deploy keeps that date across redeploys of the same version. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f39a5e369..75bf588f5 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -16,7 +16,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | Route group | Path segments | DB entity | Simulation flow | | --- | --- | --- | --- | -| `health` | `health`, `simulation-gateway-check`, `liveness-check`, `readiness-check` | `none` | `none` | +| `health` | `health`, `simulation-gateway-check`, `liveness-check`, `readiness-check`, `deployment` | `none` | `none` | | `specification` | `specification` | `none` | `none` | | `metadata` | `metadata`, `datasets`, `economy-options`, `parameter-values`, `parameters`, `regions`, `tax-benefit-model-versions`, `tax-benefit-models`, `variables` | `metadata` | `none` | | `policy` | `policy`, `policies`, `user-policy`, `user-policies` | `policy` | `none` | diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 7a0f1b67d..0938a6f98 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -14,7 +14,8 @@ "health", "simulation-gateway-check", "liveness-check", - "readiness-check" + "readiness-check", + "deployment" ], "sim_flow": null }, diff --git a/policyengine_api/fastapi_routes/health.py b/policyengine_api/fastapi_routes/health.py index 6e0da7ec1..edff74500 100644 --- a/policyengine_api/fastapi_routes/health.py +++ b/policyengine_api/fastapi_routes/health.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os +from importlib import metadata as importlib_metadata from typing import TYPE_CHECKING, Literal from fastapi import APIRouter, HTTPException @@ -25,6 +27,20 @@ class SimulationGatewayHealthResponse(BaseModel): simulation_gateway: Literal["healthy"] +class DeploymentResponse(BaseModel): + """The policyengine.py version this revision serves, and since when. + + ``policyengine_version_live_since`` is when this .py version first went + live: redeploys that keep the version keep the date. It is null on a + revision deployed before the deploy recorded it, outside Cloud Run, or + when the version the deploy dated is not the installed one. + """ + + policyengine_version: str + policyengine_version_live_since: str | None + revision: str | None + + def build_core_health_router( dependencies: NativeRouteDependencies, ) -> APIRouter: @@ -35,6 +51,21 @@ def build_core_health_router( def health() -> HealthResponse: return HealthResponse(status="healthy") + @router.get("/deployment", response_model=DeploymentResponse) + def deployment() -> DeploymentResponse: + installed = importlib_metadata.version("policyengine") + # The deploy computed the date for the version it read from uv.lock; + # it describes the installed version only when the two agree. + deployed = os.environ.get("DEPLOYED_POLICYENGINE_VERSION") + live_since = os.environ.get("POLICYENGINE_VERSION_LIVE_SINCE") or None + return DeploymentResponse( + policyengine_version=installed, + policyengine_version_live_since=( + live_since if deployed == installed else None + ), + revision=os.environ.get("K_REVISION") or None, + ) + @router.get( "/simulation-gateway-check", response_model=SimulationGatewayHealthResponse, diff --git a/policyengine_api/migration_flags.py b/policyengine_api/migration_flags.py index 2b966808b..e5d9a555f 100644 --- a/policyengine_api/migration_flags.py +++ b/policyengine_api/migration_flags.py @@ -51,6 +51,7 @@ class RouteImplementation(StrEnum): "/simulation-gateway-check": "health", "/liveness-check": "health", "/readiness-check": "health", + "/deployment": "health", "/specification": "specification", "/v2/openapi.json": "specification", } diff --git a/policyengine_api/migration_registry.py b/policyengine_api/migration_registry.py index e49de6986..dd41885ab 100644 --- a/policyengine_api/migration_registry.py +++ b/policyengine_api/migration_registry.py @@ -27,6 +27,7 @@ class RouteGroupConfig: "simulation-gateway-check", "liveness-check", "readiness-check", + "deployment", ), ), RouteGroupConfig( diff --git a/tests/unit/test_policyengine_live_since.py b/tests/unit/test_policyengine_live_since.py new file mode 100644 index 000000000..7b25fea3a --- /dev/null +++ b/tests/unit/test_policyengine_live_since.py @@ -0,0 +1,269 @@ +"""When the deployed policyengine.py version went live (deploy script and route).""" + +from __future__ import annotations + +import json +import subprocess +import sys +from importlib import metadata as importlib_metadata +from typing import TYPE_CHECKING + +import pytest +from fastapi.testclient import TestClient + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.migration_flags import infer_route_group +from tests.unit.cloud_run_deploy_support import ( + PRODUCTION_CLOUD_RUN_SERVICE, + REPO, + push_workflow, + required_runtime_env, + run_script, + script_env, + workflow_job_block, +) +from tests.unit.test_asgi_factory import create_test_wsgi_app + +if TYPE_CHECKING: + from pathlib import Path + +SCRIPT = ".github/scripts/resolve_policyengine_live_since.sh" +NOW = "2026-10-28T09:00:00Z" +EARLIER = "2026-10-20T12:00:00Z" + + +def _lock(tmp_path: Path, version: str) -> Path: + lock = tmp_path / "uv.lock" + lock.write_text( + "version = 1\n\n" + '[[package]]\nname = "policyengine"\n' + f'version = "{version}"\n\n' + '[[package]]\nname = "policyengine-us"\nversion = "2.2.1"\n', + encoding="utf-8", + ) + return lock + + +def _fake_gcloud(tmp_path: Path, revision_env: dict[str, str] | None) -> Path: + """Write a gcloud that describes one revision with revision_env, or fails.""" + calls = tmp_path / "gcloud-calls.json" + gcloud = tmp_path / "gcloud" + body = ( + "sys.exit('describe failed')" + if revision_env is None + else "print(json.dumps({'spec': {'containers': [{'env': ENV}]}}))" + ) + env_items = [ + {"name": name, "value": value} for name, value in (revision_env or {}).items() + ] + gcloud.write_text( + "#!/usr/bin/env python3\n" + "import json, pathlib, sys\n" + f"ENV = {env_items!r}\n" + f"pathlib.Path({str(calls)!r}).write_text(json.dumps(sys.argv[1:]))\n" + f"{body}\n", + encoding="utf-8", + ) + gcloud.chmod(0o755) + return gcloud + + +def _resolve( + tmp_path: Path, + *, + version: str = "6.11.1", + previous: str = "policyengine-api-00001-old", + revision_env: dict[str, str] | None = None, + dry_run: bool = False, +): + gcloud = _fake_gcloud(tmp_path, revision_env) + return run_script( + SCRIPT, + script_env( + CLOUD_RUN_DRY_RUN="1" if dry_run else "0", + CLOUD_RUN_SERVICE=PRODUCTION_CLOUD_RUN_SERVICE, + GCLOUD_BIN=str(gcloud), + POLICYENGINE_LOCK_FILE=str(_lock(tmp_path, version)), + POLICYENGINE_LIVE_SINCE_NOW=NOW, + PREVIOUS_CLOUD_RUN_REVISION=previous, + ), + ) + + +def _outputs(result) -> dict[str, str]: + assert result.returncode == 0, result.stderr + return dict(line.split("=", 1) for line in result.stdout.splitlines()) + + +def test_same_version_keeps_the_serving_revisions_date(tmp_path): + result = _resolve( + tmp_path, + revision_env={ + "DEPLOYED_POLICYENGINE_VERSION": "6.11.1", + "POLICYENGINE_VERSION_LIVE_SINCE": EARLIER, + }, + ) + + assert _outputs(result) == { + "DEPLOYED_POLICYENGINE_VERSION": "6.11.1", + "POLICYENGINE_VERSION_LIVE_SINCE": EARLIER, + } + args = json.loads((tmp_path / "gcloud-calls.json").read_text()) + assert args[:4] == ["run", "revisions", "describe", "policyengine-api-00001-old"] + + +def test_new_version_goes_live_with_this_deploy(tmp_path): + result = _resolve( + tmp_path, + revision_env={ + "DEPLOYED_POLICYENGINE_VERSION": "6.10.0", + "POLICYENGINE_VERSION_LIVE_SINCE": EARLIER, + }, + ) + + assert _outputs(result)["POLICYENGINE_VERSION_LIVE_SINCE"] == NOW + + +def test_a_first_deploy_goes_live_with_this_deploy(tmp_path): + result = _resolve(tmp_path, previous="", revision_env={}) + + assert _outputs(result)["POLICYENGINE_VERSION_LIVE_SINCE"] == NOW + + +@pytest.mark.parametrize( + "revision_env", + [ + pytest.param({}, id="revision from before versions were recorded"), + pytest.param( + {"DEPLOYED_POLICYENGINE_VERSION": "6.11.1"}, + id="same version without a recorded date", + ), + pytest.param(None, id="describe fails"), + ], +) +def test_an_unknown_start_stays_unknown_rather_than_wrong(tmp_path, revision_env): + result = _resolve(tmp_path, revision_env=revision_env) + + assert _outputs(result) == { + "DEPLOYED_POLICYENGINE_VERSION": "6.11.1", + "POLICYENGINE_VERSION_LIVE_SINCE": "", + } + + +def test_a_lock_without_policyengine_fails(tmp_path): + lock = tmp_path / "uv.lock" + lock.write_text('[[package]]\nname = "numpy"\nversion = "2.1.0"\n') + + result = run_script( + SCRIPT, + script_env(POLICYENGINE_LOCK_FILE=str(lock)), + ) + + assert result.returncode != 0 + assert "policyengine is not locked" in result.stderr + + +def test_the_candidate_deploy_sets_both_variables(): + result = run_script( + ".github/scripts/deploy_cloud_run_candidate.sh", + script_env( + **required_runtime_env(), + CLOUD_RUN_IMAGE_URI="us-central1-docker.pkg.dev/project/repo/api:sha", + CLOUD_RUN_TAG="stage3-test", + POLICYENGINE_LIVE_SINCE_NOW=NOW, + ), + ) + + assert result.returncode == 0, result.stderr + assert "DEPLOYED_POLICYENGINE_VERSION=" in result.stdout + assert f"POLICYENGINE_VERSION_LIVE_SINCE={NOW}" in result.stdout + + +def test_the_deployment_route_reports_version_date_and_revision(monkeypatch): + monkeypatch.setenv( + "DEPLOYED_POLICYENGINE_VERSION", importlib_metadata.version("policyengine") + ) + monkeypatch.setenv("POLICYENGINE_VERSION_LIVE_SINCE", EARLIER) + monkeypatch.setenv("K_REVISION", "policyengine-api-00042-abc") + client = TestClient(create_asgi_app(create_test_wsgi_app())) + + body = client.get("/deployment").json() + + assert body["policyengine_version_live_since"] == EARLIER + assert body["revision"] == "policyengine-api-00042-abc" + assert body["policyengine_version"] + + +def test_the_deployment_route_has_no_date_before_the_deploy_records_one( + monkeypatch, +): + monkeypatch.delenv("POLICYENGINE_VERSION_LIVE_SINCE", raising=False) + client = TestClient(create_asgi_app(create_test_wsgi_app())) + + assert client.get("/deployment").json()["policyengine_version_live_since"] is None + + +def test_the_deployment_route_drops_a_date_for_another_version(monkeypatch): + monkeypatch.setenv("DEPLOYED_POLICYENGINE_VERSION", "0.0.0") + monkeypatch.setenv("POLICYENGINE_VERSION_LIVE_SINCE", EARLIER) + client = TestClient(create_asgi_app(create_test_wsgi_app())) + + assert client.get("/deployment").json()["policyengine_version_live_since"] is None + + +def test_the_deployment_route_logs_under_the_health_group(): + assert infer_route_group("/deployment") == "health" + + +@pytest.mark.parametrize( + ("job", "step"), + [ + ("deploy-cloud-run-staging", "Deploy tagged Cloud Run staging candidate"), + ("deploy-cloud-run-candidate", "Deploy tagged Cloud Run candidate"), + ], +) +def test_both_candidate_deploys_receive_the_serving_revision(job, step): + """Without it every deploy would look like a first deploy and get a new date.""" + block = workflow_job_block(push_workflow(), job) + step_block = block.split(f"- name: {step}\n", 1)[1].split("\n - name:", 1)[0] + + assert ( + "PREVIOUS_CLOUD_RUN_REVISION: ${{ steps.previous.outputs.revision }}" + in step_block + ) + assert "id: previous" in block + + +def _bundle_version(lock: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(REPO / ".github/scripts/locked_packages.py"), + "bundle-version", + str(lock), + ], + text=True, + capture_output=True, + check=False, + ) + + +def test_bundle_version_reads_the_one_locked_policyengine(tmp_path): + result = _bundle_version(_lock(tmp_path, "6.11.1")) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "6.11.1" + + +def test_bundle_version_refuses_more_than_one_locked_version(tmp_path): + lock = tmp_path / "uv.lock" + lock.write_text( + '[[package]]\nname = "policyengine"\nversion = "6.10.0"\n\n' + '[[package]]\nname = "policyengine"\nversion = "6.11.1"\n', + encoding="utf-8", + ) + + result = _bundle_version(lock) + + assert result.returncode != 0 + assert "expected exactly one version" in result.stderr