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
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ confirm_apply=APPLY_AND_SYNC
- 多服务目标以 `service_name` 为唯一主标识;同一 `account_scope` 可以有多个策略服务,切换只更新精确服务,不会覆盖兄弟目标。
- `CLOUD_RUN_SERVICE_TARGETS_JSON` 同时支持数组和 `{targets:[...]}`;新增服务必须显式选择 `service_targets_mode=allow_create`。
- 跨仓写 variables 和触发 workflow 必须在本仓配置 `RUNTIME_SETTINGS_GH_TOKEN` secret,token 至少需要目标仓库的 variables/workflow 写权限;不会回退到默认 `github.token` 写远端变量。
- `RUNTIME_TARGET_JSON` 是经过 schema 校验的非敏感部署意图,控制台会写入 GitHub Variable;下游平台应优先读取该 Variable,旧同名 Secret 仅可作为迁移回退。它不得包含券商凭据、账户密码或 API key。
- `RUNTIME_TARGET_JSON` 与多服务清单 `CLOUD_RUN_SERVICE_TARGETS_JSON` 都是经过 schema 校验的非敏感部署意图,控制台会写入 GitHub Variable;下游平台应优先读取这些 Variable,旧同名 Secret 仅可作为迁移回退。两者都不得包含券商凭据、账户密码或 API key;校验器也会拒绝嵌套的疑似 secret 字段
- LongBridge、IBKR、Schwab、Firstrade 的 `service_targets_mode=auto` 会检查目标仓库是否已有多服务清单,因此即使只做 preview 也需要 `RUNTIME_SETTINGS_GH_TOKEN`。
- Binance 运行在 Oracle Cloud VPS 的 self-hosted runner。仓库变量会在外部调度器下一次触发 `main.yml` 时被读取;中控不会自动触发该运行 workflow,因为它可能直接执行实盘。切换到不同运行频率的策略时,还必须单独复核 VPS 外部调度器。
- QMT 当前仅支持 dry-run,尚无实盘部署配置;可以生成目标并暂存仓库变量,但会拒绝 `trigger_platform_sync=true`。
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

QuantRuntimeSettings is a **config-driven** runtime settings package that serves as the central control plane for QuantStrategyLab deployments. It defines versioned strategy-to-platform assignments and hosts a Cloudflare Workers-based console. The console is evolving into the personal deployment's single human-facing global decision surface; see [Unified Control Console V1](qsl_unified_control_console_architecture_v1.zh-CN.md). It remains separate from broker credentials and execution.

The generated `RUNTIME_TARGET_JSON` payload is the canonical desired-state contract for one deployment target. `scheduler`, `market`, `market_calendar`, `market_timezone`, and plugin mount outputs are derived from `strategy_profile`, while `execution_mode` is validated against strategy-profile policy. It is validated, non-secret deployment metadata and is written as a GitHub Variable so the console can reconcile desired state; it must never contain credentials or account secrets. Platform consumers must prefer that Variable and may read a legacy Secret only as a temporary migration fallback.
The generated `RUNTIME_TARGET_JSON` payload is the canonical desired-state contract for one deployment target. `scheduler`, `market`, `market_calendar`, `market_timezone`, and plugin mount outputs are derived from `strategy_profile`, while `execution_mode` is validated against strategy-profile policy. It and the repository-level `CLOUD_RUN_SERVICE_TARGETS_JSON` inventory are validated, non-secret deployment metadata written as GitHub Variables so the console can reconcile desired state; neither may contain credentials or account secrets. Platform consumers must prefer those Variables and may read legacy Secrets only as temporary migration fallbacks.

### Multi-strategy identity and storage

Expand Down
34 changes: 34 additions & 0 deletions python/scripts/runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,32 @@ def validate_option_overlay_variables(extra_variables: dict[str, Any], errors: l
errors.append("extra_variables.OPTION_OVERLAY_ENABLED is false but an option overlay family is enabled")


def validate_nonsecret_service_target_inventory(value: Any, *, path: str, errors: list[str]) -> None:
"""Reject secret-shaped keys nested in a variable-backed service inventory."""
payload = value
if isinstance(payload, str):
try:
payload = json.loads(payload)
except json.JSONDecodeError:
return

def visit(node: Any, current_path: str) -> None:
if isinstance(node, dict):
for key, nested in node.items():
key_text = str(key)
nested_path = f"{current_path}.{key_text}"
if is_secret_variable_name(key_text):
errors.append(f"{nested_path} looks like a secret and must not be stored here")
continue
visit(nested, nested_path)
elif isinstance(node, list):
for index, nested in enumerate(node):
visit(nested, f"{current_path}[{index}]")

if isinstance(payload, (dict, list)):
visit(payload, path)


def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
extra_variables = target.get("extra_variables", {})
if not isinstance(extra_variables, dict):
Expand All @@ -912,6 +938,10 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
errors.append(f"extra_variables.{name} looks like a secret and must not be stored here")
if isinstance(value, str) and "\n" in value:
errors.append(f"extra_variables.{name} must be a single-line value")
if name == "CLOUD_RUN_SERVICE_TARGETS_JSON":
validate_nonsecret_service_target_inventory(
value, path=f"extra_variables.{name}", errors=errors
)

validate_option_overlay_variables(extra_variables, errors)

Expand Down Expand Up @@ -945,6 +975,10 @@ def validate_repository_variables(target: dict[str, Any], errors: list[str]) ->
errors.append(f"repository_variables.{name} looks like a secret and must not be stored here")
if isinstance(value, str) and "\n" in value:
errors.append(f"repository_variables.{name} must be a single-line value")
if name == "CLOUD_RUN_SERVICE_TARGETS_JSON":
validate_nonsecret_service_target_inventory(
value, path=f"repository_variables.{name}", errors=errors
)


def validate_target(target: dict[str, Any], path: Path | None = None) -> list[str]:
Expand Down
25 changes: 25 additions & 0 deletions python/tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,31 @@ def test_extra_variables_reject_secret_values_but_allow_secret_pointers(self):
errors,
)

def test_service_target_inventory_rejects_nested_secret_values(self):
_, target = self.load_target("examples/targets/longbridge/sg.example.json")
target["repository_variables"] = {
"CLOUD_RUN_SERVICE_TARGETS_JSON": {
"targets": [
{
"service": "longbridge-quant-sg-service",
"BROKER_PASSWORD": "not-allowed",
"LONGPORT_SECRET_NAME": "allowed-secret-manager-name",
}
]
}
}

errors = runtime_settings.validate_target(target)

self.assertIn(
"repository_variables.CLOUD_RUN_SERVICE_TARGETS_JSON.targets[0].BROKER_PASSWORD looks like a secret and must not be stored here",
errors,
)
self.assertNotIn(
"repository_variables.CLOUD_RUN_SERVICE_TARGETS_JSON.targets[0].LONGPORT_SECRET_NAME looks like a secret and must not be stored here",
errors,
)

def test_longbridge_dry_run_flag_must_match_runtime_target(self):
_, target = self.load_target("examples/targets/longbridge/sg.example.json")
target["extra_variables"]["LONGBRIDGE_DRY_RUN_ONLY"] = "true"
Expand Down