diff --git a/README.zh-CN.md b/README.zh-CN.md index 28359fe..d41eee5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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`。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10cc812..ab04397 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/python/scripts/runtime_settings.py b/python/scripts/runtime_settings.py index 685ef2b..11c3110 100644 --- a/python/scripts/runtime_settings.py +++ b/python/scripts/runtime_settings.py @@ -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): @@ -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) @@ -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]: diff --git a/python/tests/test_runtime_settings.py b/python/tests/test_runtime_settings.py index 8ad5fd5..31e3c12 100644 --- a/python/tests/test_runtime_settings.py +++ b/python/tests/test_runtime_settings.py @@ -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"