Skip to content

Commit b53b0bd

Browse files
Pigbibicodex
andauthored
Guard explicit IBKR target creation (#193)
* fix: guard IBKR target creation Co-Authored-By: Codex <noreply@openai.com> * fix: read environment-scoped service targets Co-Authored-By: Codex <noreply@openai.com> * test: cover first IBKR target provisioning Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent b8a5d0b commit b53b0bd

4 files changed

Lines changed: 89 additions & 14 deletions

File tree

.github/workflows/manual-strategy-switch.yml

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,13 @@ on:
102102
- enabled
103103
- disabled
104104
service_targets_mode:
105-
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
105+
description: "auto updates an existing IBKR target; allow_create explicitly permits a new target."
106106
required: true
107107
type: choice
108108
default: auto
109109
options:
110110
- auto
111+
- allow_create
111112
- off
112113
apply:
113114
description: "Actually write GitHub variables. false = preview only."
@@ -208,7 +209,7 @@ jobs:
208209
fi
209210
210211
if [ "${PLATFORM}" = "ibkr" ] \
211-
&& [ "${SERVICE_TARGETS_MODE}" = "auto" ] \
212+
&& [ "${SERVICE_TARGETS_MODE}" != "off" ] \
212213
&& [ -z "${GH_TOKEN:-}" ]; then
213214
echo "RUNTIME_SETTINGS_GH_TOKEN is required for IBKR service-target preview because the workflow must read and patch CLOUD_RUN_SERVICE_TARGETS_JSON." >&2
214215
exit 2
@@ -222,20 +223,27 @@ jobs:
222223
echo "repository=${repo}" >> "$GITHUB_OUTPUT"
223224
224225
- name: Fetch existing service targets
225-
if: env.SERVICE_TARGETS_MODE == 'auto' && env.PLATFORM == 'ibkr'
226+
if: env.SERVICE_TARGETS_MODE != 'off' && env.PLATFORM == 'ibkr'
226227
env:
227228
TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }}
228229
run: |
229230
set -euo pipefail
230231
output_file="${RUNNER_TEMP}/existing-service-targets.json"
231-
python - <<'PY' "${TARGET_REPOSITORY}" "${output_file}"
232+
target_environment=""
233+
if [ "${VARIABLE_SCOPE}" = "environment" ]; then
234+
target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}"
235+
fi
236+
python - <<'PY' "${TARGET_REPOSITORY}" "${output_file}" "${target_environment}"
232237
import json
233238
import subprocess
234239
import sys
235240
236-
repo, output_path = sys.argv[1], sys.argv[2]
241+
repo, output_path, environment = sys.argv[1:4]
242+
command = ["gh", "variable", "list", "--repo", repo, "--json", "name,value"]
243+
if environment:
244+
command.extend(["--env", environment])
237245
raw = subprocess.check_output(
238-
["gh", "variable", "list", "--repo", repo, "--json", "name,value"],
246+
command,
239247
text=True,
240248
)
241249
variables = json.loads(raw)
@@ -245,7 +253,8 @@ jobs:
245253
value = str(item.get("value") or "").strip()
246254
break
247255
if not value:
248-
open(output_path, "w", encoding="utf-8").close()
256+
with open(output_path, "w", encoding="utf-8") as handle:
257+
handle.write("{}")
249258
raise SystemExit(0)
250259
try:
251260
payload = json.loads(value)
@@ -305,9 +314,12 @@ jobs:
305314
if [ -n "${INCOME_LAYER_MAX_RATIO:-}" ]; then
306315
args+=(--income-layer-max-ratio "${INCOME_LAYER_MAX_RATIO}")
307316
fi
308-
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
317+
if [ -f "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
309318
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
310319
fi
320+
if [ "${SERVICE_TARGETS_MODE}" = "allow_create" ]; then
321+
args+=(--allow-create-service-target)
322+
fi
311323
python3 python/scripts/build_runtime_switch.py "${args[@]}"
312324
python3 python/scripts/runtime_settings.py validate "${target_file}"
313325
echo "TARGET_FILE=${target_file}" >> "$GITHUB_ENV"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Notes:
8585
- LongBridge defaults to environment-scoped variables; `target_name=sg` resolves to `longbridge-sg`.
8686
- Schwab defaults to repository-scoped variables.
8787
- Firstrade defaults to repository-scoped variables; `target_name=live` uses `firstrade-quant-service` and `account_scope=US`.
88-
- IBKR patches the selected service/account-scope entry inside `CLOUD_RUN_SERVICE_TARGETS_JSON` when that variable exists, so other IBKR services are preserved.
88+
- IBKR `service_targets_mode=auto` only patches an existing service/account-scope entry inside `CLOUD_RUN_SERVICE_TARGETS_JSON`, so other services are preserved and an unknown target fails closed. Use `allow_create` only when intentionally provisioning a new target.
8989
- Cross-repository variable writes and workflow dispatches require a `RUNTIME_SETTINGS_GH_TOKEN` secret in this repository with sufficient target-repository variable/workflow permissions. The workflow does not fall back to the default `github.token` for remote writes.
9090
- IBKR `service_targets_mode=auto` must read and patch the target repository's `CLOUD_RUN_SERVICE_TARGETS_JSON`, so even preview mode requires `RUNTIME_SETTINGS_GH_TOKEN` for IBKR.
9191
- The workflow is bound to the `runtime-strategy-switch` GitHub Environment. For a personal system, required reviewers are optional; prefer storing `RUNTIME_SETTINGS_GH_TOKEN` as an Environment secret and rely on preview, confirmation text, and a least-privilege token for day-to-day safety.

python/scripts/build_runtime_switch.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,7 @@ def _patch_service_targets(
802802
mounts_variable: str,
803803
mounts: list[dict[str, Any]],
804804
extra_variables: dict[str, Any],
805+
allow_create: bool,
805806
) -> dict[str, Any]:
806807
payload = dict(current_payload)
807808
raw_entries = payload.get("targets") if isinstance(payload.get("targets"), list) else []
@@ -836,6 +837,11 @@ def _patch_service_targets(
836837
replaced = True
837838
break
838839

840+
if not replaced and not allow_create:
841+
raise ValueError(
842+
"existing IBKR service target was not found; "
843+
"use --allow-create-service-target to append a new target"
844+
)
839845
if not replaced:
840846
entries.append(replacement)
841847
payload["targets"] = entries
@@ -886,20 +892,21 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
886892
)
887893
)
888894

889-
service_targets = _load_json_from_file(
890-
args.existing_service_targets_json_file,
891-
field_name="existing_service_targets_json_file",
892-
)
893895
top_level_mounts = mounts
894896
plugin_mounts_variable: str | None = mounts_variable
895-
if service_targets:
897+
if args.existing_service_targets_json_file:
898+
service_targets = _load_json_from_file(
899+
args.existing_service_targets_json_file,
900+
field_name="existing_service_targets_json_file",
901+
)
896902
patched_service_targets = _patch_service_targets(
897903
current_payload=service_targets,
898904
platform=platform,
899905
runtime_target=runtime_target,
900906
mounts_variable=mounts_variable,
901907
mounts=mounts,
902908
extra_variables=extra_variables,
909+
allow_create=args.allow_create_service_target,
903910
)
904911
extra_variables = {"CLOUD_RUN_SERVICE_TARGETS_JSON": patched_service_targets}
905912
top_level_mounts = []
@@ -957,6 +964,7 @@ def build_parser() -> argparse.ArgumentParser:
957964
parser.add_argument("--dca-mode", default="")
958965
parser.add_argument("--dca-base-investment-usd", default="")
959966
parser.add_argument("--existing-service-targets-json-file", default="")
967+
parser.add_argument("--allow-create-service-target", action="store_true")
960968
parser.add_argument("--no-platform-dry-run-variable", dest="set_platform_dry_run_variable", action="store_false")
961969
parser.set_defaults(set_platform_dry_run_variable=True)
962970
parser.add_argument("--output", default="-", help="output path, or '-' for stdout")

python/tests/test_runtime_settings.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ def test_manual_switch_platform_choices_cover_supported_platforms(self):
9292

9393
self.assertEqual(set(platform_choices), set(runtime_settings.SUPPORTED_PLATFORMS))
9494

95+
def test_manual_switch_reads_ibkr_targets_from_selected_environment_scope(self):
96+
workflow = (ROOT / ".github/workflows/manual-strategy-switch.yml").read_text(encoding="utf-8")
97+
98+
assert 'if [ "${VARIABLE_SCOPE}" = "environment" ]; then' in workflow
99+
assert 'target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}"' in workflow
100+
assert 'command.extend(["--env", environment])' in workflow
101+
assert 'handle.write("{}")' in workflow
102+
95103
def test_live_candidate_queue_lists_profiles_needing_promotion_review(self):
96104
catalog = [
97105
{
@@ -1658,6 +1666,53 @@ def test_build_switch_target_patches_ibkr_service_targets_with_soxl_plugin_mount
16581666
"soxl_soxx_trend_income/plugins/market_regime_control/latest_signal.json",
16591667
)
16601668

1669+
def test_build_switch_target_rejects_unknown_ibkr_service_target_by_default(self):
1670+
path = ROOT / ".pytest_runtime_service_targets_unknown.json"
1671+
path.write_text('{"targets":[]}', encoding="utf-8")
1672+
self.addCleanup(lambda: path.unlink(missing_ok=True))
1673+
parser = build_runtime_switch.build_parser()
1674+
args = parser.parse_args(
1675+
[
1676+
"--platform",
1677+
"ibkr",
1678+
"--target-name",
1679+
"new-account",
1680+
"--strategy-profile",
1681+
"tqqq_growth_income",
1682+
"--existing-service-targets-json-file",
1683+
str(path),
1684+
]
1685+
)
1686+
1687+
with self.assertRaisesRegex(ValueError, "existing IBKR service target was not found"):
1688+
build_runtime_switch.build_switch_target(args)
1689+
1690+
def test_build_switch_target_can_explicitly_append_ibkr_service_target(self):
1691+
path = ROOT / ".pytest_runtime_service_targets_create.json"
1692+
path.write_text("{}", encoding="utf-8")
1693+
self.addCleanup(lambda: path.unlink(missing_ok=True))
1694+
parser = build_runtime_switch.build_parser()
1695+
args = parser.parse_args(
1696+
[
1697+
"--platform",
1698+
"ibkr",
1699+
"--target-name",
1700+
"new-account",
1701+
"--strategy-profile",
1702+
"tqqq_growth_income",
1703+
"--existing-service-targets-json-file",
1704+
str(path),
1705+
"--allow-create-service-target",
1706+
]
1707+
)
1708+
1709+
target = build_runtime_switch.build_switch_target(args)
1710+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
1711+
patched = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"])
1712+
1713+
self.assertEqual(len(patched["targets"]), 1)
1714+
self.assertEqual(patched["targets"][0]["runtime_target"]["account_scope"], "new-account")
1715+
16611716

16621717
if __name__ == "__main__":
16631718
unittest.main()

0 commit comments

Comments
 (0)