Skip to content

Commit 4a62334

Browse files
anth-volkclaude
andcommitted
Isolate staging Cloud Run service and add backend response header
Migration PR 4 stage 1. The staging Cloud Run track previously promoted staging-configured revisions to 100% of the production policyengine-api service URL on every push; once a load balancer fronts that service this becomes a public incident per deploy. Staging now deploys and promotes its own policyengine-api-staging service, with the image name decoupled from the service name because production reuses the staging-built image. Every API response now carries X-PolicyEngine-Backend (app_engine or cloud_run) so traffic-weight changes and rollbacks during the host cutover can be verified in-band with sampled requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3dccb8a commit 4a62334

8 files changed

Lines changed: 145 additions & 1 deletion

File tree

.github/scripts/cloud_run_env.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ cloud_run_set_defaults() {
55
CLOUD_RUN_REGION="${CLOUD_RUN_REGION:-us-central1}"
66
CLOUD_RUN_SERVICE="${CLOUD_RUN_SERVICE:-policyengine-api}"
77
CLOUD_RUN_ARTIFACT_REPOSITORY="${CLOUD_RUN_ARTIFACT_REPOSITORY:-policyengine-api}"
8+
# Image name stays fixed across services: the production deploy reuses the
9+
# image built by the staging track, so it must not embed the service name.
10+
CLOUD_RUN_IMAGE_NAME="${CLOUD_RUN_IMAGE_NAME:-policyengine-api}"
811
CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT="${CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT:-policyengine-api-cr-runtime@policyengine-api.iam.gserviceaccount.com}"
912
CLOUD_RUN_CLOUD_SQL_INSTANCE="${CLOUD_RUN_CLOUD_SQL_INSTANCE:-policyengine-api:us-central1:policyengine-api-data}"
1013
CLOUD_RUN_CPU="${CLOUD_RUN_CPU:-4}"
@@ -22,7 +25,7 @@ cloud_run_set_defaults() {
2225
local sha
2326
sha="${GITHUB_SHA:-local}"
2427
CLOUD_RUN_IMAGE_TAG="${CLOUD_RUN_IMAGE_TAG:-${sha}}"
25-
CLOUD_RUN_IMAGE_URI="${CLOUD_RUN_IMAGE_URI:-${CLOUD_RUN_REGION}-docker.pkg.dev/${CLOUD_RUN_PROJECT}/${CLOUD_RUN_ARTIFACT_REPOSITORY}/${CLOUD_RUN_SERVICE}:${CLOUD_RUN_IMAGE_TAG}}"
28+
CLOUD_RUN_IMAGE_URI="${CLOUD_RUN_IMAGE_URI:-${CLOUD_RUN_REGION}-docker.pkg.dev/${CLOUD_RUN_PROJECT}/${CLOUD_RUN_ARTIFACT_REPOSITORY}/${CLOUD_RUN_IMAGE_NAME}:${CLOUD_RUN_IMAGE_TAG}}"
2629

2730
local short_sha
2831
short_sha="${sha:0:7}"
@@ -32,6 +35,7 @@ cloud_run_set_defaults() {
3235
export CLOUD_RUN_REGION
3336
export CLOUD_RUN_SERVICE
3437
export CLOUD_RUN_ARTIFACT_REPOSITORY
38+
export CLOUD_RUN_IMAGE_NAME
3539
export CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT
3640
export CLOUD_RUN_CLOUD_SQL_INSTANCE
3741
export CLOUD_RUN_CPU

.github/workflows/push.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ jobs:
211211
(github.repository == 'PolicyEngine/policyengine-api')
212212
&& (github.event.head_commit.message == 'Update PolicyEngine API')
213213
environment: staging
214+
env:
215+
CLOUD_RUN_SERVICE: policyengine-api-staging
214216
permissions:
215217
contents: read
216218
id-token: write
@@ -316,6 +318,8 @@ jobs:
316318
(github.repository == 'PolicyEngine/policyengine-api')
317319
&& (github.event.head_commit.message == 'Update PolicyEngine API')
318320
environment: staging
321+
env:
322+
CLOUD_RUN_SERVICE: policyengine-api-staging
319323
permissions:
320324
contents: read
321325
id-token: write
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Isolated staging Cloud Run deploys onto a dedicated policyengine-api-staging service and tagged every API response with an X-PolicyEngine-Backend header.

policyengine_api/asgi_factory.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
from a2wsgi import WSGIMiddleware
1010
from fastapi import FastAPI, HTTPException
1111
from policyengine_api.constants import VERSION
12+
from policyengine_api.migration_flags import (
13+
BACKEND_RESPONSE_HEADER,
14+
get_api_host_backend,
15+
)
1216
from policyengine_api.migration_logging import log_migration_request
1317
from pydantic import BaseModel
1418
from starlette.middleware.gzip import GZipMiddleware
@@ -56,6 +60,8 @@ async def add_cors_for_native_routes(request, call_next):
5660
started_at = time.time()
5761
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
5862
response = await call_next(request)
63+
if BACKEND_RESPONSE_HEADER not in response.headers:
64+
response.headers[BACKEND_RESPONSE_HEADER] = get_api_host_backend()
5965
origin = request.headers.get("origin")
6066
if origin and "access-control-allow-origin" not in response.headers:
6167
response.headers["Access-Control-Allow-Origin"] = origin

policyengine_api/migration_flags.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
DEFAULT_SIM_FRONT_DOOR = "old_gateway_direct"
3131
DEFAULT_SIM_COMPUTE_BACKEND = "old_gateway"
3232

33+
BACKEND_RESPONSE_HEADER = "X-PolicyEngine-Backend"
34+
3335

3436
@dataclass(frozen=True)
3537
class MigrationContext:
@@ -112,6 +114,18 @@ def get_db_read(entity: str) -> str:
112114
return _read_choice(env_name, DEFAULT_DB_SOURCE, DB_READ_SOURCES)
113115

114116

117+
def get_api_host_backend() -> str:
118+
"""Backend value for response tagging; must never break a response."""
119+
try:
120+
return _read_choice(
121+
"API_HOST_BACKEND",
122+
DEFAULT_API_HOST_BACKEND,
123+
API_HOST_BACKENDS,
124+
)
125+
except ValueError:
126+
return DEFAULT_API_HOST_BACKEND
127+
128+
115129
def get_sim_compute(flow: str) -> str:
116130
env_name = f"SIM_COMPUTE_{flow.upper()}"
117131
return _read_choice(

policyengine_api/migration_logging.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from policyengine_api.gcp_logging import logger
1111
from policyengine_api.migration_flags import (
12+
BACKEND_RESPONSE_HEADER,
13+
get_api_host_backend,
1214
get_migration_log_context,
1315
infer_route_group,
1416
)
@@ -26,6 +28,10 @@ def set_request_migration_context():
2628

2729
@app.after_request
2830
def log_request_migration_context(response):
31+
try:
32+
response.headers[BACKEND_RESPONSE_HEADER] = get_api_host_backend()
33+
except Exception:
34+
pass
2935
try:
3036
log_migration_request(
3137
request_id=getattr(flask.g, "request_id", None),

tests/unit/routes/test_migration_context_logging.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from flask import Flask, Response
55

66
from policyengine_api.asgi_factory import create_asgi_app
7+
from policyengine_api.migration_flags import BACKEND_RESPONSE_HEADER
78
from policyengine_api.migration_logging import register_migration_request_logging
89

910

@@ -108,3 +109,49 @@ def test_asgi_shell_does_not_log_unregistered_flask_fallback_routes():
108109
assert response.status_code == 200
109110
assert response.content == b"fallback"
110111
mock_logger.log_struct.assert_not_called()
112+
113+
114+
def test_flask_responses_include_backend_header_default():
115+
with patch("policyengine_api.migration_logging.logger"):
116+
response = _app().test_client().get("/readiness-check")
117+
118+
assert response.status_code == 200
119+
assert response.headers[BACKEND_RESPONSE_HEADER] == "app_engine"
120+
121+
122+
def test_flask_responses_include_backend_header_for_cloud_run(monkeypatch):
123+
monkeypatch.setenv("API_HOST_BACKEND", "cloud_run")
124+
125+
with patch("policyengine_api.migration_logging.logger"):
126+
response = _app().test_client().get("/readiness-check")
127+
128+
assert response.headers[BACKEND_RESPONSE_HEADER] == "cloud_run"
129+
130+
131+
def test_backend_header_falls_back_to_default_on_invalid_flag(monkeypatch):
132+
monkeypatch.setenv("API_HOST_BACKEND", "not-a-backend")
133+
134+
with patch("policyengine_api.migration_logging.logger"):
135+
response = _app().test_client().get("/readiness-check")
136+
137+
assert response.status_code == 200
138+
assert response.headers[BACKEND_RESPONSE_HEADER] == "app_engine"
139+
140+
141+
def test_fastapi_native_routes_include_backend_header(monkeypatch):
142+
monkeypatch.setenv("API_HOST_BACKEND", "cloud_run")
143+
144+
with patch("policyengine_api.migration_logging.logger"):
145+
response = TestClient(create_asgi_app(_app())).get("/health")
146+
147+
assert response.status_code == 200
148+
assert response.headers[BACKEND_RESPONSE_HEADER] == "cloud_run"
149+
150+
151+
def test_asgi_shell_adds_backend_header_when_flask_hook_is_absent():
152+
response = TestClient(create_asgi_app(_app_without_migration_logging())).get(
153+
"/fallback"
154+
)
155+
156+
assert response.status_code == 200
157+
assert response.headers[BACKEND_RESPONSE_HEADER] == "app_engine"

tests/unit/test_cloud_run_deploy_scripts.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
REPO = Path(__file__).resolve().parents[2]
1111
PRODUCTION_CLOUD_SQL_INSTANCE = "policyengine-api:us-central1:policyengine-api-data"
12+
STAGING_CLOUD_RUN_SERVICE = "policyengine-api-staging"
1213
DEDICATED_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT = (
1314
"policyengine-api-cr-runtime@policyengine-api.iam.gserviceaccount.com"
1415
)
@@ -384,6 +385,67 @@ def test_promote_cloud_run_tag_dry_run_shifts_service_traffic_to_tag():
384385
assert "--to-latest" not in result.stdout
385386

386387

388+
def test_push_workflow_isolates_staging_cloud_run_service():
389+
workflow = _push_workflow()
390+
staging_deploy = _workflow_job_block(workflow, "deploy-cloud-run-staging")
391+
staging_promote = _workflow_job_block(workflow, "promote-cloud-run-staging")
392+
production_deploy = _workflow_job_block(workflow, "deploy-cloud-run-candidate")
393+
394+
staging_service_env = f"CLOUD_RUN_SERVICE: {STAGING_CLOUD_RUN_SERVICE}"
395+
assert staging_service_env in staging_deploy
396+
assert staging_service_env in staging_promote
397+
assert STAGING_CLOUD_RUN_SERVICE not in production_deploy
398+
399+
400+
def test_build_cloud_run_image_uri_is_independent_of_service_override():
401+
result = _run_script(
402+
".github/scripts/build_cloud_run_image.sh",
403+
_script_env(
404+
GITHUB_SHA="1234567890abcdef",
405+
GITHUB_RUN_NUMBER="42",
406+
CLOUD_RUN_SERVICE=STAGING_CLOUD_RUN_SERVICE,
407+
),
408+
)
409+
410+
assert result.returncode == 0, result.stderr
411+
assert (
412+
"us-central1-docker.pkg.dev/policyengine-api/policyengine-api/"
413+
"policyengine-api:1234567890abcdef"
414+
) in result.stdout
415+
assert f"{STAGING_CLOUD_RUN_SERVICE}:" not in result.stdout
416+
417+
418+
def test_deploy_cloud_run_candidate_dry_run_targets_service_override():
419+
result = _run_script(
420+
".github/scripts/deploy_cloud_run_candidate.sh",
421+
_script_env(
422+
**_required_runtime_env(),
423+
CLOUD_RUN_IMAGE_URI="us-central1-docker.pkg.dev/project/repo/api:sha",
424+
CLOUD_RUN_TAG="stage3-test",
425+
CLOUD_RUN_SERVICE=STAGING_CLOUD_RUN_SERVICE,
426+
),
427+
)
428+
429+
assert result.returncode == 0, result.stderr
430+
assert f"gcloud run deploy {STAGING_CLOUD_RUN_SERVICE}" in result.stdout
431+
432+
433+
def test_promote_cloud_run_tag_dry_run_targets_service_override():
434+
result = _run_script(
435+
".github/scripts/promote_cloud_run_tag.sh",
436+
_script_env(
437+
CLOUD_RUN_TAG="stage3-test",
438+
CLOUD_RUN_SERVICE=STAGING_CLOUD_RUN_SERVICE,
439+
),
440+
)
441+
442+
assert result.returncode == 0, result.stderr
443+
assert (
444+
f"gcloud run services update-traffic {STAGING_CLOUD_RUN_SERVICE}"
445+
in result.stdout
446+
)
447+
448+
387449
def test_push_workflow_tests_app_engine_and_cloud_run_staging_tracks():
388450
workflow = _push_workflow()
389451
app_engine_tests = _workflow_job_block(workflow, "integration-tests-staging")

0 commit comments

Comments
 (0)