Skip to content

Commit d125d38

Browse files
Pigbibicodex
andcommitted
fix: preserve service-specific plugin mounts
Co-Authored-By: Codex <noreply@openai.com>
1 parent 5bf39e3 commit d125d38

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,25 @@ jobs:
361361
target_environment="longbridge-${TARGET_NAME}"
362362
fi
363363
output_file="${RUNNER_TEMP}/current-plugin-mounts.json"
364+
if [ -f "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
365+
set +e
366+
python3 python/scripts/extract_service_plugin_mounts.py \
367+
--service-targets-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}" \
368+
--mounts-variable "${mounts_variable}" \
369+
--service-name "${SERVICE_NAME:-}" \
370+
--target-name "${TARGET_NAME}" \
371+
--output "${output_file}"
372+
extraction_status=$?
373+
set -e
374+
if [ "${extraction_status}" -eq 0 ]; then
375+
echo "CURRENT_PLUGIN_MOUNTS_JSON_FILE=${output_file}" >> "$GITHUB_ENV"
376+
exit 0
377+
fi
378+
if [ "${extraction_status}" -ne 3 ]; then
379+
echo "Unable to safely preserve service-specific ${mounts_variable}." >&2
380+
exit "${extraction_status}"
381+
fi
382+
fi
364383
python - <<'PY' "${TARGET_REPOSITORY}" "${mounts_variable}" "${target_environment}" "${output_file}"
365384
import json
366385
import subprocess
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""Extract one service target's existing plugin mounts without broadening them.
3+
4+
Cloud Run inventories can hold a distinct plugin-mount object for each service.
5+
This utility is used by the central switch workflow when ``plugin_mode=current``:
6+
it reads only the selected existing service target and emits its exact current
7+
mount document. It never resolves, adds, or rewrites a plugin.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import json
14+
from pathlib import Path
15+
from typing import Any
16+
17+
18+
class TargetNotFoundError(ValueError):
19+
"""Raised when the requested incumbent cannot be identified safely."""
20+
21+
22+
def _entry_service_name(entry: dict[str, Any]) -> str:
23+
for field in ("service", "service_name", "cloud_run_service"):
24+
value = entry.get(field)
25+
if isinstance(value, str) and value.strip():
26+
return value.strip()
27+
runtime_target = entry.get("runtime_target")
28+
if isinstance(runtime_target, dict):
29+
value = runtime_target.get("service_name")
30+
if isinstance(value, str) and value.strip():
31+
return value.strip()
32+
return ""
33+
34+
35+
def _entry_target_name(entry: dict[str, Any]) -> str:
36+
runtime_target = entry.get("runtime_target")
37+
if not isinstance(runtime_target, dict):
38+
return ""
39+
for field in ("deployment_selector", "account_scope"):
40+
value = runtime_target.get(field)
41+
if isinstance(value, str) and value.strip():
42+
return value.strip()
43+
return ""
44+
45+
46+
def _target_entries(payload: object) -> list[dict[str, Any]]:
47+
raw_entries = payload.get("targets") if isinstance(payload, dict) else payload
48+
if not isinstance(raw_entries, list) or any(not isinstance(item, dict) for item in raw_entries):
49+
raise ValueError("service targets must be an array of objects")
50+
return [dict(item) for item in raw_entries]
51+
52+
53+
def _select_entry(
54+
entries: list[dict[str, Any]],
55+
*,
56+
service_name: str,
57+
target_name: str,
58+
) -> dict[str, Any]:
59+
if service_name:
60+
matches = [entry for entry in entries if _entry_service_name(entry) == service_name]
61+
else:
62+
matches = [entry for entry in entries if _entry_target_name(entry) == target_name]
63+
if not matches:
64+
description = f"service {service_name!r}" if service_name else f"target {target_name!r}"
65+
raise TargetNotFoundError(f"existing {description} was not found")
66+
if len(matches) != 1:
67+
description = f"service {service_name!r}" if service_name else f"target {target_name!r}"
68+
raise ValueError(f"existing {description} is ambiguous")
69+
return matches[0]
70+
71+
72+
def _mount_document(entry: dict[str, Any], mounts_variable: str) -> dict[str, Any]:
73+
nested_env = entry.get("env")
74+
nested_value = nested_env.get(mounts_variable) if isinstance(nested_env, dict) else None
75+
top_level_value = entry.get(mounts_variable)
76+
if nested_value is not None and top_level_value is not None and nested_value != top_level_value:
77+
raise ValueError(f"{mounts_variable} has conflicting top-level and env values")
78+
value = nested_value if nested_value is not None else top_level_value
79+
if value is None:
80+
return {"strategy_plugins": []}
81+
if isinstance(value, str):
82+
try:
83+
value = json.loads(value)
84+
except json.JSONDecodeError as exc:
85+
raise ValueError(f"{mounts_variable} must contain valid JSON") from exc
86+
if not isinstance(value, dict):
87+
raise ValueError(f"{mounts_variable} must be an object")
88+
mounts = value.get("strategy_plugins")
89+
if not isinstance(mounts, list) or any(not isinstance(item, dict) for item in mounts):
90+
raise ValueError(f"{mounts_variable}.strategy_plugins must be an array of objects")
91+
return {"strategy_plugins": [dict(item) for item in mounts]}
92+
93+
94+
def extract_service_plugin_mounts(
95+
payload: object,
96+
*,
97+
mounts_variable: str,
98+
service_name: str = "",
99+
target_name: str = "",
100+
) -> dict[str, Any]:
101+
"""Return the exact current mount document for one unambiguous service."""
102+
103+
mounts_variable = mounts_variable.strip()
104+
service_name = service_name.strip()
105+
target_name = target_name.strip()
106+
if not mounts_variable:
107+
raise ValueError("mounts_variable is required")
108+
if not service_name and not target_name:
109+
raise ValueError("service_name or target_name is required")
110+
entry = _select_entry(
111+
_target_entries(payload),
112+
service_name=service_name,
113+
target_name=target_name,
114+
)
115+
return _mount_document(entry, mounts_variable)
116+
117+
118+
def build_parser() -> argparse.ArgumentParser:
119+
parser = argparse.ArgumentParser(description=__doc__)
120+
parser.add_argument("--service-targets-file", required=True, type=Path)
121+
parser.add_argument("--mounts-variable", required=True)
122+
parser.add_argument("--service-name", default="")
123+
parser.add_argument("--target-name", default="")
124+
parser.add_argument("--output", required=True, type=Path)
125+
return parser
126+
127+
128+
def main(argv: list[str] | None = None) -> int:
129+
args = build_parser().parse_args(argv)
130+
try:
131+
payload = json.loads(args.service_targets_file.read_text(encoding="utf-8"))
132+
mounts = extract_service_plugin_mounts(
133+
payload,
134+
mounts_variable=args.mounts_variable,
135+
service_name=args.service_name,
136+
target_name=args.target_name,
137+
)
138+
except TargetNotFoundError as exc:
139+
print(f"error: {exc}")
140+
return 3
141+
except (OSError, ValueError, json.JSONDecodeError) as exc:
142+
print(f"error: {exc}")
143+
return 2
144+
args.output.write_text(json.dumps(mounts, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
145+
return 0
146+
147+
148+
if __name__ == "__main__":
149+
raise SystemExit(main())

python/tests/test_runtime_settings.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@
4242
sys.modules[BUILD_CONFIG_SPEC.name] = build_config
4343
BUILD_CONFIG_SPEC.loader.exec_module(build_config)
4444

45+
SERVICE_PLUGIN_MOUNTS_MODULE_PATH = ROOT / "python" / "scripts" / "extract_service_plugin_mounts.py"
46+
SERVICE_PLUGIN_MOUNTS_SPEC = importlib.util.spec_from_file_location(
47+
"extract_service_plugin_mounts",
48+
SERVICE_PLUGIN_MOUNTS_MODULE_PATH,
49+
)
50+
service_plugin_mounts = importlib.util.module_from_spec(SERVICE_PLUGIN_MOUNTS_SPEC)
51+
assert SERVICE_PLUGIN_MOUNTS_SPEC.loader is not None
52+
sys.modules[SERVICE_PLUGIN_MOUNTS_SPEC.name] = service_plugin_mounts
53+
SERVICE_PLUGIN_MOUNTS_SPEC.loader.exec_module(service_plugin_mounts)
54+
4555

4656
class RuntimeSettingsTest(unittest.TestCase):
4757
NOT_EVIDENCED_PROFILES = (
@@ -97,6 +107,88 @@ def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self)
97107
self.assertNotIn("income_threshold_usd", input_names)
98108
self.assertNotIn("qqqi_income_ratio", input_names)
99109

110+
def test_service_plugin_mounts_preserve_selected_nested_configuration(self):
111+
payload = {
112+
"targets": [
113+
{
114+
"runtime_target": {
115+
"deployment_selector": "legacy-tqqq",
116+
"service_name": "ibkr-tqqq-service",
117+
},
118+
"env": {
119+
"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {
120+
"strategy_plugins": [
121+
{
122+
"strategy": "tqqq_growth_income",
123+
"plugin": "market_regime_control",
124+
"enabled": True,
125+
}
126+
]
127+
}
128+
},
129+
},
130+
{
131+
"runtime_target": {
132+
"deployment_selector": "legacy-other",
133+
"service_name": "ibkr-other-service",
134+
},
135+
"env": {"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {"strategy_plugins": []}},
136+
},
137+
]
138+
}
139+
140+
mounts = service_plugin_mounts.extract_service_plugin_mounts(
141+
payload,
142+
mounts_variable="IBKR_STRATEGY_PLUGIN_MOUNTS_JSON",
143+
service_name="ibkr-tqqq-service",
144+
target_name="legacy-tqqq",
145+
)
146+
147+
self.assertEqual(mounts["strategy_plugins"][0]["plugin"], "market_regime_control")
148+
self.assertTrue(mounts["strategy_plugins"][0]["enabled"])
149+
150+
def test_service_plugin_mounts_fail_closed_on_ambiguous_or_conflicting_source(self):
151+
ambiguous = {
152+
"targets": [
153+
{"service": "ibkr-duplicate", "env": {"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {"strategy_plugins": []}}},
154+
{"service": "ibkr-duplicate", "env": {"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {"strategy_plugins": []}}},
155+
]
156+
}
157+
with self.assertRaisesRegex(ValueError, "ambiguous"):
158+
service_plugin_mounts.extract_service_plugin_mounts(
159+
ambiguous,
160+
mounts_variable="IBKR_STRATEGY_PLUGIN_MOUNTS_JSON",
161+
service_name="ibkr-duplicate",
162+
)
163+
164+
conflicting = {
165+
"targets": [
166+
{
167+
"service": "ibkr-conflicting",
168+
"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {"strategy_plugins": []},
169+
"env": {
170+
"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {
171+
"strategy_plugins": [{"strategy": "tqqq_growth_income"}]
172+
}
173+
},
174+
}
175+
]
176+
}
177+
with self.assertRaisesRegex(ValueError, "conflicting"):
178+
service_plugin_mounts.extract_service_plugin_mounts(
179+
conflicting,
180+
mounts_variable="IBKR_STRATEGY_PLUGIN_MOUNTS_JSON",
181+
service_name="ibkr-conflicting",
182+
)
183+
184+
def test_manual_switch_preserves_service_specific_plugins_before_repo_fallback(self):
185+
workflow = (ROOT / ".github/workflows/manual-strategy-switch.yml").read_text(encoding="utf-8")
186+
187+
self.assertIn("extract_service_plugin_mounts.py", workflow)
188+
self.assertIn("--service-targets-file \"${EXISTING_SERVICE_TARGETS_JSON_FILE}\"", workflow)
189+
self.assertIn("Unable to safely preserve service-specific", workflow)
190+
self.assertIn("CURRENT_PLUGIN_MOUNTS_JSON_FILE=${output_file}", workflow)
191+
100192
def test_platform_health_monitor_workflow_creates_codex_ready_issue(self):
101193
workflow = (ROOT / ".github/workflows/platform-health-monitor.yml").read_text(encoding="utf-8")
102194

0 commit comments

Comments
 (0)