183 lines
5.9 KiB
Python
Executable File
183 lines
5.9 KiB
Python
Executable File
#!/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())
|