Skip to content

Commit 3fa4af6

Browse files
Pigbibicodex
andcommitted
ci: gate Firstrade rollouts on target admission
Co-Authored-By: Codex <noreply@openai.com>
1 parent f2b1db9 commit 3fa4af6

3 files changed

Lines changed: 185 additions & 12 deletions

File tree

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,28 @@ jobs:
215215
project_id: ${{ env.GCP_PROJECT_ID }}
216216
version: ">= 416.0.0"
217217

218+
- name: Set up Python for strategy requirement resolution
219+
if: steps.deploy_config.outputs.enabled == 'true'
220+
uses: actions/setup-python@v6
221+
with:
222+
python-version: "3.12"
223+
224+
- name: Install strategy status dependencies
225+
if: steps.deploy_config.outputs.enabled == 'true'
226+
run: |
227+
set -euo pipefail
228+
python -m pip install --upgrade pip uv
229+
uv sync --frozen --no-dev
230+
231+
- name: Verify deployed runtime target admission before traffic shift
232+
if: steps.deploy_config.outputs.enabled == 'true'
233+
run: |
234+
set -euo pipefail
235+
uv run --no-sync python scripts/verify_deployed_runtime_target_admission.py \
236+
--project="${GCP_PROJECT_ID}" \
237+
--region="${CLOUD_RUN_REGION}" \
238+
--service="${CLOUD_RUN_SERVICE}"
239+
218240
219241
- name: Build, push, and deploy Cloud Run image
220242
if: steps.deploy_config.outputs.enabled == 'true'
@@ -257,18 +279,6 @@ jobs:
257279
258280
echo "enabled=true" >> "$GITHUB_OUTPUT"
259281
260-
- name: Set up Python for strategy requirement resolution
261-
if: steps.env_sync_config.outputs.enabled == 'true'
262-
uses: actions/setup-python@v6
263-
with:
264-
python-version: "3.12"
265-
266-
- name: Install strategy status dependencies
267-
if: steps.env_sync_config.outputs.enabled == 'true'
268-
run: |
269-
set -euo pipefail
270-
python -m pip install --upgrade pip uv
271-
uv sync --frozen --no-dev
272282
- name: Resolve Cloud Run sync targets
273283
id: strategy_requirements
274284
if: steps.env_sync_config.outputs.enabled == 'true'
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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 checker
5+
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 FIRSTRADE_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 {str(item.get("name") or "").strip(): str(item.get("value") or "").strip() for item in entries if isinstance(item, Mapping) and str(item.get("name") or "").strip() and "value" in item}
48+
49+
50+
def _parse_bool(value: object, *, field: str, service: str) -> bool:
51+
if isinstance(value, bool):
52+
return value
53+
normalized = str(value).strip().lower()
54+
if normalized in {"1", "true", "yes", "on"}:
55+
return True
56+
if normalized in {"0", "false", "no", "off"}:
57+
return False
58+
raise AdmissionError(f"{service}: {field} must be a boolean")
59+
60+
61+
def verify_service(*, service: str, service_json: Mapping[str, Any]) -> dict[str, object]:
62+
"""Validate one deployed service without printing account or secret data."""
63+
64+
env = _container_env(service_json)
65+
raw_target = env.get("RUNTIME_TARGET_JSON") or env.get("QSL_RUNTIME_TARGET_JSON")
66+
if not raw_target:
67+
raise AdmissionError(f"{service}: RUNTIME_TARGET_JSON is required for image admission")
68+
try:
69+
target = json.loads(raw_target)
70+
except json.JSONDecodeError as exc:
71+
raise AdmissionError(f"{service}: RUNTIME_TARGET_JSON is invalid JSON") from exc
72+
if not isinstance(target, Mapping):
73+
raise AdmissionError(f"{service}: RUNTIME_TARGET_JSON must be an object")
74+
if (target_service := str(target.get("service_name") or "").strip()) and target_service != service:
75+
raise AdmissionError(f"{service}: runtime target service_name does not match the deployed service")
76+
raw_profile = str(target.get("strategy_profile") or "").strip()
77+
if not raw_profile:
78+
raise AdmissionError(f"{service}: runtime target strategy_profile is required")
79+
try:
80+
definition = resolve_strategy_definition(raw_profile, platform_id=FIRSTRADE_PLATFORM)
81+
except (TypeError, ValueError) as exc:
82+
raise AdmissionError(f"{service}: strategy profile is not admitted") from exc
83+
canonical_profile = definition.profile
84+
if str(env.get("STRATEGY_PROFILE") or "").strip() != canonical_profile:
85+
raise AdmissionError(f"{service}: STRATEGY_PROFILE does not match the admitted runtime target profile")
86+
execution_mode = str(target.get("execution_mode") or "").strip().lower()
87+
if execution_mode not in {"paper", "live"}:
88+
raise AdmissionError(f"{service}: execution_mode must be paper or live")
89+
if "dry_run_only" not in target:
90+
raise AdmissionError(f"{service}: runtime target dry_run_only is required")
91+
target_dry_run = _parse_bool(target["dry_run_only"], field="runtime target dry_run_only", service=service)
92+
configured_dry_run = env.get("FIRSTRADE_DRY_RUN_ONLY")
93+
if configured_dry_run is not None and _parse_bool(configured_dry_run, field="FIRSTRADE_DRY_RUN_ONLY", service=service) != target_dry_run:
94+
raise AdmissionError(f"{service}: FIRSTRADE_DRY_RUN_ONLY does not match runtime target dry_run_only")
95+
if target_dry_run and execution_mode != "paper":
96+
raise AdmissionError(f"{service}: a dry-run/shadow target must declare execution_mode=paper")
97+
return {"service": service, "profile": canonical_profile, "execution_mode": execution_mode, "dry_run_only": target_dry_run, "enabled": _parse_bool(env.get("RUNTIME_TARGET_ENABLED", "true"), field="RUNTIME_TARGET_ENABLED", service=service)}
98+
99+
100+
def main() -> int:
101+
parser = argparse.ArgumentParser()
102+
parser.add_argument("--project", required=True)
103+
parser.add_argument("--region", required=True)
104+
parser.add_argument("--service", required=True)
105+
args = parser.parse_args()
106+
try:
107+
result = verify_service(service=args.service, service_json=_describe_service(service=args.service, project=args.project, region=args.region))
108+
except AdmissionError as exc:
109+
print(f"Deployed runtime target admission failed: {exc}", file=sys.stderr)
110+
return 1
111+
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']}")
112+
return 0
113+
114+
115+
if __name__ == "__main__":
116+
raise SystemExit(main())
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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": "FIRSTRADE_DRY_RUN_ONLY", "value": dry_run},
20+
{"name": "RUNTIME_TARGET_ENABLED", "value": "true"},
21+
]}]}}}}
22+
23+
24+
def target(profile="ibit_smart_dca", dry_run=True):
25+
return {"platform_id": "firstrade", "service_name": "live-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+
assert admission.verify_service(service="live-service", service_json=payload(target(), "ibit_smart_dca"))["profile"] == "ibit_smart_dca"
30+
31+
32+
def test_paper_broker_submission_target_passes():
33+
configured = target(dry_run=False) | {"execution_mode": "paper"}
34+
assert admission.verify_service(service="live-service", service_json=payload(configured, "ibit_smart_dca", "false"))["dry_run_only"] is False
35+
36+
37+
@pytest.mark.parametrize(
38+
("configured", "profile", "message"),
39+
[
40+
(target(), "different_profile", "STRATEGY_PROFILE does not match"),
41+
(target() | {"execution_mode": "live"}, "ibit_smart_dca", "dry-run/shadow target"),
42+
(target("retired_profile"), "retired_profile", "not admitted"),
43+
],
44+
)
45+
def test_target_drift_fails_closed(configured, profile, message):
46+
with pytest.raises(admission.AdmissionError, match=message):
47+
admission.verify_service(service="live-service", service_json=payload(configured, profile))

0 commit comments

Comments
 (0)