From 59b3cc82da1ff61c40c7355bf6ce3a0cd3e99474 Mon Sep 17 00:00:00 2001 From: Li SiYi Date: Tue, 13 Oct 2026 19:01:17 +0000 Subject: [PATCH] Add read-only Flex configuration preflight --- .../check-ibkr-flex-configuration.yml | 37 +++++ docs/ibkr_flex_ledger.zh-CN.md | 26 +++ scripts/publish_ibkr_flex_period_return.py | 84 +++++++++- tests/test_publish_ibkr_flex_period_return.py | 149 ++++++++++++++++++ 4 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/check-ibkr-flex-configuration.yml diff --git a/.github/workflows/check-ibkr-flex-configuration.yml b/.github/workflows/check-ibkr-flex-configuration.yml new file mode 100644 index 00000000..45f820dc --- /dev/null +++ b/.github/workflows/check-ibkr-flex-configuration.yml @@ -0,0 +1,37 @@ +name: Check IBKR Flex Configuration + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + if: ${{ github.repository == 'QuantStrategyLab/InteractiveBrokersPlatform' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout main source + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + + - name: Install locked runtime dependencies + run: uv sync --frozen --no-dev + + - name: Check configuration shape without fetching or publishing + run: uv run --no-sync python -m scripts.publish_ibkr_flex_period_return --preflight + env: + GITHUB_SHA: ${{ github.sha }} + IBKR_PERIOD_RETURN_TARGET_ID: ${{ secrets.IBKR_PERIOD_RETURN_TARGET_ID }} + IBKR_PERIOD_RETURN_SOURCE_BINDING_ID: ${{ secrets.IBKR_PERIOD_RETURN_SOURCE_BINDING_ID }} + IBKR_PERIOD_RETURN_ACCOUNT_SCOPE: ${{ secrets.IBKR_PERIOD_RETURN_ACCOUNT_SCOPE }} + IBKR_PERIOD_RETURN_ACCOUNT_KEY: ${{ secrets.IBKR_PERIOD_RETURN_ACCOUNT_KEY }} + IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON: ${{ secrets.IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON }} + IBKR_FLEX_TOKEN: ${{ secrets.IBKR_FLEX_TOKEN }} + IBKR_FLEX_QUERY_ID: ${{ secrets.IBKR_FLEX_QUERY_ID }} + IBKR_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN }} diff --git a/docs/ibkr_flex_ledger.zh-CN.md b/docs/ibkr_flex_ledger.zh-CN.md index 4b9bcadc..b8692535 100644 --- a/docs/ibkr_flex_ledger.zh-CN.md +++ b/docs/ibkr_flex_ledger.zh-CN.md @@ -208,3 +208,29 @@ XML 元素/属性名与 `StmtFunds` 活动码(`DEP`/`WITH`/`MFEE`/`OFEE`/`FRTA `tests/test_ibkr_flex_source.py` 复现原 1019 普通错误并覆盖 pending 原句柄、安全异常、显式 同 reference 领取、永久错误零重发、账户范围拒绝;`tests/test_ibkr_flex_period_workflow.py` 验证云端入口的默认关闭、main/首 attempt 边界和私有配置来源。 + +## 只检查配置形状的手动入口 + +`PYTHONPATH=. python -m scripts.publish_ibkr_flex_period_return --preflight` +只读取同一个进程的八项既有发布配置,复用现有 `_config` 验证,不生成、领取 Flex 报表, +不创建 HTTP session,不调用同步发布器,也不读取 Cloud Run、Secret Manager 或账户门户。 +即使发布开关已经是 `true`,显式 preflight 也会在发布路径之前返回。原来无参数的发布行为、 +默认关闭开关、身份校验、1019 停车和一次 POST 合同保持不变。 + +stdout 为固定 `ibkr.flex_configuration_preflight.v1`,只含 `schema_version`、合法的 +workflow `source_sha`(缺失或不合格时为 null)、总 `status` 与按八个固定配置名列出的 +`fields`。每项状态仅为 `configuration_shape_valid`、`missing` 或 `invalid`;没有配置值、 +账户标识、令牌、收益、数量或异常正文。所有既有配置谓词通过时退出 0;缺项、坏形状或 +原验证器未能完成时退出 2。未识别参数也退出 2,不打印参数内容或进入发布路径。 + +形状合格不表示 Flex 凭据有效、Query 真正覆盖目标、两端同步令牌相同、受保护绑定已经 +独立批准、运行实例已经加载该配置或看板有真实期间收益。preflight 不接触券商,无法验证 +这些事实,也不授权开启发布变量、采集或写入看板。固定字段状态只解释当前进程可检查的 +配置形状;意外的原验证器失败仍保持总 `invalid`,不把已通过的字段形状当作整体通过。 + +`.github/workflows/check-ibkr-flex-configuration.yml` 提供仅本仓 main 的手动入口,只有 +`contents: read`,复用原来的 uv action 和 frozen runtime lock;只向 preflight 步骤注入 +与既有发布 workflow 相同的八个 Secrets,以及实际 workflow source SHA。不注入开关、 +新的令牌别名、GCP/WIF/Gateway 或 Cloudflare 凭据,不创建调度、不发通知,不上传私有 +配置 artifact。Secret 名称元数据读取失败时可以由这个已授权入口检查现有注入配置的 +形状,但它不调用 Secret 管理 API 取值、不增加 GitHub 权限,也不证明实际凭据有效。 diff --git a/scripts/publish_ibkr_flex_period_return.py b/scripts/publish_ibkr_flex_period_return.py index 1d13734d..f8d98ea2 100644 --- a/scripts/publish_ibkr_flex_period_return.py +++ b/scripts/publish_ibkr_flex_period_return.py @@ -5,7 +5,8 @@ import json import os import re -from collections.abc import Mapping +import sys +from collections.abc import Mapping, Sequence from datetime import datetime, timezone from typing import Any from urllib.error import HTTPError @@ -15,6 +16,10 @@ from application.ibkr_flex_source import FlexReportPending, import_activity_flex_ledger from application.ibkr_period_return import ( + _ACCOUNT as _PERIOD_ACCOUNT, + _BINDING as _PERIOD_BINDING, + _SCOPE as _PERIOD_SCOPE, + _TARGET as _PERIOD_TARGET, PeriodReturnError, build_ibkr_period_return, validate_period_return_scope, ) from scripts.publish_account_facts_from_report import _NoRedirect @@ -28,6 +33,12 @@ "RECONCILIATION_RECOVERY_SYNC_TOKEN", "RECONCILIATION_RECOVERY_CONTROLLER_TOKEN", "CYCLE_HEALTH_PROVISIONER_TOKEN", ) +_PREFLIGHT_FIELDS = ( + "IBKR_PERIOD_RETURN_TARGET_ID", "IBKR_PERIOD_RETURN_SOURCE_BINDING_ID", + "IBKR_PERIOD_RETURN_ACCOUNT_SCOPE", "IBKR_PERIOD_RETURN_ACCOUNT_KEY", + "IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", "IBKR_FLEX_TOKEN", "IBKR_FLEX_QUERY_ID", + "IBKR_ACCOUNT_FACTS_SYNC_TOKEN", +) def _required(env: Mapping[str, str], name: str) -> str: @@ -63,6 +74,66 @@ def _config(env: Mapping[str, str]) -> dict[str, Any]: return config +def _preflight_result(env: Mapping[str, str], *, status: str, fields: Mapping[str, str]) -> dict[str, Any]: + source_sha = env.get("GITHUB_SHA") + return { + "schema_version": "ibkr.flex_configuration_preflight.v1", + "source_sha": source_sha if isinstance(source_sha, str) and re.fullmatch(r"[a-fA-F0-9]{40}", source_sha) else None, + "status": status, "fields": dict(fields), + } + + +def _configuration_preflight(env: Mapping[str, str]) -> dict[str, Any]: + """Inspect local shape only; the existing _config remains authoritative.""" + fields = {} + for name in _PREFLIGHT_FIELDS: + if env.get(name) in (None, ""): + fields[name] = "missing" + continue + try: + _required(env, name) + except PeriodReturnError: + fields[name] = "invalid" + else: + fields[name] = "configuration_shape_valid" + + patterns = { + "IBKR_PERIOD_RETURN_TARGET_ID": _PERIOD_TARGET, + "IBKR_PERIOD_RETURN_SOURCE_BINDING_ID": _PERIOD_BINDING, + "IBKR_PERIOD_RETURN_ACCOUNT_SCOPE": _PERIOD_SCOPE, + "IBKR_PERIOD_RETURN_ACCOUNT_KEY": re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", re.ASCII), + "IBKR_FLEX_QUERY_ID": re.compile(r"[0-9]+", re.ASCII), + } + for name, pattern in patterns.items(): + if fields[name] == "configuration_shape_valid" and not pattern.fullmatch(env[name]): + fields[name] = "invalid" + name = "IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON" + if fields[name] == "configuration_shape_valid": + try: + accounts = json.loads(env[name]) + valid = (isinstance(accounts, list) and len(accounts) == 1 + and isinstance(accounts[0], str) and _PERIOD_ACCOUNT.fullmatch(accounts[0])) + except Exception: + # Oversized integer/deep JSON failures must not emit a traceback. + valid = False + if not valid: + fields[name] = "invalid" + name = "IBKR_ACCOUNT_FACTS_SYNC_TOKEN" + if fields[name] == "configuration_shape_valid" and ( + any(char.isspace() for char in env[name]) + or env[name] == env.get("IBKR_FLEX_TOKEN") + or any(env[name] == env.get(alias) for alias in _TOKEN_ALIASES) + ): + fields[name] = "invalid" + try: + _config(env) + except Exception: + status = "missing" if "missing" in fields.values() else "invalid" + else: + status = "configuration_shape_valid" + return _preflight_result(env, status=status, fields=fields) + + def _unique_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result = {} for key, value in pairs: @@ -101,7 +172,14 @@ def publish_period_return(payload: Mapping[str, Any], *, sync_token: str, expect return "sync_unknown" -def main() -> int: +def main(argv: Sequence[str] = ()) -> int: + if tuple(argv) == ("--preflight",): + result = _configuration_preflight(os.environ) + print(json.dumps(result, sort_keys=True)) + return 0 if result["status"] == "configuration_shape_valid" else 2 + if argv: + print(json.dumps(_preflight_result(os.environ, status="invalid", fields={}), sort_keys=True)) + return 2 if os.environ.get("IBKR_PERIOD_RETURN_PUBLISH_ENABLED") != "true": status = "disabled" else: @@ -136,4 +214,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_publish_ibkr_flex_period_return.py b/tests/test_publish_ibkr_flex_period_return.py index ac4bfd8c..b1932bd9 100644 --- a/tests/test_publish_ibkr_flex_period_return.py +++ b/tests/test_publish_ibkr_flex_period_return.py @@ -237,3 +237,152 @@ def test_unchanged_ack_is_success_without_second_post(monkeypatch, capsys): assert cli.main() == 0 assert capsys.readouterr().out == '{"status": "unchanged"}\n' assert len(opener.calls) == 1 + + +_PREFLIGHT_FIELDS = ( + "IBKR_PERIOD_RETURN_TARGET_ID", "IBKR_PERIOD_RETURN_SOURCE_BINDING_ID", + "IBKR_PERIOD_RETURN_ACCOUNT_SCOPE", "IBKR_PERIOD_RETURN_ACCOUNT_KEY", + "IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", "IBKR_FLEX_TOKEN", "IBKR_FLEX_QUERY_ID", + "IBKR_ACCOUNT_FACTS_SYNC_TOKEN", +) +_PREFLIGHT_SOURCE_SHA = "e2ec696a9ba3ec64156804a701a556ad69d5b64b" + + +def _forbid_preflight_io(monkeypatch): + def forbidden(*_args, **_kwargs): + pytest.fail("configuration-only preflight used an external capability") + for name in ("import_activity_flex_ledger", "publish_period_return", "build_opener", "Request"): + monkeypatch.setattr(cli, name, forbidden) + monkeypatch.setattr(cli.requests, "Session", forbidden) + + +@pytest.mark.parametrize("enabled", [None, "false", "true"]) +def test_preflight_reuses_original_config_without_flex_or_publish_even_when_enabled(monkeypatch, capsys, enabled): + values = configure(monkeypatch) + if enabled is None: + monkeypatch.delenv("IBKR_PERIOD_RETURN_PUBLISH_ENABLED") + else: + monkeypatch.setenv("IBKR_PERIOD_RETURN_PUBLISH_ENABLED", enabled) + monkeypatch.setenv("GITHUB_SHA", _PREFLIGHT_SOURCE_SHA) + _forbid_preflight_io(monkeypatch) + original, calls = cli._config, [] + def checked(env): + calls.append(True) + return original(env) + monkeypatch.setattr(cli, "_config", checked) + assert cli.main(("--preflight",)) == 0 + output = capsys.readouterr() + result = json.loads(output.out) + assert result == { + "schema_version": "ibkr.flex_configuration_preflight.v1", + "source_sha": _PREFLIGHT_SOURCE_SHA, "status": "configuration_shape_valid", + "fields": dict.fromkeys(_PREFLIGHT_FIELDS, "configuration_shape_valid"), + } + assert calls == [True] and output.err == "" + assert not any(value in output.out for key, value in values.items() + if key != "IBKR_PERIOD_RETURN_PUBLISH_ENABLED") + + +@pytest.mark.parametrize("field", _PREFLIGHT_FIELDS) +@pytest.mark.parametrize("missing", [None, ""]) +def test_preflight_identifies_each_missing_field_without_values_or_io(monkeypatch, capsys, field, missing): + configure(monkeypatch) + if missing is None: + monkeypatch.delenv(field) + else: + monkeypatch.setenv(field, missing) + _forbid_preflight_io(monkeypatch) + assert cli.main(("--preflight",)) == 2 + result = json.loads(capsys.readouterr().out) + assert result["status"] == "missing" + assert result["fields"][field] == "missing" + assert set(result["fields"]) == set(_PREFLIGHT_FIELDS) + assert all(status == "configuration_shape_valid" for key, status in result["fields"].items() if key != field) + + +@pytest.mark.parametrize("field,bad", [ + ("IBKR_PERIOD_RETURN_TARGET_ID", "BAD_private_target"), + ("IBKR_PERIOD_RETURN_SOURCE_BINDING_ID", "private-binding"), + ("IBKR_PERIOD_RETURN_ACCOUNT_SCOPE", "private scope"), + ("IBKR_PERIOD_RETURN_ACCOUNT_KEY", "private/key"), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", "private-not-json"), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", "[]"), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", '["DU1234567","DU7654321"]'), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", '["not-account"]'), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", '[1234567]'), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", '"DU1234567"'), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", "[" * 1100 + "]" * 1100), + ("IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON", "[" + "1" * 5000 + "]"), + ("IBKR_FLEX_TOKEN", " private-token"), + ("IBKR_FLEX_TOKEN", "private\ntoken"), + ("IBKR_FLEX_QUERY_ID", "private-query"), + ("IBKR_FLEX_QUERY_ID", "١٢٣"), + ("IBKR_ACCOUNT_FACTS_SYNC_TOKEN", "private token"), + ("IBKR_ACCOUNT_FACTS_SYNC_TOKEN", "private\u00a0token"), +]) +def test_preflight_identifies_exact_invalid_shape_without_echo(monkeypatch, capsys, field, bad): + configure(monkeypatch) + monkeypatch.setenv(field, bad) + _forbid_preflight_io(monkeypatch) + assert cli.main(("--preflight",)) == 2 + output = capsys.readouterr() + result = json.loads(output.out) + assert result["status"] == "invalid" + assert result["fields"][field] == "invalid" + assert all(status == "configuration_shape_valid" for key, status in result["fields"].items() if key != field) + assert bad not in output.out and output.err == "" + + +@pytest.mark.parametrize("alias", ("IBKR_FLEX_TOKEN", *cli._TOKEN_ALIASES)) +def test_preflight_preserves_existing_sync_token_separation(monkeypatch, capsys, alias): + values = configure(monkeypatch) + monkeypatch.setenv(alias, values["IBKR_ACCOUNT_FACTS_SYNC_TOKEN"]) + _forbid_preflight_io(monkeypatch) + assert cli.main(("--preflight",)) == 2 + result = json.loads(capsys.readouterr().out) + assert result["status"] == "invalid" + assert result["fields"]["IBKR_ACCOUNT_FACTS_SYNC_TOKEN"] == "invalid" + assert all(status == "configuration_shape_valid" for key, status in result["fields"].items() + if key != "IBKR_ACCOUNT_FACTS_SYNC_TOKEN") + + +def test_preflight_redacts_invalid_source_sha_and_unknown_cli_arguments(monkeypatch, capsys): + configure(monkeypatch) + monkeypatch.setenv("GITHUB_SHA", "private-revision-value") + _forbid_preflight_io(monkeypatch) + assert cli.main(("--preflight",)) == 0 + output = capsys.readouterr().out + assert json.loads(output)["source_sha"] is None and "private-revision-value" not in output + assert cli.main(("--preflight", "private-cli-value")) == 2 + output = capsys.readouterr().out + assert json.loads(output)["status"] == "invalid" and "private-cli-value" not in output + + +def test_preflight_original_validator_failure_is_fixed_and_never_echoes_exception(monkeypatch, capsys): + configure(monkeypatch) + _forbid_preflight_io(monkeypatch) + def failed(_env): + raise RuntimeError("private-validator-error-body") + monkeypatch.setattr(cli, "_config", failed) + assert cli.main(("--preflight",)) == 2 + output = capsys.readouterr() + assert json.loads(output.out)["status"] == "invalid" + assert "private-validator-error-body" not in output.out and output.err == "" + + +def test_preflight_workflow_is_manual_main_configuration_only_with_exact_original_secrets(): + from pathlib import Path + import re + + workflow = Path(".github/workflows/check-ibkr-flex-configuration.yml").read_text() + assert "workflow_dispatch:" in workflow and "github.ref == 'refs/heads/main'" in workflow + assert "github.repository == 'QuantStrategyLab/InteractiveBrokersPlatform'" in workflow + assert "contents: read" in workflow and "persist-credentials: false" in workflow + assert "uv sync --frozen --no-dev" in workflow + assert "astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78" in workflow + assert workflow.count("python -m scripts.publish_ibkr_flex_period_return --preflight") == 1 + assert set(re.findall(r"secrets\.([A-Z0-9_]+)", workflow)) == set(_PREFLIGHT_FIELDS) + assert "GITHUB_SHA: ${{ github.sha }}" in workflow + for unsafe in ("schedule:", "workflow_run:", "id-token:", "google-github-actions/auth", "gcloud", + "upload-artifact", "TELEGRAM", "CLOUDFLARE", "publish_once", "PUBLISH_ENABLED", "vars."): + assert unsafe not in workflow