Add P24 remote ops smoke
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user