Skip to content

Commit ec36123

Browse files
authored
Merge pull request #327 from QuantStrategyLab/ci/deployed-target-admission
ci: gate Schwab rollouts on target admission
2 parents de37b68 + 3a2e8b7 commit ec36123

3 files changed

Lines changed: 184 additions & 2 deletions

File tree

.github/workflows/sync-cloud-run-env.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,13 @@ jobs:
168168
ref: ${{ github.sha }}
169169

170170
- name: Set up Python for strategy requirement resolution
171-
if: steps.config.outputs.env_sync_enabled == 'true'
171+
if: steps.config.outputs.enabled == 'true'
172172
uses: actions/setup-python@v6
173173
with:
174174
python-version: "3.12"
175175

176176
- name: Install strategy status dependencies
177-
if: steps.config.outputs.env_sync_enabled == 'true'
177+
if: steps.config.outputs.enabled == 'true'
178178
run: |
179179
set -euo pipefail
180180
python -m pip install --upgrade pip uv
@@ -275,6 +275,15 @@ jobs:
275275
project_id: ${{ env.GCP_PROJECT_ID }}
276276
version: ">= 416.0.0"
277277

278+
- name: Verify deployed runtime target admission before traffic shift
279+
if: steps.config.outputs.deploy_enabled == 'true'
280+
run: |
281+
set -euo pipefail
282+
uv run --no-sync python scripts/verify_deployed_runtime_target_admission.py \
283+
--project="${GCP_PROJECT_ID}" \
284+
--region="${CLOUD_RUN_REGION}" \
285+
--service="${CLOUD_RUN_SERVICE}"
286+
278287
279288
- name: Build, push, and deploy Cloud Run image
280289
if: steps.config.outputs.deploy_enabled == 'true'
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env python3
2+
"""Fail closed before a Cloud Run rollout reaches an unadmitted target.
3+
4+
Only non-sensitive target identity fields are read from Cloud Run. This
5+
checker never reads Secret Manager values and never mutates a service.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import json
12+
import subprocess
13+
import sys
14+
from collections.abc import Mapping, Sequence
15+
from typing import Any
16+
17+
from strategy_registry import SCHWAB_PLATFORM, resolve_strategy_definition
18+
19+
20+
class AdmissionError(ValueError):
21+
"""A deployed runtime target is not safe to receive a new image."""
22+
23+
24+
def _run(command: Sequence[str]) -> str:
25+
result = subprocess.run(command, text=True, capture_output=True, check=False)
26+
if result.returncode:
27+
detail = (result.stderr or result.stdout).strip()
28+
raise AdmissionError(detail or f"Command failed: {' '.join(command)}")
29+
return result.stdout
30+
31+
32+
def _describe_service(*, service: str, project: str, region: str) -> Mapping[str, Any]:
33+
payload = _run(["gcloud", "run", "services", "describe", service, f"--project={project}", f"--region={region}", "--format=json"])
34+
loaded = json.loads(payload)
35+
if not isinstance(loaded, Mapping):
36+
raise AdmissionError(f"{service}: Cloud Run describe returned a non-object payload")
37+
return loaded
38+
39+
40+
def _container_env(service_json: Mapping[str, Any]) -> dict[str, str]:
41+
containers = service_json.get("spec", {}).get("template", {}).get("spec", {}).get("containers", [])
42+
if not isinstance(containers, list) or not containers:
43+
raise AdmissionError("Cloud Run service has no container configuration")
44+
entries = containers[0].get("env", [])
45+
if not isinstance(entries, list):
46+
raise AdmissionError("Cloud Run container environment is malformed")
47+
return {
48+
str(entry.get("name") or "").strip(): str(entry.get("value") or "").strip()
49+
for entry in entries
50+
if isinstance(entry, Mapping) and str(entry.get("name") or "").strip() and "value" in entry
51+
}
52+
53+
54+
def _parse_bool(value: object, *, field: str, service: str) -> bool:
55+
if isinstance(value, bool):
56+
return value
57+
normalized = str(value).strip().lower()
58+
if normalized in {"1", "true", "yes", "on"}:
59+
return True
60+
if normalized in {"0", "false", "no", "off"}:
61+
return False
62+
raise AdmissionError(f"{service}: {field} must be a boolean")
63+
64+
65+
def verify_service(*, service: str, service_json: Mapping[str, Any]) -> dict[str, object]:
66+
"""Validate one deployed service without printing account or secret data."""
67+
68+
env = _container_env(service_json)
69+
raw_target = env.get("RUNTIME_TARGET_JSON") or env.get("QSL_RUNTIME_TARGET_JSON")
70+
if not raw_target:
71+
raise AdmissionError(f"{service}: RUNTIME_TARGET_JSON is required for image admission")
72+
try:
73+
target = json.loads(raw_target)
74+
except json.JSONDecodeError as exc:
75+
raise AdmissionError(f"{service}: RUNTIME_TARGET_JSON is invalid JSON") from exc
76+
if not isinstance(target, Mapping):
77+
raise AdmissionError(f"{service}: RUNTIME_TARGET_JSON must be an object")
78+
target_service = str(target.get("service_name") or "").strip()
79+
if target_service and target_service != service:
80+
raise AdmissionError(f"{service}: runtime target service_name does not match the deployed service")
81+
82+
raw_profile = str(target.get("strategy_profile") or "").strip()
83+
if not raw_profile:
84+
raise AdmissionError(f"{service}: runtime target strategy_profile is required")
85+
try:
86+
definition = resolve_strategy_definition(raw_profile, platform_id=SCHWAB_PLATFORM)
87+
except (TypeError, ValueError) as exc:
88+
raise AdmissionError(f"{service}: strategy profile is not admitted") from exc
89+
canonical_profile = definition.profile
90+
if str(env.get("STRATEGY_PROFILE") or "").strip() != canonical_profile:
91+
raise AdmissionError(f"{service}: STRATEGY_PROFILE does not match the admitted runtime target profile")
92+
93+
execution_mode = str(target.get("execution_mode") or "").strip().lower()
94+
if execution_mode not in {"paper", "live"}:
95+
raise AdmissionError(f"{service}: execution_mode must be paper or live")
96+
if "dry_run_only" not in target:
97+
raise AdmissionError(f"{service}: runtime target dry_run_only is required")
98+
target_dry_run = _parse_bool(target["dry_run_only"], field="runtime target dry_run_only", service=service)
99+
configured_dry_run = env.get("SCHWAB_DRY_RUN_ONLY")
100+
if configured_dry_run is not None and _parse_bool(configured_dry_run, field="SCHWAB_DRY_RUN_ONLY", service=service) != target_dry_run:
101+
raise AdmissionError(f"{service}: SCHWAB_DRY_RUN_ONLY does not match runtime target dry_run_only")
102+
if target_dry_run and execution_mode != "paper":
103+
raise AdmissionError(f"{service}: a dry-run/shadow target must declare execution_mode=paper")
104+
105+
enabled = _parse_bool(env.get("RUNTIME_TARGET_ENABLED", "true"), field="RUNTIME_TARGET_ENABLED", service=service)
106+
return {"service": service, "profile": canonical_profile, "execution_mode": execution_mode, "dry_run_only": target_dry_run, "enabled": enabled}
107+
108+
109+
def main() -> int:
110+
parser = argparse.ArgumentParser()
111+
parser.add_argument("--project", required=True)
112+
parser.add_argument("--region", required=True)
113+
parser.add_argument("--service", required=True)
114+
args = parser.parse_args()
115+
try:
116+
result = verify_service(service=args.service, service_json=_describe_service(service=args.service, project=args.project, region=args.region))
117+
except AdmissionError as exc:
118+
print(f"Deployed runtime target admission failed: {exc}", file=sys.stderr)
119+
return 1
120+
print("Verified deployed runtime target admission: " f"service={result['service']}, profile={result['profile']}, " f"mode={result['execution_mode']}, dry_run_only={result['dry_run_only']}, enabled={result['enabled']}")
121+
return 0
122+
123+
124+
if __name__ == "__main__":
125+
raise SystemExit(main())
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import importlib.util
2+
import json
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
8+
path = Path(__file__).resolve().parents[1] / "scripts" / "verify_deployed_runtime_target_admission.py"
9+
spec = importlib.util.spec_from_file_location("deployed_target_admission", path)
10+
assert spec is not None and spec.loader is not None
11+
admission = importlib.util.module_from_spec(spec)
12+
spec.loader.exec_module(admission)
13+
14+
15+
def payload(target, profile, dry_run="true"):
16+
return {"spec": {"template": {"spec": {"containers": [{"env": [
17+
{"name": "RUNTIME_TARGET_JSON", "value": json.dumps(target)},
18+
{"name": "STRATEGY_PROFILE", "value": profile},
19+
{"name": "SCHWAB_DRY_RUN_ONLY", "value": dry_run},
20+
{"name": "RUNTIME_TARGET_ENABLED", "value": "true"},
21+
]}]}}}}
22+
23+
24+
def target(profile="soxl_soxx_trend_income", dry_run=True):
25+
return {"platform_id": "schwab", "service_name": "paper-service", "strategy_profile": profile, "execution_mode": "paper" if dry_run else "live", "dry_run_only": dry_run}
26+
27+
28+
def test_admitted_shadow_target_passes():
29+
result = admission.verify_service(service="paper-service", service_json=payload(target(), "soxl_soxx_trend_income"))
30+
assert result["profile"] == "soxl_soxx_trend_income"
31+
32+
33+
def test_paper_broker_submission_target_passes():
34+
configured = target(dry_run=False) | {"execution_mode": "paper"}
35+
assert admission.verify_service(service="paper-service", service_json=payload(configured, "soxl_soxx_trend_income", "false"))["dry_run_only"] is False
36+
37+
38+
@pytest.mark.parametrize(
39+
("configured", "profile", "message"),
40+
[
41+
(target(), "different_profile", "STRATEGY_PROFILE does not match"),
42+
(target() | {"execution_mode": "live"}, "soxl_soxx_trend_income", "dry-run/shadow target"),
43+
(target("retired_profile"), "retired_profile", "not admitted"),
44+
],
45+
)
46+
def test_target_drift_fails_closed(configured, profile, message):
47+
with pytest.raises(admission.AdmissionError, match=message):
48+
admission.verify_service(service="paper-service", service_json=payload(configured, profile))

0 commit comments

Comments
 (0)