Files
ai-exchange/scripts/run_remote_ops_smoke.py
T
2026-05-12 09:18:12 +08:00

331 lines
11 KiB
Python

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())