From da3c456b951b4351ddf24b3e02d157c4305568ca Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 12 May 2026 09:18:12 +0800 Subject: [PATCH] Add P24 remote ops smoke --- scripts/deploy_with_password.py | 5 + scripts/run_remote_ops_smoke.py | 330 ++++++++++++++++++++++++ tests/unit/test_deploy_with_password.py | 7 + tests/unit/test_run_remote_ops_smoke.py | 97 +++++++ 4 files changed, 439 insertions(+) create mode 100644 scripts/run_remote_ops_smoke.py create mode 100644 tests/unit/test_run_remote_ops_smoke.py diff --git a/scripts/deploy_with_password.py b/scripts/deploy_with_password.py index 96a1a67..d42d738 100644 --- a/scripts/deploy_with_password.py +++ b/scripts/deploy_with_password.py @@ -136,6 +136,11 @@ def run_http_smoke(base_url: str, *, attempts: int = 10, sleep_seconds: float = "/trading/research-council/optimization-cycles?instrument_id=1&limit=1", "/trading/research-council/optimization-review?instrument_id=1", "/trading/review-packet?instrument_id=1", + "/trading/alerts/quality-summary?instrument_id=1", + "/trading/alerts/operator-digest?instrument_id=1", + "/trading/runtime-incidents?limit=20&refresh=0", + "/trading/maintenance-summary?instrument_id=1", + "/trading/unattended-harness?instrument_id=1", ] results = [] for endpoint in endpoints: diff --git a/scripts/run_remote_ops_smoke.py b/scripts/run_remote_ops_smoke.py new file mode 100644 index 0000000..75479a4 --- /dev/null +++ b/scripts/run_remote_ops_smoke.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Callable, Optional +import argparse +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request + + +DEFAULT_BASE_URL = "http://192.168.3.90:8000" +DEFAULT_TIMEOUT_SECONDS = 20.0 +DEFAULT_ATTEMPTS = 2 +DEFAULT_SLEEP_SECONDS = 2.0 + + +ENDPOINTS = ( + { + "name": "health", + "method": "GET", + "path": "/health", + "expected_statuses": (200,), + }, + { + "name": "runtime_status", + "method": "GET", + "path": "/trading/runtime-status", + "expected_statuses": (200,), + }, + { + "name": "review_packet", + "method": "GET", + "path": "/trading/review-packet?instrument_id={instrument_id}", + "expected_statuses": (200,), + }, + { + "name": "alert_quality", + "method": "GET", + "path": "/trading/alerts/quality-summary?instrument_id={instrument_id}", + "expected_statuses": (200,), + }, + { + "name": "operator_digest", + "method": "GET", + "path": "/trading/alerts/operator-digest?instrument_id={instrument_id}", + "expected_statuses": (200,), + }, + { + "name": "runtime_incidents", + "method": "GET", + "path": "/trading/runtime-incidents?limit=20&refresh=0", + "expected_statuses": (200,), + }, + { + "name": "maintenance_summary", + "method": "GET", + "path": "/trading/maintenance-summary?instrument_id={instrument_id}", + "expected_statuses": (200,), + }, + { + "name": "visual_watch_cleanup_dry_run", + "method": "POST", + "path": "/trading/visual-watch/cleanup-captures", + "expected_statuses": (200,), + "body": { + "dry_run": True, + "retention_hours": 168, + "max_delete_files": 0, + }, + }, + { + "name": "unattended_harness", + "method": "GET", + "path": "/trading/unattended-harness?instrument_id={instrument_id}", + "expected_statuses": (200,), + "payload_status_must_pass": True, + }, +) + + +def run_remote_ops_smoke( + *, + base_url: str = DEFAULT_BASE_URL, + instrument_id: int = 1, + attempts: int = DEFAULT_ATTEMPTS, + sleep_seconds: float = DEFAULT_SLEEP_SECONDS, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable[[urllib.request.Request, float], object]] = None, + sleeper: Optional[Callable[[float], None]] = None, +) -> dict: + opener = opener or _urlopen + sleeper = sleeper or time.sleep + endpoint_results = [] + blockers = [] + warnings = [] + passed_checks = [] + + for endpoint in ENDPOINTS: + result = _call_endpoint( + endpoint=endpoint, + base_url=base_url, + instrument_id=instrument_id, + attempts=max(attempts, 1), + sleep_seconds=max(sleep_seconds, 0.0), + timeout_seconds=max(timeout_seconds, 0.1), + opener=opener, + sleeper=sleeper, + ) + endpoint_results.append(result) + if result["status"] == "passed": + passed_checks.append(result["name"]) + elif result["status"] == "warning": + warnings.append(result["name"]) + else: + blockers.append(result["name"]) + + return { + "status": "blocked" if blockers else ("warning" if warnings else "passed"), + "mode": "remote_ops_smoke", + "generated_ts": datetime.now(timezone.utc).isoformat(), + "base_url": base_url.rstrip("/"), + "instrument_id": instrument_id, + "external_channels_used": False, + "real_trade_required": False, + "endpoints": endpoint_results, + "passed_checks": passed_checks, + "warnings": warnings, + "blockers": blockers, + } + + +def _call_endpoint( + *, + endpoint: dict, + base_url: str, + instrument_id: int, + attempts: int, + sleep_seconds: float, + timeout_seconds: float, + opener, + sleeper, +) -> dict: + name = endpoint["name"] + method = endpoint["method"] + path = endpoint["path"].format(instrument_id=urllib.parse.quote(str(instrument_id))) + url = base_url.rstrip("/") + path + body = endpoint.get("body") + request = _build_request(url=url, method=method, body=body) + expected_statuses = tuple(endpoint.get("expected_statuses") or (200,)) + last_error = None + + for attempt in range(1, attempts + 1): + try: + with opener(request, timeout_seconds) as response: + raw_body = response.read() + payload = _decode_payload(raw_body) + status_code = int(getattr(response, "status", 0) or 0) + return _classify_response( + name=name, + method=method, + path=path, + status_code=status_code, + expected_statuses=expected_statuses, + body_size=len(raw_body), + payload=payload, + payload_status_must_pass=bool(endpoint.get("payload_status_must_pass")), + attempt=attempt, + ) + except urllib.error.HTTPError as exc: + raw_body = exc.read() + payload = _decode_payload(raw_body) + status_code = int(exc.code) + return _classify_response( + name=name, + method=method, + path=path, + status_code=status_code, + expected_statuses=expected_statuses, + body_size=len(raw_body), + payload=payload, + payload_status_must_pass=bool(endpoint.get("payload_status_must_pass")), + attempt=attempt, + ) + except Exception as exc: + last_error = exc + if attempt < attempts: + sleeper(sleep_seconds) + + return { + "name": name, + "method": method, + "path": path, + "status": "blocked", + "blocker_type": "environment", + "reason": "endpoint_unreachable", + "attempts": attempts, + "error": str(last_error), + } + + +def _build_request(*, url: str, method: str, body) -> urllib.request.Request: + headers = {"Accept": "application/json"} + data = None + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + return urllib.request.Request(url, data=data, headers=headers, method=method) + + +def _classify_response( + *, + name: str, + method: str, + path: str, + status_code: int, + expected_statuses: tuple, + body_size: int, + payload, + payload_status_must_pass: bool, + attempt: int, +) -> dict: + result = { + "name": name, + "method": method, + "path": path, + "status_code": status_code, + "body_size": body_size, + "attempt": attempt, + } + if status_code not in expected_statuses: + result.update( + { + "status": "blocked", + "reason": "unexpected_http_status", + "expected_statuses": list(expected_statuses), + "payload_excerpt": _payload_excerpt(payload), + } + ) + return result + + if payload_status_must_pass and isinstance(payload, dict) and payload.get("status") != "passed": + blocker_type = "environment" if _payload_has_environment_blocker(payload) else "remote_runtime" + result.update( + { + "status": "blocked", + "blocker_type": blocker_type, + "reason": "payload_status_not_passed", + "payload_status": payload.get("status"), + "payload_blockers": list(payload.get("blockers") or []), + "payload_warnings": list(payload.get("warnings") or []), + } + ) + return result + + result["status"] = "passed" + return result + + +def _decode_payload(raw_body: bytes): + if not raw_body: + return None + try: + return json.loads(raw_body.decode("utf-8")) + except Exception: + return raw_body[:256].decode("utf-8", errors="replace") + + +def _payload_excerpt(payload): + if isinstance(payload, dict): + keys = sorted(payload.keys())[:8] + return {key: payload.get(key) for key in keys} + if isinstance(payload, str): + return payload[:256] + return payload + + +def _payload_has_environment_blocker(payload: dict) -> bool: + for check in payload.get("checks") or []: + if isinstance(check, dict) and check.get("status") == "blocked" and check.get("blocker_type") == "environment": + return True + return False + + +def _urlopen(request: urllib.request.Request, timeout_seconds: float): + return urllib.request.urlopen(request, timeout=timeout_seconds) + + +def _get_bool(env, key: str, default: bool) -> bool: + value = env.get(key) + if value is None or value == "": + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _parse_args(argv: Optional[list[str]], env) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run P24 remote ops smoke against ai-exchange API.") + parser.add_argument("--base-url", default=env.get("REMOTE_OPS_SMOKE_BASE_URL", DEFAULT_BASE_URL)) + parser.add_argument("--instrument-id", type=int, default=int(env.get("REMOTE_OPS_SMOKE_INSTRUMENT_ID", "1"))) + parser.add_argument("--attempts", type=int, default=int(env.get("REMOTE_OPS_SMOKE_ATTEMPTS", str(DEFAULT_ATTEMPTS)))) + parser.add_argument("--sleep-seconds", type=float, default=float(env.get("REMOTE_OPS_SMOKE_SLEEP_SECONDS", str(DEFAULT_SLEEP_SECONDS)))) + parser.add_argument("--timeout-seconds", type=float, default=float(env.get("REMOTE_OPS_SMOKE_TIMEOUT_SECONDS", str(DEFAULT_TIMEOUT_SECONDS)))) + return parser.parse_args(argv) + + +def main(argv: Optional[list[str]] = None, env=None) -> int: + env = os.environ if env is None else env + args = _parse_args(argv, env) + payload = run_remote_ops_smoke( + base_url=args.base_url, + instrument_id=args.instrument_id, + attempts=args.attempts, + sleep_seconds=args.sleep_seconds, + timeout_seconds=args.timeout_seconds, + ) + if _get_bool(env, "REMOTE_OPS_SMOKE_OUTPUT_JSON", False): + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + print(f"Remote ops smoke status: {payload['status']}") + print(f"Base URL: {payload['base_url']}") + print(f"Passed checks: {', '.join(payload['passed_checks']) or 'none'}") + print(f"Warnings: {', '.join(payload['warnings']) or 'none'}") + print(f"Blockers: {', '.join(payload['blockers']) or 'none'}") + return 0 if payload["status"] == "passed" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_deploy_with_password.py b/tests/unit/test_deploy_with_password.py index bfc115a..e4ea0af 100644 --- a/tests/unit/test_deploy_with_password.py +++ b/tests/unit/test_deploy_with_password.py @@ -28,6 +28,13 @@ class DeployWithPasswordTests(unittest.TestCase): self.assertIn("./.claude:/app/.claude", compose) + def test_deploy_smoke_includes_p24_execution_layer_endpoints(self) -> None: + smoke_source = Path("scripts/deploy_with_password.py").read_text(encoding="utf-8") + + self.assertIn("/trading/alerts/operator-digest?instrument_id=1", smoke_source) + self.assertIn("/trading/maintenance-summary?instrument_id=1", smoke_source) + self.assertIn("/trading/unattended-harness?instrument_id=1", smoke_source) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_run_remote_ops_smoke.py b/tests/unit/test_run_remote_ops_smoke.py new file mode 100644 index 0000000..ea701a6 --- /dev/null +++ b/tests/unit/test_run_remote_ops_smoke.py @@ -0,0 +1,97 @@ +import json +import unittest +import urllib.error + +from scripts.run_remote_ops_smoke import ENDPOINTS, run_remote_ops_smoke + + +class FakeResponse: + def __init__(self, status, payload): + self.status = status + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + def close(self): + return None + + +class RemoteOpsSmokeTests(unittest.TestCase): + def test_passes_all_required_remote_ops_endpoints(self) -> None: + seen = [] + + def opener(request, timeout_seconds): + seen.append((request.get_method(), request.full_url)) + if request.full_url.endswith("/trading/unattended-harness?instrument_id=1"): + return FakeResponse(200, {"status": "passed", "blockers": [], "checks": []}) + return FakeResponse(200, {"status": "ok"}) + + payload = run_remote_ops_smoke(base_url="http://remote.test", opener=opener, sleeper=lambda _: None) + + self.assertEqual(payload["status"], "passed") + self.assertEqual(len(payload["passed_checks"]), len(ENDPOINTS)) + self.assertIn(("POST", "http://remote.test/trading/visual-watch/cleanup-captures"), seen) + self.assertFalse(payload["external_channels_used"]) + self.assertFalse(payload["real_trade_required"]) + + def test_http_404_blocks_stale_remote(self) -> None: + def opener(request, timeout_seconds): + raise urllib.error.HTTPError( + request.full_url, + 404, + "not found", + hdrs=None, + fp=FakeResponse(404, {"error": "not_found"}), + ) + + payload = run_remote_ops_smoke(base_url="http://remote.test", opener=opener, sleeper=lambda _: None) + + self.assertEqual(payload["status"], "blocked") + self.assertEqual(payload["blockers"][0], "health") + self.assertEqual(payload["endpoints"][0]["reason"], "unexpected_http_status") + + def test_unreachable_endpoint_is_environment_blocker(self) -> None: + def opener(request, timeout_seconds): + raise TimeoutError("timed out") + + payload = run_remote_ops_smoke(base_url="http://remote.test", attempts=1, opener=opener, sleeper=lambda _: None) + + self.assertEqual(payload["status"], "blocked") + self.assertEqual(payload["endpoints"][0]["blocker_type"], "environment") + self.assertEqual(payload["endpoints"][0]["reason"], "endpoint_unreachable") + + def test_harness_environment_blocker_is_preserved(self) -> None: + def opener(request, timeout_seconds): + if request.full_url.endswith("/trading/unattended-harness?instrument_id=1"): + return FakeResponse( + 200, + { + "status": "blocked", + "blockers": ["runtime_environment"], + "checks": [ + { + "name": "runtime_environment", + "status": "blocked", + "blocker_type": "environment", + } + ], + }, + ) + return FakeResponse(200, {"status": "ok"}) + + payload = run_remote_ops_smoke(base_url="http://remote.test", opener=opener, sleeper=lambda _: None) + + self.assertEqual(payload["status"], "blocked") + self.assertEqual(payload["blockers"], ["unattended_harness"]) + self.assertEqual(payload["endpoints"][-1]["blocker_type"], "environment") + + +if __name__ == "__main__": + unittest.main()