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
37 changes: 37 additions & 0 deletions .github/workflows/check-ibkr-flex-configuration.yml
Original file line number Diff line number Diff line change
@@ -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 }}
26 changes: 26 additions & 0 deletions docs/ibkr_flex_ledger.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 权限,也不证明实际凭据有效。
84 changes: 81 additions & 3 deletions scripts/publish_ibkr_flex_period_return.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -136,4 +214,4 @@ def main() -> int:


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main(sys.argv[1:]))
149 changes: 149 additions & 0 deletions tests/test_publish_ibkr_flex_period_return.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading