#!/usr/bin/env python3 """Bounded control-plane readiness/read-task concurrency smoke test.""" from __future__ import annotations import argparse import concurrent.futures import http.client import json import os import ssl import statistics import sys import time import uuid from urllib.parse import urlsplit def request(base_url: str, path: str, method: str, body: object | None, headers: dict[str, str], timeout: float): url = base_url.rstrip("/") + path parts = urlsplit(url) if parts.scheme.lower() not in {"http", "https"} or not parts.hostname or parts.username or parts.password: raise ValueError("base URL must be an http/https URL without user information") target = parts.path or "/" if parts.query: target += "?" + parts.query data = None if body is None else json.dumps(body, ensure_ascii=False).encode() connection: http.client.HTTPConnection if parts.scheme.lower() == "https": connection = http.client.HTTPSConnection(parts.hostname, parts.port, timeout=timeout, context=ssl.create_default_context()) else: connection = http.client.HTTPConnection(parts.hostname, parts.port, timeout=timeout) started = time.perf_counter() try: connection.request(method, target, body=data, headers={"Accept": "application/json", **headers}) response = connection.getresponse() return response.status, time.perf_counter() - started, response.read() except OSError as error: return 599, time.perf_counter() - started, str(error).encode() finally: connection.close() def percentile(values: list[float], p: float) -> float: if not values: return 0.0 ordered = sorted(values) index = min(len(ordered) - 1, round((len(ordered) - 1) * p)) return ordered[index] def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base-url", default=os.getenv("WXAGENT_CONTROL_PLANE_URL", "http://127.0.0.1:8090")) parser.add_argument("--mode", choices=("ready", "reads"), default="reads") parser.add_argument("--web-user", default=os.getenv("WXAGENT_SCALE_WEB_USER", "admin")) parser.add_argument("--node-id", default=os.getenv("WXAGENT_SCALE_NODE_ID", "")) parser.add_argument("--account-id", default=os.getenv("WXAGENT_SCALE_ACCOUNT_ID", "")) parser.add_argument("--requests", type=int, default=100) parser.add_argument("--workers", type=int, default=10) parser.add_argument("--timeout", type=float, default=15.0) args = parser.parse_args() if args.requests < 1 or args.requests > 5000 or args.workers < 1 or args.workers > 100: parser.error("requests must be 1..5000 and workers must be 1..100") headers: dict[str, str] = {} if args.mode == "reads": password = os.getenv("WXAGENT_SCALE_WEB_PASSWORD") if not password or not args.node_id or not args.account_id: parser.error("reads mode requires WXAGENT_SCALE_WEB_PASSWORD, --node-id and --account-id") status, _, payload = request( args.base_url, "/v1/auth/login", "POST", {"username": args.web_user, "password": password}, {"Content-Type": "application/json"}, args.timeout, ) if status != 200: print(json.dumps({"ok": False, "phase": "login", "status": status, "body": payload[:256].decode(errors="replace")})) return 1 try: login = json.loads(payload) access_token = login["access_token"] except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as error: print(json.dumps({"ok": False, "phase": "login-response", "error": str(error)})) return 1 headers = {"Authorization": "Bearer " + access_token, "Content-Type": "application/json"} def one(index: int) -> tuple[int, float]: if args.mode == "ready": status, elapsed, _ = request(args.base_url, "/readyz", "GET", None, {}, args.timeout) else: body = { "node_id": args.node_id, "account_id": args.account_id, "idempotency_key": f"scale-read-{uuid.uuid4().hex}-{index}", "limit": 1, "offset": 0, } status, elapsed, _ = request(args.base_url, "/v1/reads/sessions", "POST", body, headers, args.timeout) return status, elapsed with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor: results = list(executor.map(one, range(args.requests))) elapsed = [item[1] for item in results] success = sum(200 <= status < 300 for status, _ in results) summary = { "ok": success == args.requests, "mode": args.mode, "requests": args.requests, "workers": args.workers, "success": success, "failed": args.requests - success, "p50_ms": round(statistics.median(elapsed) * 1000, 2) if elapsed else 0, "p95_ms": round(percentile(elapsed, 0.95) * 1000, 2), "statuses": {str(status): sum(item[0] == status for item in results) for status, _ in results}, } print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) return 0 if summary["ok"] else 1 if __name__ == "__main__": sys.exit(main())