331 lines
11 KiB
Python
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())
|