GuestOps/deploy/capacity_probe.py
wolf-demon 2e8cf269fb
Some checks are pending
Build and verify web migration / verify (push) Waiting to run
miledtone 17
2026-09-29 20:44:39 +01:00

129 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Run a bounded, read-only capacity probe against an approved GuestOps sandbox."""
from __future__ import annotations
import argparse
import concurrent.futures
import datetime as dt
import http.cookiejar
import json
import os
from pathlib import Path
import re
import statistics
import time
import urllib.error
import urllib.parse
import urllib.request
PATHS = ("/health/ready", "/api/hotel", "/api/conversations/page")
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def percentile(values: list[float], fraction: float) -> float:
ordered = sorted(values)
position = max(0, min(len(ordered) - 1, int(len(ordered) * fraction + 0.999999) - 1))
return ordered[position]
def summarize(results: list[tuple[bool, float]], concurrency: int) -> dict[str, object]:
require(len(results) > 0, "At least one probe result is required.")
latencies = [latency for _, latency in results]
successes = sum(1 for success, _ in results if success)
return {
"concurrency": concurrency,
"requests": len(results),
"successes": successes,
"failures": len(results) - successes,
"errorRate": round((len(results) - successes) / len(results), 6),
"latencyMs": {
"median": round(statistics.median(latencies), 2),
"p95": round(percentile(latencies, 0.95), 2),
"maximum": round(max(latencies), 2),
},
}
def login(origin: str, email: str, password: str) -> str:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
with opener.open(origin + "/api/session", timeout=15) as response:
csrf = json.load(response)["csrfToken"]
body = json.dumps({"email": email, "password": password}).encode()
request = urllib.request.Request(origin + "/api/auth/login", data=body, method="POST", headers={"Content-Type": "application/json", "X-CSRF-TOKEN": csrf})
with opener.open(request, timeout=15) as response:
require(response.status == 200, "Sandbox login failed.")
cookies = "; ".join(f"{cookie.name}={cookie.value}" for cookie in jar)
require("guestops.session=" in cookies, "Sandbox did not issue a GuestOps session cookie.")
return cookies
def request_once(origin: str, cookie: str, number: int) -> tuple[bool, float]:
path = PATHS[number % len(PATHS)]
request = urllib.request.Request(origin + path, headers={"Cookie": cookie, "Accept": "application/json"})
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=20) as response:
success = response.status == 200
response.read(1024) # Bound local processing; never retain response or guest content.
except (OSError, urllib.error.HTTPError):
success = False
return success, (time.perf_counter() - started) * 1000
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--origin", required=True)
parser.add_argument("--requests", type=int, default=100)
parser.add_argument("--concurrency", type=int, default=5)
parser.add_argument("--release-commit", required=True)
parser.add_argument("--release-record-sha256", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--confirm-sandbox", action="store_true")
args = parser.parse_args()
parsed = urllib.parse.urlparse(args.origin)
require(args.confirm_sandbox, "Use --confirm-sandbox after confirming the target and maintenance window.")
require(parsed.scheme == "https" and parsed.hostname and parsed.path in ("", "/") and not parsed.query and not parsed.fragment and not parsed.username,
"Origin must be an HTTPS origin without credentials, path, query or fragment.")
require(parsed.hostname != "localhost" and not parsed.hostname.startswith("127."), "Use the deployed HTTPS sandbox, not a development server.")
require(1 <= args.concurrency <= 20 and 1 <= args.requests <= 2000, "Probe bounds are 1–20 concurrent workers and 1–2000 requests.")
require(re.fullmatch(r"[0-9a-f]{40}", args.release_commit) is not None, "Use a full lowercase release commit SHA.")
require(re.fullmatch(r"[0-9a-f]{64}", args.release_record_sha256) is not None, "Use the release-record SHA-256.")
require(not args.output.exists() and args.output.parent.is_dir(), "Output must be a new file in an existing restricted directory.")
email = os.environ.get("CAPACITY_EMAIL", "")
password = os.environ.get("CAPACITY_PASSWORD", "")
require(email and password, "Set CAPACITY_EMAIL and CAPACITY_PASSWORD for a dedicated sandbox staff account.")
cookie = login(args.origin.rstrip("/"), email, password)
with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool:
results = list(pool.map(lambda number: request_once(args.origin.rstrip("/"), cookie, number), range(args.requests)))
report = {
"schemaVersion": 1,
"kind": "guestops-read-only-capacity",
"recordedAt": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
"originHost": parsed.hostname,
"releaseCommit": args.release_commit,
"releaseRecordSha256": args.release_record_sha256,
"paths": list(PATHS),
**summarize(results, args.concurrency),
}
descriptor = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
output.write(json.dumps(report, indent=2, sort_keys=True) + "\n")
print(f"Capacity probe completed: {report['successes']}/{report['requests']} successful; p95 {report['latencyMs']['p95']} ms. Review against the approved target before release.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Capacity probe stopped: {error}", file=__import__("sys").stderr)
raise SystemExit(1)