Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions .github/workflows/execution-report-heartbeat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ on:
- cron: "20 22 * * *"

env:
GCP_PROJECT_ID: longbridgequant
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/252919773759/locations/global/workloadIdentityPools/github-actions/providers/github-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: longbridge-platform-deploy@longbridgequant.iam.gserviceaccount.com
GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ secrets.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}

concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
Expand Down Expand Up @@ -151,6 +151,7 @@ jobs:
RUNTIME_HEARTBEAT_EXPECTED_TIMEZONE: ${{ vars.RUNTIME_HEARTBEAT_EXPECTED_TIMEZONE }}
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
ACCOUNT_HISTORY_OBSERVATION_MODE: ${{ vars.ACCOUNT_HISTORY_OBSERVATION_MODE || 'scheduler_archive' }}
ACCOUNT_HISTORY_ALLOWED_REF: ${{ format('refs/heads/{0}', github.event.repository.default_branch) }}
ACCOUNT_HISTORY_REQUESTED_TARGET: ${{ inputs.target || 'all' }}
NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }}
RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }}
Expand Down Expand Up @@ -180,11 +181,12 @@ jobs:
continue-on-error: true
env:
ACCOUNT_HISTORY_RECORDING_ENABLED: ${{ vars.ACCOUNT_HISTORY_RECORDING_ENABLED }}
ACCOUNT_HISTORY_SERVICE_URL: ${{ vars.ACCOUNT_HISTORY_SERVICE_URL }}
ACCOUNT_HISTORY_SERVICE_URL: ${{ secrets.ACCOUNT_HISTORY_SERVICE_URL }}
ACCOUNT_HISTORY_TARGET_ID: ${{ matrix.target.id }}
ACCOUNT_HISTORY_EXPECTED_SCOPE: ${{ matrix.target.label }}
ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID: ${{ secrets.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID || vars.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID }}
ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID: ${{ secrets.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID }}
GOOGLE_CLOUD_PROJECT: ${{ env.GCP_PROJECT_ID }}
GOOGLE_CLOUD_PROJECT_NUMBER: ${{ secrets.GCP_PROJECT_NUMBER }}
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-audience >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -235,16 +237,17 @@ jobs:
continue-on-error: true
env:
ACCOUNT_HISTORY_RECORDING_ENABLED: ${{ vars.ACCOUNT_HISTORY_RECORDING_ENABLED }}
ACCOUNT_HISTORY_SERVICE_URL: ${{ env.ACCOUNT_HISTORY_OBSERVATION_MODE != 'independent_get' && vars.ACCOUNT_HISTORY_SERVICE_URL || steps.independent_audience.outputs.service_url }}
ACCOUNT_HISTORY_SERVICE_URL: ${{ env.ACCOUNT_HISTORY_OBSERVATION_MODE != 'independent_get' && secrets.ACCOUNT_HISTORY_SERVICE_URL || steps.independent_audience.outputs.service_url }}
ACCOUNT_HISTORY_GCS_PREFIX: ${{ vars.ACCOUNT_HISTORY_GCS_PREFIX }}
ACCOUNT_HISTORY_TARGET_ID: ${{ matrix.target.id }}
ACCOUNT_HISTORY_EXPECTED_SCOPE: ${{ matrix.target.label }}
ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID: ${{ secrets.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID || vars.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID }}
ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID: ${{ secrets.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID }}
GOOGLE_CLOUD_PROJECT: ${{ env.GCP_PROJECT_ID }}
ACCOUNT_FACTS_SYNC_ENABLED: ${{ vars.ACCOUNT_FACTS_SYNC_ENABLED }}
ACCOUNT_FACTS_SYNC_URL: ${{ vars.ACCOUNT_FACTS_SYNC_URL }}
ACCOUNT_FACTS_SYNC_TOKEN: ${{ vars.ACCOUNT_FACTS_SYNC_ENABLED == 'true' && secrets.ACCOUNT_FACTS_SYNC_TOKEN || '' }}
ACCOUNT_HISTORY_INDEPENDENT_ID_TOKEN: ${{ steps.gcp_auth_primary.outputs.id_token || steps.gcp_auth_retry.outputs.id_token }}
GOOGLE_CLOUD_PROJECT_NUMBER: ${{ secrets.GCP_PROJECT_NUMBER }}
SNAPSHOT_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }}
SNAPSHOT_REGION: ${{ vars.CLOUD_RUN_REGION }}
run: |
Expand Down Expand Up @@ -329,14 +332,14 @@ jobs:
id-token: write
environment: ${{ matrix.target.environment }}
env:
GCP_PROJECT_ID: longbridgequant
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/252919773759/locations/global/workloadIdentityPools/github-actions/providers/github-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: longbridge-platform-deploy@longbridgequant.iam.gserviceaccount.com
GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ secrets.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
ACCOUNT_HISTORY_TARGET_ID: ${{ matrix.target.id }}
ACCOUNT_HISTORY_EXPECTED_SCOPE: ${{ matrix.target.label }}
ACCOUNT_HISTORY_SERVICE_URL: ${{ vars.ACCOUNT_HISTORY_SERVICE_URL }}
ACCOUNT_HISTORY_SERVICE_URL: ${{ secrets.ACCOUNT_HISTORY_SERVICE_URL }}
ACCOUNT_HISTORY_GCS_PREFIX: ${{ vars.ACCOUNT_HISTORY_GCS_PREFIX }}
ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID: ${{ secrets.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID || vars.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID }}
ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID: ${{ secrets.ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID }}
ACCOUNT_HISTORY_INSPECTION_TRIGGERED_AT: ${{ inputs.snapshot_triggered_at }}
ACCOUNT_HISTORY_INSPECTION_COMPLETED_AT: ${{ inputs.snapshot_inspection_completed_at }}
steps:
Expand Down
33 changes: 33 additions & 0 deletions docs/read_only_account_observation.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 只读账户观察器的配置与边界

独立观察器支持 LongBridge 的 PAPER、HK、SG 协议范围。目标名称可以自定义;账户槽位、策略名称和券商原生账户身份分别处理,策略名称不能代替账户身份。

本实现复用普通配置对象、只读券商入口和现有账户事实接收接口。配置解析、服务身份核验、券商响应投影和发送分别由已有函数承担,不增加插件框架或第二套账户注册表。

## 配置来源

独立观察器从当前受保护任务环境读取部署信息,不从公开运行目标清单推导生产映射。

| 参数 | 用途 |
| --- | --- |
| `GOOGLE_CLOUD_PROJECT`、`GOOGLE_CLOUD_PROJECT_NUMBER` | 核对服务所在项目;数字项目号在返回数字 namespace 时必填 |
| `SNAPSHOT_SERVICE`、`SNAPSHOT_REGION` | 定位并核对实际承载流量的服务版本 |
| `ACCOUNT_HISTORY_TARGET_ID`、`ACCOUNT_HISTORY_EXPECTED_SCOPE` | 自定义账户槽位与协议范围 |
| `ACCOUNT_HISTORY_SERVICE_URL` | 精确服务地址与 ID token audience |
| `ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID` | 核对该账户的预期来源绑定 |
| `ACCOUNT_HISTORY_ALLOWED_REF` | 允许执行的分支;工作流使用仓库默认分支 |
| `ACCOUNT_FACTS_SYNC_URL`、`ACCOUNT_FACTS_SYNC_TOKEN` | 既有账户事实接收接口及专用同步凭据 |

工作流的 `GCP_PROJECT_ID`、`GCP_PROJECT_NUMBER`、`GCP_WORKLOAD_IDENTITY_PROVIDER` 和 `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` 使用 GitHub Secrets。每个环境的 `ACCOUNT_HISTORY_SERVICE_URL` 和 `ACCOUNT_HISTORY_EXPECTED_SOURCE_BINDING_ID` 同样只从 Secrets 读取。真实地址、来源绑定及可关联用户的映射应放在受保护配置中;源码、文档、fixture 和公开日志只用不可关联真实部署的示例。部署参数不得在应用代码中设生产默认值。

`GITHUB_REPOSITORY`、`GITHUB_REF`、`GITHUB_WORKFLOW_REF` 使用 GitHub 提供的执行上下文。保护环境、云端 WIF 条件及精确来源绑定共同限制调用者;仓库名称不写死在 Python 中。

## 访问与数据处理

观察器先核对服务、项目、区域、地址及实际承载 100% 流量版本的只读开关,再发送 audience token。失败的新版本不能代替现役版本,也不能阻断一个已核准的健康现役版本。

`independent_get` 适用于可由当前执行器访问的已鉴权 Cloud Run 服务。内部 ingress 返回固定原因 `internal_observer_required`,在调用券商或发送账户事实前停止;不得为采集开放 ingress。内部服务应复用受支持的内部执行入口,另行验证只读采集、私有归档与接收闭环。此文档不表示该内部闭环已经完成。

单次操作最多一次快照 GET 和一次事实 POST,不自动重试未知结果。投影保留券商实际币种、金额、观察时间及来源;账户来源不匹配时拒收,不补零、不改旧数据时间。交易启停、风险预算和通知不由此观察器控制。

本次配置迁移只覆盖独立观察器与该心跳工作流。历史 Scheduler 观察器及其他部署流程尚有旧配置约定,不能据此宣称全仓已完成配置与隐私整改。
52 changes: 30 additions & 22 deletions scripts/record_daily_account_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,24 +300,35 @@ class _IndependentConfig:
source_binding_id: str
target_id: str
account_scope: str
project_number: str = ""


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)
target_id = str(env.get("ACCOUNT_HISTORY_TARGET_ID") or "")
scope = str(env.get("ACCOUNT_HISTORY_EXPECTED_SCOPE") or "")
project = str(env.get("GOOGLE_CLOUD_PROJECT") or "")
project_number = str(env.get("GOOGLE_CLOUD_PROJECT_NUMBER") or "")
service = str(env.get("SNAPSHOT_SERVICE") or "")
region = str(env.get("SNAPSHOT_REGION") or "")
repository = str(env.get("GITHUB_REPOSITORY") or "")
allowed_ref = str(env.get("ACCOUNT_HISTORY_ALLOWED_REF") or "refs/heads/main")
if (
env.get("ACCOUNT_HISTORY_OBSERVATION_MODE") != "independent_get"
or env.get("ACCOUNT_HISTORY_RECORDING_ENABLED") != "true"
or expected is None
or env.get("ACCOUNT_HISTORY_EXPECTED_SCOPE") != expected[0]
or env.get("GOOGLE_CLOUD_PROJECT") != "longbridgequant"
or not _TARGET_ID.fullmatch(target_id)
or scope not in {"PAPER", "HK", "SG"}
or not _PROJECT_ID.fullmatch(project)
or (project_number and not re.fullmatch(r"[1-9]\d{5,19}", project_number))
or not re.fullmatch(r"[a-z][a-z0-9-]{0,62}", service)
or not re.fullmatch(r"[a-z]+-[a-z]+\d+", region)
or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository)
or not allowed_ref.startswith("refs/heads/") or any(c.isspace() for c in allowed_ref)
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_REF") != allowed_ref
or env.get("GITHUB_WORKFLOW_REF") != (
"QuantStrategyLab/LongBridgePlatform/.github/workflows/"
"execution-report-heartbeat.yml@refs/heads/main"
f"{repository}/.github/workflows/execution-report-heartbeat.yml@{allowed_ref}"
)
):
raise _Rejected("independent_config_invalid")
Expand All @@ -327,22 +338,13 @@ def _independent_config(env: Mapping[str, str], *, require_id_token: bool = True
require_id_token and (not token or len(token) > 16384 or any(c.isspace() for c in token))
):
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 == 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:
raise _Rejected("independent_config_invalid")
raw_url = env.get("ACCOUNT_HISTORY_SERVICE_URL")
service_url = _service_url(str(raw_url or ""), service=target.service, region=target.region)
service_url = _service_url(str(raw_url or ""), service=service, region=region)
# Unlike the legacy Scheduler route, this exact string is also the token audience.
if raw_url != service_url:
raise _Rejected("independent_config_invalid")
return _IndependentConfig(
"longbridgequant", target.region, target.service,
service_url, binding, target_id, expected[0],
project, region, service, service_url, binding, target_id, scope, project_number,
)


Expand All @@ -360,10 +362,8 @@ def _validate_independent_service(value: object, config: _IndependentConfig) ->
revision_name = revision["metadata"]["name"]
if (
metadata["name"] != config.service
or str(metadata["namespace"]) not in {config.project_id, "252919773759"}
or str(metadata["namespace"]) not in {v for v in (config.project_id, config.project_number) if v}
or metadata["labels"]["cloud.googleapis.com/location"] != config.region
or annotations.get("run.googleapis.com/ingress") != "all"
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_name, str) or not revision_name.startswith(config.service + "-")
Expand All @@ -380,6 +380,14 @@ def _validate_independent_service(value: object, config: _IndependentConfig) ->
]
if len(snapshot_flags) != 1 or snapshot_flags[0].get("value") != "true" or "valueFrom" in snapshot_flags[0]:
raise ValueError
ingress = annotations.get("run.googleapis.com/ingress")
ingress_status = annotations.get("run.googleapis.com/ingress-status", ingress)
if ingress in {"internal", "internal-and-cloud-load-balancing"} or ingress_status in {
"internal", "internal-and-cloud-load-balancing",
}:
raise _Rejected("internal_observer_required")
if ingress != "all" or ingress_status != "all":
raise ValueError
except (KeyError, TypeError, AttributeError, ValueError):
raise _Rejected("independent_service_unqualified") from None

Expand Down
Loading
Loading