diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index 8e0cd56..4538e2c 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -3,6 +3,11 @@ name: Execution Report Heartbeat on: workflow_dispatch: inputs: + snapshot_only: + description: "Only collect and sync the existing read-only account snapshot; skip heartbeat, notification and runtime projections." + required: false + type: boolean + default: false target: description: "Target account to check; scheduled runs always use all targets." required: false @@ -169,7 +174,7 @@ jobs: set -euo pipefail uv sync --frozen --no-dev - - name: Validate independent HK audience before authentication + - name: Validate independent account audience before authentication id: independent_audience if: ${{ env.ACCOUNT_HISTORY_OBSERVATION_MODE == 'independent_get' && vars.ACCOUNT_HISTORY_RECORDING_ENABLED == 'true' }} continue-on-error: true @@ -182,7 +187,7 @@ jobs: GOOGLE_CLOUD_PROJECT: ${{ env.GCP_PROJECT_ID }} SNAPSHOT_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} SNAPSHOT_REGION: ${{ vars.CLOUD_RUN_REGION }} - run: uv run --no-sync python scripts/record_daily_account_snapshot.py --validate-independent-hk-audience >> "$GITHUB_OUTPUT" + run: uv run --no-sync python scripts/record_daily_account_snapshot.py --validate-independent-audience >> "$GITHUB_OUTPUT" - name: Authenticate to Google Cloud id: gcp_auth_primary @@ -249,12 +254,12 @@ jobs: fi case "$ACCOUNT_HISTORY_OBSERVATION_MODE" in independent_get) - uv run --no-sync python scripts/record_daily_account_snapshot.py --validate-independent-hk-account-snapshot + uv run --no-sync python scripts/record_daily_account_snapshot.py --validate-independent-account-snapshot # Private metadata stays in the pipe. No file, log, or artifact. gcloud run services describe "${SNAPSHOT_SERVICE}" \ --project "${GOOGLE_CLOUD_PROJECT}" --region "${SNAPSHOT_REGION}" \ --format=json 2>/dev/null \ - | uv run --no-sync python scripts/record_daily_account_snapshot.py --independent-hk-account-snapshot + | uv run --no-sync python scripts/record_daily_account_snapshot.py --independent-account-snapshot ;; *) # The script rejects unknown modes before any Scheduler/GCS call. @@ -263,11 +268,11 @@ jobs: esac - name: Check recent execution report - if: ${{ env.INSPECT_SG_PROBE_PERMISSIONS != 'true' }} + if: ${{ !inputs.snapshot_only && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' }} run: uv run --no-sync python scripts/execution_report_heartbeat.py - name: Publish read-only runtime execution evidence - if: ${{ matrix.target.label == 'PAPER' && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' }} + if: ${{ !inputs.snapshot_only && matrix.target.label == 'PAPER' && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' }} uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@2eef9296e1ca5c214213ec71a74059cf1d5014d9 with: source-id: runtime-reports-longbridge @@ -278,7 +283,7 @@ jobs: EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }} - name: Publish daily runtime projection - if: ${{ !cancelled() && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' && matrix.target.label == 'PAPER' && vars.RUNTIME_DAILY_PROJECTION_ENABLED == 'true' && vars.RUNTIME_DAILY_SYNC_ENABLED == 'true' && (steps.gcp_auth_primary.outcome == 'success' || steps.gcp_auth_retry.outcome == 'success') }} + if: ${{ !inputs.snapshot_only && !cancelled() && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' && matrix.target.label == 'PAPER' && vars.RUNTIME_DAILY_PROJECTION_ENABLED == 'true' && vars.RUNTIME_DAILY_SYNC_ENABLED == 'true' && (steps.gcp_auth_primary.outcome == 'success' || steps.gcp_auth_retry.outcome == 'success') }} env: RUNTIME_DAILY_PROJECTION_ENABLED: ${{ vars.RUNTIME_DAILY_PROJECTION_ENABLED }} RUNTIME_DAILY_PROJECTION_GCS_PREFIX: ${{ secrets.RUNTIME_DAILY_PROJECTION_GCS_PREFIX }} @@ -296,7 +301,7 @@ jobs: - name: Publish existing reports as unified digest candidates - if: ${{ (success() && vars.QSL_ROUTINE_NOTIFICATION_MODE == 'daily_digest' && vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED != 'true') || (!cancelled() && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' && vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED == 'true') }} + if: ${{ !inputs.snapshot_only && ((success() && vars.QSL_ROUTINE_NOTIFICATION_MODE == 'daily_digest' && vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED != 'true') || (!cancelled() && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' && vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED == 'true')) }} env: QSL_ROUTINE_NOTIFICATION_MODE: ${{ vars.QSL_ROUTINE_NOTIFICATION_MODE || 'immediate' }} RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} diff --git a/scripts/record_daily_account_snapshot.py b/scripts/record_daily_account_snapshot.py index 1a841e9..6ffb283 100644 --- a/scripts/record_daily_account_snapshot.py +++ b/scripts/record_daily_account_snapshot.py @@ -7,6 +7,7 @@ import inspect import os import re +import subprocess import sys import time from collections.abc import Callable, Mapping @@ -297,19 +298,22 @@ class _IndependentConfig: service: str service_url: str source_binding_id: str + target_id: str + account_scope: str -def _independent_hk_config(env: Mapping[str, str], *, require_id_token: bool = True) -> _IndependentConfig: - """This first opt-in is manual HK observation with the existing identity only.""" +def _independent_config(env: Mapping[str, str], *, require_id_token: bool = True) -> _IndependentConfig: + """Read one configured target without depending on its trading activation.""" + target_id = env.get("ACCOUNT_HISTORY_TARGET_ID") + expected = _EXPECTED_TARGETS.get(target_id) if ( env.get("ACCOUNT_HISTORY_OBSERVATION_MODE") != "independent_get" or env.get("ACCOUNT_HISTORY_RECORDING_ENABLED") != "true" - or env.get("ACCOUNT_HISTORY_TARGET_ID") != "hk" - or env.get("ACCOUNT_HISTORY_EXPECTED_SCOPE") != "HK" + or expected is None + or env.get("ACCOUNT_HISTORY_EXPECTED_SCOPE") != expected[0] or env.get("GOOGLE_CLOUD_PROJECT") != "longbridgequant" - or env.get("RUNTIME_TARGET_ENABLED") != "false" - or env.get("GITHUB_EVENT_NAME") != "workflow_dispatch" - or env.get("ACCOUNT_HISTORY_REQUESTED_TARGET") != "hk" + or env.get("GITHUB_EVENT_NAME") not in {"workflow_dispatch", "schedule"} + or env.get("ACCOUNT_HISTORY_REQUESTED_TARGET") not in {target_id, "all"} or env.get("GITHUB_REF") != "refs/heads/main" or env.get("GITHUB_WORKFLOW_REF") != ( "QuantStrategyLab/LongBridgePlatform/.github/workflows/" @@ -325,8 +329,8 @@ def _independent_hk_config(env: Mapping[str, str], *, require_id_token: bool = T raise _Rejected("independent_config_invalid") from application.runtime_target_manifest import load_runtime_target_manifest - targets = [target for target in load_runtime_target_manifest().targets if target.id == "hk"] - if len(targets) != 1 or targets[0].mode != "live" or targets[0].account_scope != "HK": + targets = [target for target in load_runtime_target_manifest().targets if target.id == target_id] + if len(targets) != 1 or targets[0].mode != expected[1] or targets[0].account_scope != expected[0]: raise _Rejected("independent_config_invalid") target = targets[0] if env.get("SNAPSHOT_SERVICE") != target.service or env.get("SNAPSHOT_REGION") != target.region: @@ -338,7 +342,7 @@ def _independent_hk_config(env: Mapping[str, str], *, require_id_token: bool = T raise _Rejected("independent_config_invalid") return _IndependentConfig( "longbridgequant", target.region, target.service, - service_url, binding, + service_url, binding, target_id, expected[0], ) @@ -349,13 +353,11 @@ def _validate_independent_service(value: object, config: _IndependentConfig) -> raise TypeError metadata, status = value["metadata"], value["status"] annotations = metadata["annotations"] - template = value["spec"]["template"] - containers = template["spec"]["containers"] - generation = metadata["generation"] - observed_generation = status["observedGeneration"] - ready = [row for row in status["conditions"] if row.get("type") == "Ready"] - traffic = status["traffic"] - revision = status["latestReadyRevisionName"] + revision = value["serving_revision"] + containers = revision["spec"]["containers"] + ready = [row for row in revision["status"]["conditions"] if row.get("type") == "Ready"] + traffic = [row for row in status["traffic"] if (row.get("percent") or 0) > 0] + revision_name = revision["metadata"]["name"] if ( metadata["name"] != config.service or str(metadata["namespace"]) not in {config.project_id, "252919773759"} @@ -364,14 +366,11 @@ def _validate_independent_service(value: object, config: _IndependentConfig) -> or annotations.get("run.googleapis.com/ingress-status", "all") != "all" or status["url"] != config.service_url or len(ready) != 1 or ready[0].get("status") != "True" - or not isinstance(revision, str) or not revision.startswith(config.service + "-") - or type(generation) is not int or generation < 1 - or type(observed_generation) is not int or observed_generation != generation - or status["latestCreatedRevisionName"] != revision - or template["metadata"]["name"] != revision + or not isinstance(revision_name, str) or not revision_name.startswith(config.service + "-") + or str(revision["metadata"]["namespace"]) != str(metadata["namespace"]) or not isinstance(traffic, list) or len(traffic) != 1 or type(traffic[0].get("percent")) is not int or traffic[0]["percent"] != 100 - or traffic[0].get("revisionName") != revision or traffic[0].get("tag") + or traffic[0].get("revisionName") != revision_name or traffic[0].get("tag") or not isinstance(containers, list) or len(containers) != 1 ): raise ValueError @@ -412,7 +411,7 @@ def _project_independent_history( if ( payload.get("schema_version") != SNAPSHOT_SCHEMA or payload.get("status") != "partial" - or payload.get("account_scope") != "HK" + or payload.get("account_scope") != config.account_scope or payload.get("no_order") is not True or payload.get("live_authority_granted") is not False or payload.get("snapshot_atomic") is not False @@ -434,7 +433,7 @@ def _project_independent_history( cash = _independent_money_rows(payload.get("cash"), _CASH_FIELDS) record = { "schema_version": HISTORY_SCHEMA, "snapshot_schema_version": SNAPSHOT_SCHEMA, - "account_scope": "HK", "target_id": "hk", + "account_scope": config.account_scope, "target_id": config.target_id, "source_binding": {"kind": SOURCE_KIND, "status": "bound", "id": config.source_binding_id}, "observed_started_at": payload["observed_started_at"], "observed_finished_at": payload["observed_finished_at"], @@ -450,7 +449,7 @@ def _project_independent_history( raise _Rejected("snapshot_invalid") from None -def observe_independent_hk_account_snapshot( +def observe_independent_account_snapshot( env: Mapping[str, str], *, service_metadata: object, http_get: Callable[..., Any], http_post: Callable[..., Any], now_reader: Callable[[], datetime], ) -> DailyAccountRecordResult: @@ -458,7 +457,7 @@ def observe_independent_hk_account_snapshot( if str(env.get("ACCOUNT_HISTORY_RECORDING_ENABLED") or "").strip() != "true": return DailyAccountRecordResult("disabled") try: - config = _independent_hk_config(env) + config = _independent_config(env) sync_config = _account_facts_sync_config(env) _validate_independent_service(service_metadata, config) started_at = _utc_now(now_reader) @@ -506,14 +505,33 @@ def _default_http_get(url: str, **kwargs: Any): return _OwnedResponse(response, session) -def _read_independent_service_metadata() -> object: +def _read_independent_service_metadata(config: _IndependentConfig) -> object: # gcloud stdout is piped directly here; never save or print its private env. body = sys.stdin.buffer.read(256 * 1024 + 1) if len(body) > 256 * 1024: raise _Rejected("independent_service_unqualified") try: - return json.loads(body) - except (UnicodeDecodeError, json.JSONDecodeError): + service = json.loads(body) + # The active revision can be older than a failed or zero-traffic deploy. + # Read its actual flags instead of treating the newest template as live. + if service["metadata"]["name"] != config.service: + raise ValueError + traffic = [row for row in service["status"]["traffic"] if (row.get("percent") or 0) > 0] + if len(traffic) != 1 or type(traffic[0].get("percent")) is not int or traffic[0]["percent"] != 100: + raise ValueError + revision_name = traffic[0]["revisionName"] + if not isinstance(revision_name, str) or not re.fullmatch(re.escape(config.service) + r"-[a-z0-9-]+", revision_name): + raise ValueError + result = subprocess.run( + ["gcloud", "run", "revisions", "describe", revision_name, "--project", config.project_id, + "--region", config.region, "--format=json", "--quiet"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=HTTP_TIMEOUT_SECONDS, check=False, + ) + if result.returncode or len(result.stdout) > 256 * 1024: + raise ValueError + service["serving_revision"] = json.loads(result.stdout) + return service + except (KeyError, TypeError, AttributeError, ValueError, subprocess.SubprocessError): raise _Rejected("independent_service_unqualified") from None @@ -1828,25 +1846,25 @@ def main( service_metadata: object = None, ) -> int: args = sys.argv[1:] if argv is None else argv - if args == ["--validate-independent-hk-audience"]: + if args == ["--validate-independent-audience"]: try: - config = _independent_hk_config(os.environ if environ is None else environ, require_id_token=False) + config = _independent_config(os.environ if environ is None else environ, require_id_token=False) except Exception: # noqa: BLE001 - never disclose invalid operational input print("independent_audience=error:independent_config_invalid") return 1 # The workflow redirects this one verified origin to GITHUB_OUTPUT. print(f"service_url={config.service_url}") return 0 - if args in (["--validate-independent-hk-account-snapshot"], ["--independent-hk-account-snapshot"]): + if args in (["--validate-independent-account-snapshot"], ["--independent-account-snapshot"]): env = os.environ if environ is None else environ try: - _independent_hk_config(env) + config = _independent_config(env) _account_facts_sync_config(env) - if args == ["--validate-independent-hk-account-snapshot"]: + if args == ["--validate-independent-account-snapshot"]: print("independent_config=ok") return 0 - metadata = _read_independent_service_metadata() if service_metadata is None else service_metadata - result = observe_independent_hk_account_snapshot( + metadata = _read_independent_service_metadata(config) if service_metadata is None else service_metadata + result = observe_independent_account_snapshot( env, service_metadata=metadata, http_get=http_get or _default_http_get, http_post=http_post or _default_http_post, now_reader=now_reader or (lambda: datetime.now(UTC)), diff --git a/tests/test_daily_account_snapshot.py b/tests/test_daily_account_snapshot.py index 2a030a1..bf10ff8 100644 --- a/tests/test_daily_account_snapshot.py +++ b/tests/test_daily_account_snapshot.py @@ -1,9 +1,11 @@ from __future__ import annotations +import io import json import sys from datetime import datetime, timedelta, timezone from pathlib import Path +from types import SimpleNamespace import pytest @@ -1639,7 +1641,7 @@ def _independent_env(**overrides): def _service_metadata(): - return { + value = { "metadata": { "name": "longbridge-quant-hk-service", "namespace": "252919773759", @@ -1660,6 +1662,13 @@ def _service_metadata(): }, } + value["serving_revision"] = { + "metadata": {"name": value["status"]["traffic"][0]["revisionName"], "namespace": value["metadata"]["namespace"]}, + "spec": json.loads(json.dumps(value["spec"]["template"]["spec"])), + "status": {"conditions": [{"type": "Ready", "status": "True"}]}, + } + return value + def _independent_snapshot(**overrides): value = { @@ -1727,7 +1736,7 @@ def post(url, **kwargs): "observed_finished_at": payload["observed_finished_at"], }) - result = snapshots.observe_independent_hk_account_snapshot( + result = snapshots.observe_independent_account_snapshot( _independent_env() if env is None else env, service_metadata=_service_metadata() if metadata is None else metadata, http_get=get, http_post=post, @@ -1767,9 +1776,6 @@ def test_independent_hk_preserves_native_currency_and_only_publishes_existing_hi {"ACCOUNT_HISTORY_EXPECTED_SCOPE": "SG"}, {"ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID": ""}, {"GOOGLE_CLOUD_PROJECT": "other-project"}, - {"RUNTIME_TARGET_ENABLED": "true"}, - {"GITHUB_EVENT_NAME": "schedule"}, - {"ACCOUNT_HISTORY_REQUESTED_TARGET": "all"}, {"GITHUB_REF": "refs/heads/candidate"}, {"GITHUB_WORKFLOW_REF": "other/workflow@refs/heads/main"}, {"ACCOUNT_HISTORY_INDEPENDENT_ID_TOKEN": ""}, @@ -1792,9 +1798,9 @@ def test_independent_invalid_config_has_no_get_or_publish(overrides): lambda v: v["status"].update(url="https://different.run.app"), lambda v: v["status"].update(traffic=[{"revisionName": "old-revision", "percent": 100}]), lambda v: v["status"].update(traffic=[{"revisionName": "longbridge-quant-hk-service-00001", "percent": 50}]), - lambda v: v["status"].update(conditions=[{"type": "Ready", "status": "False"}]), - lambda v: v["spec"]["template"]["spec"]["containers"][0].update(env=[]), - lambda v: v["spec"]["template"]["spec"]["containers"][0]["env"][0].update(value="false"), + lambda v: v["serving_revision"]["status"].update(conditions=[{"type": "Ready", "status": "False"}]), + lambda v: v["serving_revision"]["spec"]["containers"][0].update(env=[]), + lambda v: v["serving_revision"]["spec"]["containers"][0]["env"][0].update(value="false"), ]) def test_independent_service_gate_rejects_before_sending_id_token(mutate): metadata = _service_metadata() @@ -1885,12 +1891,12 @@ def test_independent_hk_workflow_reuses_explicit_identity_and_exact_audience(): assert "ACCOUNT_HISTORY_OBSERVATION_MODE:" in workflow assert "ACCOUNT_HISTORY_REQUESTED_TARGET: \u0024{{ inputs.target || 'all' }}" in workflow assert "ACCOUNT_HISTORY_INDEPENDENT_ID_TOKEN:" in workflow - assert "--independent-hk-account-snapshot" in workflow + assert "--independent-account-snapshot" in workflow assert "token_format:" in workflow and "id_token_audience:" in workflow assert 'gcloud run services describe "\u0024{SNAPSHOT_SERVICE}"' in workflow assert '--project "\u0024{GOOGLE_CLOUD_PROJECT}"' in workflow assert '--region "\u0024{SNAPSHOT_REGION}"' in workflow - assert "| uv run --no-sync python scripts/record_daily_account_snapshot.py --independent-hk-account-snapshot" in workflow + assert "| uv run --no-sync python scripts/record_daily_account_snapshot.py --independent-account-snapshot" in workflow assert "uv run --no-sync python scripts/record_daily_account_snapshot.py\n" in workflow @@ -1938,7 +1944,7 @@ def test_independent_invalid_financing_cannot_expand_wire_contract(financing): def test_independent_observation_date_is_utc_and_original_instants_are_not_rewritten(): - config = snapshots._independent_hk_config(_independent_env()) + config = snapshots._independent_config(_independent_env()) started = "2026-09-29T00:00:01+08:00" finished = "2026-09-29T00:00:02+08:00" request_started = datetime.fromisoformat(started) - timedelta(seconds=1) @@ -1952,7 +1958,7 @@ def test_independent_observation_date_is_utc_and_original_instants_are_not_rewri def test_independent_rechecks_existing_freshness_window_before_publish(): calls = [] - result = snapshots.observe_independent_hk_account_snapshot( + result = snapshots.observe_independent_account_snapshot( _independent_env(), service_metadata=_service_metadata(), http_get=lambda *_args, **_kwargs: _IndependentResponse(_independent_snapshot()), http_post=lambda *_args, **_kwargs: calls.append("post"), @@ -1967,7 +1973,7 @@ def fail_get(*_args, **_kwargs): raise TimeoutError(f"{SECRET} {QRS_TOKEN} synthetic-private-position") code = snapshots.main( - ["--independent-hk-account-snapshot"], + ["--independent-account-snapshot"], environ=_independent_env(), service_metadata=_service_metadata(), http_get=fail_get, http_post=lambda *_a, **_k: pytest.fail("must not publish"), now_reader=lambda: T0, @@ -1978,7 +1984,7 @@ def fail_get(*_args, **_kwargs): def test_independent_configuration_check_never_reads_stdin_or_provider(monkeypatch, capsys): monkeypatch.setattr(snapshots, "_read_independent_service_metadata", lambda: pytest.fail("must not read metadata")) - code = snapshots.main(["--validate-independent-hk-account-snapshot"], environ=_independent_env()) + code = snapshots.main(["--validate-independent-account-snapshot"], environ=_independent_env()) assert code == 0 and capsys.readouterr().out.strip() == "independent_config=ok" @@ -2026,27 +2032,20 @@ def test_independent_workflow_keeps_cloud_resources_secret_and_no_new_auth_ident assert "id_token_audience: \u0024{{ steps.independent_audience.outputs.service_url || '' }}" in workflow assert "service_account: \u0024{{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}" in workflow independent = workflow[workflow.index(' independent_get)'):workflow.index(' *)', workflow.index(' independent_get)'))] - assert "--validate-independent-hk-account-snapshot" in independent + assert "--validate-independent-account-snapshot" in independent assert independent.index("--validate-independent") < independent.index("gcloud run services describe") assert "/probe" not in independent and "/run" not in independent assert "scheduler" not in independent and "tee" not in independent and "upload-artifact" not in independent @pytest.mark.parametrize("mutate", [ - lambda v: v["metadata"].pop("generation"), - lambda v: v["metadata"].update(generation=0), - lambda v: v["metadata"].update(generation=True), - lambda v: v["metadata"].update(generation="7"), - lambda v: v["status"].pop("observedGeneration"), - lambda v: v["status"].update(observedGeneration=6), - lambda v: v["status"].update(observedGeneration=8), - lambda v: v["status"].update(observedGeneration="7"), - lambda v: v["status"].pop("latestCreatedRevisionName"), - lambda v: v["status"].update(latestCreatedRevisionName="longbridge-quant-hk-service-00002"), - lambda v: v["spec"]["template"].pop("metadata"), - lambda v: v["spec"]["template"]["metadata"].update(name="longbridge-quant-hk-service-00002"), + lambda v: v.pop("serving_revision"), + lambda v: v["serving_revision"]["metadata"].update(name="other-service-00001"), + lambda v: v["serving_revision"]["metadata"].update(namespace="other-project"), + lambda v: v["serving_revision"]["status"].update(conditions=[{"type": "Ready", "status": "False"}]), + lambda v: v["serving_revision"]["spec"].update(containers=[]), ]) -def test_independent_revision_and_generation_must_prove_serving_template(mutate): +def test_independent_actual_serving_revision_must_match_target_and_be_ready(mutate): metadata = _service_metadata() mutate(metadata) result, calls, _ = _observe_independent(metadata=metadata) @@ -2054,17 +2053,6 @@ def test_independent_revision_and_generation_must_prove_serving_template(mutate) assert calls == {"gets": [], "posts": []} -def test_independent_unreconciled_new_template_cannot_qualify_old_serving_revision(): - metadata = _service_metadata() - metadata["metadata"]["generation"] = 2 - metadata["status"]["observedGeneration"] = 1 - metadata["status"]["latestCreatedRevisionName"] = "longbridge-quant-hk-service-00002" - metadata["spec"]["template"]["metadata"]["name"] = "longbridge-quant-hk-service-00002" - result, calls, _ = _observe_independent(metadata=metadata) - assert result.status == "error" - assert calls == {"gets": [], "posts": []} - - @pytest.mark.parametrize("raw_url", [ _independent_env()["ACCOUNT_HISTORY_SERVICE_URL"] + "/", " " + _independent_env()["ACCOUNT_HISTORY_SERVICE_URL"], @@ -2081,7 +2069,7 @@ def test_independent_noncanonical_raw_audience_rejected_before_get(raw_url): def test_independent_pre_auth_audience_output_is_canonical_without_token_or_metadata(monkeypatch, capsys): env = _independent_env(ACCOUNT_HISTORY_INDEPENDENT_ID_TOKEN="") monkeypatch.setattr(snapshots, "_read_independent_service_metadata", lambda: pytest.fail("no service read before auth")) - assert snapshots.main(["--validate-independent-hk-audience"], environ=env) == 0 + assert snapshots.main(["--validate-independent-audience"], environ=env) == 0 assert capsys.readouterr().out.strip() == "service_url=" + env["ACCOUNT_HISTORY_SERVICE_URL"] @@ -2089,7 +2077,7 @@ def test_independent_pre_auth_audience_output_is_canonical_without_token_or_meta def test_independent_pre_auth_rejects_noncanonical_audience_without_printing_it(suffix, capsys): env = _independent_env(ACCOUNT_HISTORY_INDEPENDENT_ID_TOKEN="") env["ACCOUNT_HISTORY_SERVICE_URL"] += suffix - assert snapshots.main(["--validate-independent-hk-audience"], environ=env) == 1 + assert snapshots.main(["--validate-independent-audience"], environ=env) == 1 output = capsys.readouterr().out assert output.strip() == "independent_audience=error:independent_config_invalid" assert env["ACCOUNT_HISTORY_SERVICE_URL"] not in output @@ -2099,9 +2087,104 @@ def test_independent_workflow_validates_one_audience_before_auth_and_reuses_it() workflow = (ROOT / ".github/workflows/execution-report-heartbeat.yml").read_text() validation = workflow.index("id: independent_audience") assert validation < workflow.index("id: gcp_auth_primary") - assert '--validate-independent-hk-audience >> "$GITHUB_OUTPUT"' in workflow + assert '--validate-independent-audience >> "$GITHUB_OUTPUT"' in workflow audience = "id_token_audience: \u0024{{ steps.independent_audience.outputs.service_url || '' }}" assert workflow.count(audience) == 2 assert "token_format: \u0024{{ steps.independent_audience.outcome == 'success' && 'id_token' || '' }}" in workflow record_step = workflow[workflow.index(" - name: Record and sync daily account snapshot"):workflow.index(" - name: Check recent execution report")] assert "ACCOUNT_HISTORY_SERVICE_URL: \u0024{{ env.ACCOUNT_HISTORY_OBSERVATION_MODE != 'independent_get' && vars.ACCOUNT_HISTORY_SERVICE_URL || steps.independent_audience.outputs.service_url }}" in record_step + + +def test_scheduled_read_only_snapshot_does_not_depend_on_strategy_activation(): + env = _independent_env(GITHUB_EVENT_NAME="schedule", ACCOUNT_HISTORY_REQUESTED_TARGET="all") + result, calls, _ = _observe_independent(env) + assert result.status == "observed" + assert len(calls["gets"]) == len(calls["posts"]) == 1 + assert env["RUNTIME_TARGET_ENABLED"] == "false" + + +def test_failed_candidate_does_not_block_healthy_serving_snapshot(): + metadata = _service_metadata() + metadata["serving_revision"] = { + "metadata": {"name": metadata["status"]["traffic"][0]["revisionName"], "namespace": metadata["metadata"]["namespace"]}, + "spec": metadata["spec"]["template"]["spec"], + "status": {"conditions": [{"type": "Ready", "status": "True"}]}, + } + metadata["status"]["latestCreatedRevisionName"] = "longbridge-quant-hk-service-failed-candidate" + metadata["status"]["conditions"] = [{"type": "Ready", "status": "False"}] + metadata["spec"]["template"] = {"metadata": {"name": "longbridge-quant-hk-service-failed-candidate"}, "spec": {"containers": []}} + result, calls, _ = _observe_independent(metadata=metadata) + assert result.status == "observed" + assert len(calls["gets"]) == 1 + + +@pytest.mark.parametrize("target_id", ["paper", "hk", "sg"]) +@pytest.mark.parametrize("event", ["workflow_dispatch", "schedule"]) +@pytest.mark.parametrize("trading_enabled", ["true", "false"]) +def test_independent_snapshot_targets_keep_scope_and_ignore_trading_activation(target_id, event, trading_enabled): + from application.runtime_target_manifest import load_runtime_target_manifest + target = next(t for t in load_runtime_target_manifest().targets if t.id == target_id) + env = _independent_env(**{**_env(target_id), "SNAPSHOT_SERVICE": target.service, "SNAPSHOT_REGION": target.region, + "ACCOUNT_HISTORY_REQUESTED_TARGET": target_id if event == "workflow_dispatch" else "all", + "GITHUB_EVENT_NAME": event, "RUNTIME_TARGET_ENABLED": trading_enabled}) + metadata = _service_metadata() + metadata["metadata"]["name"] = target.service + metadata["metadata"]["labels"]["cloud.googleapis.com/location"] = target.region + metadata["status"]["url"] = env["ACCOUNT_HISTORY_SERVICE_URL"] + revision = target.service + "-00001" + metadata["status"]["traffic"] = [{"revisionName": revision, "percent": 100}] + metadata["serving_revision"]["metadata"]["name"] = revision + result, calls, _ = _observe_independent(env, metadata=metadata, snapshot=_independent_snapshot(account_scope=target.account_scope)) + assert result.status == "observed" and result.publish_status == "published" + assert len(calls["gets"]) == len(calls["posts"]) == 1 + history = json.loads(calls["posts"][0][1]["data"]) + assert history["target_id"] == target_id and history["account_scope"] == target.account_scope + assert env["RUNTIME_TARGET_ENABLED"] == trading_enabled + + +def test_independent_cli_reads_only_the_actual_serving_revision(monkeypatch, capsys): + metadata = _service_metadata() + serving = metadata.pop("serving_revision") + metadata["status"]["latestCreatedRevisionName"] = "longbridge-quant-hk-service-failed-candidate" + calls = [] + monkeypatch.setattr(snapshots.sys, "stdin", SimpleNamespace(buffer=io.BytesIO(json.dumps(metadata).encode()))) + def run(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(returncode=0, stdout=json.dumps(serving).encode()) + monkeypatch.setattr(snapshots.subprocess, "run", run) + config = snapshots._independent_config(_independent_env()) + observed = snapshots._read_independent_service_metadata(config) + snapshots._validate_independent_service(observed, config) + assert len(calls) == 1 + command, kwargs = calls[0] + assert command[:5] == ["gcloud", "run", "revisions", "describe", serving["metadata"]["name"]] + assert command[command.index("--project") + 1] == "longbridgequant" + assert command[command.index("--region") + 1] == "asia-east2" + assert kwargs["stderr"] == snapshots.subprocess.DEVNULL + assert kwargs["timeout"] == snapshots.HTTP_TIMEOUT_SECONDS + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize("failure", ["permission", "timeout", "oversize"]) +def test_independent_revision_read_failure_is_bounded_and_never_retried(monkeypatch, failure): + metadata = _service_metadata() + monkeypatch.setattr(snapshots.sys, "stdin", SimpleNamespace(buffer=io.BytesIO(json.dumps(metadata).encode()))) + calls = [] + def run(command, **kwargs): + calls.append(command) + if failure == "timeout": + raise snapshots.subprocess.TimeoutExpired(command, kwargs["timeout"]) + return SimpleNamespace(returncode=1 if failure == "permission" else 0, stdout=b"x" * (256 * 1024 + 1)) + monkeypatch.setattr(snapshots.subprocess, "run", run) + with pytest.raises(snapshots._Rejected, match="independent_service_unqualified"): + snapshots._read_independent_service_metadata(snapshots._independent_config(_independent_env())) + assert len(calls) == 1 + + +def test_manual_snapshot_only_skips_heartbeat_notifications_and_runtime_publishers(): + workflow = (ROOT / ".github/workflows/execution-report-heartbeat.yml").read_text() + assert "snapshot_only:" in workflow + for step in ["Check recent execution report", "Publish read-only runtime execution evidence", + "Publish daily runtime projection", "Publish existing reports as unified digest candidates"]: + block = workflow[workflow.index(" - name: " + step):].split("\n - name:", 1)[0] + assert "!inputs.snapshot_only" in block.split("\n", 2)[1] diff --git a/tests/test_execution_report_heartbeat.py b/tests/test_execution_report_heartbeat.py index d525d90..2d11fe9 100644 --- a/tests/test_execution_report_heartbeat.py +++ b/tests/test_execution_report_heartbeat.py @@ -44,7 +44,7 @@ def test_sg_permission_inspection_is_opt_in_target_limited_and_skips_pipeline(): ) assert "python scripts/record_daily_account_snapshot.py --inspect-sg-probe-permissions" in workflow assert "env.INSPECT_SG_PROBE_PERMISSIONS != 'true'" in workflow - assert "name: Check recent execution report\n if: ${{ env.INSPECT_SG_PROBE_PERMISSIONS != 'true' }}" in workflow + assert "name: Check recent execution report\n if: ${{ !inputs.snapshot_only && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' }}" in workflow def test_archive_inspection_is_manual_target_limited_and_skips_heartbeat_job():