test(loadtests): 10 load scenarios + verifier; docs: Sonnet review findings

This commit is contained in:
“Naeel”
2026-08-16 16:59:29 +04:00
parent a4a11bae54
commit 090cedca67
14 changed files with 1024 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
# common.py — общие примитивы нагрузочных тестов IoT.
#
# Конфигурация через env (дефолты — прод-платформа):
# IOT_API — базовый URL API (https://iot.containerk8s.dev.nubes.ru)
# IOT_WS — wss URL EMQX (wss://exqx.containerk8s.dev.nubes.ru/mqtt)
# IOT_NS_PREFIX — префикс namespace для тестов (lt)
# IOT_CLEANUP — удалять созданные устройства после теста (1)
import base64
import json
import os
import ssl
import threading
import time
import urllib.request
from urllib.parse import urlparse
API = os.environ.get("IOT_API", "https://iot.containerk8s.dev.nubes.ru")
WS = os.environ.get("IOT_WS", "wss://exqx.containerk8s.dev.nubes.ru/mqtt")
NS_PREFIX = os.environ.get("IOT_NS_PREFIX", "lt")
CLEANUP = os.environ.get("IOT_CLEANUP", "1") == "1"
_ws = urlparse(WS)
WS_HOST = _ws.hostname or ""
WS_PORT = _ws.port or 443
WS_PATH = _ws.path or "/mqtt"
# Платформа рвёт внешние wss ~150с: паблишер должен уметь реконнектиться.
EDGE_BREAK_SEC = 150
def mint_jwt(sub="loadtest", ttl=86400):
h = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').rstrip(b"=").decode()
p = base64.urlsafe_b64encode(
json.dumps({"sub": sub, "exp": int(time.time()) + ttl}).encode()
).rstrip(b"=").decode()
return h + "." + p + ".sig"
class API:
"""Тонкая обёртка над REST API монолита."""
def __init__(self, base=API):
self.base = base.rstrip("/")
self.token = mint_jwt()
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
def _req(self, method, path, body=None):
url = self.base + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, data=data, method=method,
headers={"Authorization": "Bearer " + self.token,
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30, context=self.ctx) as r:
return json.loads(r.read().decode())
def create_device(self, ns, name, device_id):
return self._req("POST", f"/v1/namespaces/{ns}/iot/devices",
{"name": name, "device_id": device_id})
def get_device(self, ns, name):
return self._req("GET", f"/v1/namespaces/{ns}/iot/devices/{name}")
def list_devices(self, ns):
return self._req("GET", f"/v1/namespaces/{ns}/iot/devices")
def delete_device(self, ns, name):
return self._req("DELETE", f"/v1/namespaces/{ns}/iot/devices/{name}")
def telemetry(self, ns, device_id="", limit=1000):
q = f"?limit={limit}"
if device_id:
q += f"&device_id={device_id}"
return self._req("GET", f"/v1/namespaces/{ns}/iot/telemetry{q}")
class Counter:
"""Потокобезопасные счётчики метрик."""
def __init__(self):
self._l = threading.Lock()
self.v = {}
def inc(self, key, n=1):
with self._l:
self.v[key] = self.v.get(key, 0) + n
def set(self, key, val):
with self._l:
self.v[key] = val
def get(self, key, default=0):
with self._l:
return self.v.get(key, default)
def snapshot(self):
with self._l:
return dict(self.v)
def ws_tls_opts(client):
"""wss с самоподписанным/платформенным сертификатом — без проверки."""
client.tls_set(cert_reqs=ssl.CERT_NONE)
client.tls_insecure_set(True)
def percentile(values, q):
if not values:
return 0.0
s = sorted(values)
i = min(len(s) - 1, int(round(q * (len(s) - 1))))
return s[i]
def latency_stats(ms_list):
return {
"n": len(ms_list),
"p50_ms": round(percentile(ms_list, 0.50), 1),
"p95_ms": round(percentile(ms_list, 0.95), 1),
"p99_ms": round(percentile(ms_list, 0.99), 1),
"max_ms": round(max(ms_list), 1) if ms_list else 0.0,
}
def now_ms():
return int(time.time() * 1000)