v0.33.8
This commit is contained in:
1 parent
6b937aaf85
commit
87c8393d86
79 files changed
+6727
-297
No files matched your search
Executable
+448
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disposable, loopback-only fixtures for testing actual RustProxy processes.
|
||||
|
||||
Runtime databases and credentials live in TemporaryDirectory, never in results.
|
||||
No pre-existing SQLite database, fixed port, or historical /mnt/data path is used.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import gzip
|
||||
import hashlib
|
||||
from http.client import HTTPConnection, HTTPException
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
result = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
result.update(block)
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def seal_results(directory: Path) -> None:
|
||||
"""Checksums cover every regular evidence file, except the checksum manifest."""
|
||||
paths = sorted(p for p in directory.rglob("*") if p.is_file()
|
||||
and p.name != "SHA256SUMS")
|
||||
(directory / "SHA256SUMS").write_text("".join(
|
||||
f"{sha256(path)} {path.relative_to(directory)}\n" for path in paths))
|
||||
|
||||
|
||||
def http(port: int, path="/small.html", method="GET", headers=None, body=None,
|
||||
timeout=10, source_address=None):
|
||||
connection = HTTPConnection("127.0.0.1", port, timeout=timeout,
|
||||
source_address=source_address)
|
||||
try:
|
||||
connection.request(method, path, body, headers or {})
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
return response.status, {k.lower(): v for k, v in response.getheaders()}, payload
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def wait_ready(port: int, process: subprocess.Popen, timeout=30):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"process {process.pid} exited with {process.returncode}")
|
||||
try:
|
||||
http(port, "/", timeout=.5)
|
||||
return
|
||||
except (OSError, HTTPException):
|
||||
time.sleep(.05)
|
||||
raise RuntimeError(f"loopback listener {port} did not start")
|
||||
|
||||
|
||||
class Fixture:
|
||||
def __init__(self, results: Path, workers=4, temp_parent: Path | None = None,
|
||||
server_cpus=None, origin_cpus=None, origin_workers=None):
|
||||
self.results = results.resolve()
|
||||
self.results.mkdir(parents=True, exist_ok=True)
|
||||
# Keep live state outside the evidence tree. Even an interrupted run or
|
||||
# workspace synchronization must never put databases/keys in results.
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="rustproxy-perf-", dir=temp_parent)
|
||||
self.root = Path(self.temp.name)
|
||||
self.root.chmod(0o755) # nginx workers must traverse the public fixture root
|
||||
self.site = self.root / "site"
|
||||
self.site.mkdir(mode=0o755)
|
||||
self.workers = workers
|
||||
self.server_cpus = server_cpus
|
||||
self.origin_cpus = origin_cpus
|
||||
self.origin_workers = origin_workers or workers
|
||||
self.children = []
|
||||
self.handles = []
|
||||
self.ports = set()
|
||||
self.origin_port = self.port()
|
||||
self.make_site()
|
||||
|
||||
def port(self):
|
||||
while True:
|
||||
port = free_port()
|
||||
if port not in self.ports:
|
||||
self.ports.add(port)
|
||||
return port
|
||||
|
||||
def make_site(self):
|
||||
# Exact, reproducible payload sizes match the original report's cases.
|
||||
small = b"<!doctype html><html><body>RustProxy benchmark "
|
||||
small += b"x" * (1040 - len(small) - len(b"</body></html>"))
|
||||
small += b"</body></html>"
|
||||
files = {"small.html": small,
|
||||
"large.bin": bytes(range(256)) * 256,
|
||||
"huge.bin": bytes(range(256)) * 4096,
|
||||
"empty.txt": b"",
|
||||
"app.json": (b'{\n "message": "stable fixture",\n "values": [1, 2, 3]\n}\n'),
|
||||
"app.css": b"/* fixture comment */\nbody { color: red; padding: 0px; }\n",
|
||||
"compress.txt": (b"compressible RustProxy text fixture. " * 4096),
|
||||
"download.txt": b"download fixture\n"}
|
||||
for name, body in files.items():
|
||||
(self.site / name).write_bytes(body)
|
||||
(self.site / "small.html.gz").write_bytes(gzip.compress(small, mtime=0))
|
||||
(self.site / "directory").mkdir()
|
||||
(self.site / "directory" / "index.html").write_bytes(b"directory index\n")
|
||||
(self.site / "listing").mkdir()
|
||||
(self.site / "listing" / "visible.txt").write_bytes(b"listing fixture\n")
|
||||
(self.site / "inside-link.txt").symlink_to(self.site / "download.txt")
|
||||
self.outside = self.root / "outside-secret.txt"
|
||||
self.outside.write_bytes(b"MUST-NOT-BE-SERVED-OUTSIDE-ROOT\n")
|
||||
(self.site / "escape-link.txt").symlink_to(self.outside)
|
||||
write_json(self.results / "payloads.json", {
|
||||
name: {"bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()}
|
||||
for name, body in files.items()})
|
||||
|
||||
def spawn(self, argv, name, env=None, affinity=None):
|
||||
log = (self.results / f"{name}.log").open("w")
|
||||
self.handles.append(log)
|
||||
if affinity:
|
||||
argv = ["taskset", "-c", affinity, *argv]
|
||||
child = subprocess.Popen([str(x) for x in argv], stdout=log,
|
||||
stderr=subprocess.STDOUT, env=env, start_new_session=True)
|
||||
self.children.append(child)
|
||||
return child
|
||||
|
||||
@staticmethod
|
||||
def stop(process):
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=3)
|
||||
|
||||
def close(self):
|
||||
for process in reversed(self.children):
|
||||
self.stop(process)
|
||||
for handle in self.handles:
|
||||
handle.close()
|
||||
self.temp.cleanup()
|
||||
write_json(self.results / "fixture-cleanup.json", {
|
||||
"temporary_state_removed": not self.root.exists(),
|
||||
"state_was_outside_results": not self.root.is_relative_to(self.results)})
|
||||
if self.root.exists():
|
||||
raise RuntimeError("temporary runtime state was not removed; do not package the fixture directory")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
def base_config(self, reverse_port, static_port):
|
||||
endpoints = []
|
||||
for label, port in [("reverse", reverse_port), ("static", static_port)]:
|
||||
endpoints.append({"id": label, "name": label, "scope": "internal",
|
||||
"enabled": True, "binding": {
|
||||
"address": f"127.0.0.1:{port}", "tls": False,
|
||||
"http2": False, "http3": None}})
|
||||
return {
|
||||
"cache": {"enabled": False},
|
||||
"geoip_updates": {"enabled": False},
|
||||
"listeners": {"endpoints": endpoints},
|
||||
"web_services": [{"id": "fixture-static", "name": "Fixture static",
|
||||
"document_root": str(self.site), "index": ["index.html", "small.html"],
|
||||
"directory_listing": False}],
|
||||
"vhosts": [{"id": "reverse", "frontend": {
|
||||
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
|
||||
"listener_ids": ["reverse"]},
|
||||
"backends": [{"id": "origin", "url": f"http://127.0.0.1:{self.origin_port}",
|
||||
"allow_private": True, "preserve_host": False}],
|
||||
"load_balancing": {"health_check": {"mode": "off"}}, "routes": []},
|
||||
{"id": "static", "frontend": {
|
||||
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
|
||||
"listener_ids": ["static"]},
|
||||
"source": {"type": "web_server", "web_service_id": "fixture-static"},
|
||||
"routes": []}]
|
||||
}
|
||||
|
||||
def rust(self, binary: Path, label: str, max_idle=None):
|
||||
return RustInstance(self, binary.resolve(), label, max_idle=max_idle)
|
||||
|
||||
def nginx(self, executable: Path, frontend=True):
|
||||
"""Use a separate nginx master for the shared origin and comparator."""
|
||||
executable = executable.resolve()
|
||||
result = subprocess.run([str(executable), "-V"], capture_output=True, text=True, timeout=10)
|
||||
(self.results / "nginx-version.txt").write_text(result.stdout + result.stderr)
|
||||
origin_conf = self.nginx_config("nginx-origin", f"""
|
||||
server {{ listen 127.0.0.1:{self.origin_port}; server_name _;
|
||||
root "{self.site}"; location / {{ add_header Cache-Control "public, max-age=60"; }}
|
||||
}}
|
||||
""", workers=self.origin_workers)
|
||||
origin = self.spawn([executable, "-p", self.root, "-c", origin_conf], "nginx-origin",
|
||||
affinity=self.origin_cpus)
|
||||
wait_ready(self.origin_port, origin)
|
||||
result = {"origin": origin, "origin_port": self.origin_port}
|
||||
if frontend:
|
||||
reverse_port, static_port = self.port(), self.port()
|
||||
conf = self.nginx_config("nginx-frontend", f"""
|
||||
upstream fixture_origin {{ server 127.0.0.1:{self.origin_port}; keepalive 64; }}
|
||||
server {{ listen 127.0.0.1:{reverse_port}; server_name _;
|
||||
location / {{ proxy_pass http://fixture_origin; proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; proxy_buffering off; }}
|
||||
}}
|
||||
server {{ listen 127.0.0.1:{static_port}; server_name _; root "{self.site}"; }}
|
||||
""")
|
||||
process = self.spawn([executable, "-p", self.root, "-c", conf], "nginx-frontend",
|
||||
affinity=self.server_cpus)
|
||||
wait_ready(reverse_port, process)
|
||||
wait_ready(static_port, process)
|
||||
result.update({"process": process, "reverse_port": reverse_port,
|
||||
"static_port": static_port})
|
||||
return result
|
||||
|
||||
def nginx_config(self, name, servers, workers=None):
|
||||
path = self.root / f"{name}.conf"
|
||||
text = f"""daemon off;
|
||||
master_process on;
|
||||
user {pwd.getpwuid(os.geteuid()).pw_name};
|
||||
worker_processes {workers or self.workers};
|
||||
pid {self.root}/{name}.pid;
|
||||
error_log stderr warn;
|
||||
events {{ worker_connections 8192; }}
|
||||
http {{
|
||||
access_log off;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 1000000;
|
||||
default_type application/octet-stream;
|
||||
types {{ text/html html; text/css css; application/json json; text/plain txt; }}
|
||||
client_body_temp_path {self.root}/{name}-body;
|
||||
proxy_temp_path {self.root}/{name}-proxy;
|
||||
{servers}
|
||||
}}
|
||||
"""
|
||||
path.write_text(text)
|
||||
(self.results / path.name).write_text(text)
|
||||
return path
|
||||
|
||||
|
||||
class RustInstance:
|
||||
def __init__(self, fixture, binary, label, max_idle=None):
|
||||
self.fixture, self.binary, self.label = fixture, binary, label
|
||||
if not os.access(binary, os.X_OK):
|
||||
target = fixture.root / f"{label}-rust-proxy"
|
||||
shutil.copyfile(binary, target)
|
||||
target.chmod(0o700)
|
||||
self.binary = target
|
||||
self.admin_port, self.reverse_port, self.static_port = (fixture.port() for _ in range(3))
|
||||
self.data = fixture.root / f"{label}-data"
|
||||
self.base = fixture.base_config(self.reverse_port, self.static_port)
|
||||
if max_idle is not None:
|
||||
self.base["vhosts"][0]["load_balancing"]["keepalive"] = {"max_idle_per_host": max_idle}
|
||||
self.password = "Fixture!" + secrets.token_hex(20)
|
||||
self.username = "performance"
|
||||
self.env = os.environ.copy()
|
||||
self.env["TOKIO_WORKER_THREADS"] = str(fixture.workers)
|
||||
self.env["RUST_PROXY_PROXY_LISTEN"] = f"127.0.0.1:{self.reverse_port}"
|
||||
self.env["RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS"] = "0"
|
||||
self.env.pop("RUST_PROXY_TLS_LISTEN", None)
|
||||
self.applies = []
|
||||
self.cli("admin", "init", "--username", self.username, "--password-stdin",
|
||||
input=self.password + "\n")
|
||||
self.start("bootstrap")
|
||||
self.apply(self.base, "base")
|
||||
fixture.stop(self.process)
|
||||
self.start("runtime")
|
||||
wait_ready(self.static_port, self.process)
|
||||
version = self.cli("--version").stdout.strip()
|
||||
write_json(fixture.results / f"{label}-binary.json", {
|
||||
"label": label, "path": str(binary), "sha256": sha256(binary),
|
||||
"version": version, "worker_threads": fixture.workers,
|
||||
"pid": self.process.pid, "protocol": "HTTP/1.1 plaintext loopback",
|
||||
"cpu_affinity": fixture.server_cpus,
|
||||
"max_idle_per_host_override": max_idle})
|
||||
|
||||
def cli(self, *args, input=None, check=True):
|
||||
result = subprocess.run([str(self.binary), "--data-dir", str(self.data),
|
||||
"--admin-listen", f"127.0.0.1:{self.admin_port}", *args],
|
||||
input=input, text=True, capture_output=True,
|
||||
env=self.env, timeout=40)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError(f"{self.label} CLI {' '.join(args[:2])}: "
|
||||
f"{result.stdout[-2000:]} {result.stderr[-3000:]}")
|
||||
return result
|
||||
|
||||
def start(self, phase):
|
||||
self.process = self.fixture.spawn([
|
||||
self.binary, "--data-dir", self.data, "--admin-listen", f"127.0.0.1:{self.admin_port}",
|
||||
"--update-check-interval-secs", "0", "serve", "--proxy-listen",
|
||||
f"127.0.0.1:{self.reverse_port}", "--max-connections", "4096", "--max-inflight", "4096"],
|
||||
f"{self.label}-{phase}", env=self.env, affinity=self.fixture.server_cpus)
|
||||
wait_ready(self.admin_port, self.process)
|
||||
wait_ready(self.reverse_port, self.process)
|
||||
|
||||
def apply(self, config, name, expected_success=True):
|
||||
config_path = self.fixture.root / f"{self.label}-{name}.json"
|
||||
write_json(config_path, config)
|
||||
result = self.cli("config", "apply", str(config_path), check=False)
|
||||
# Configurations with ephemeral test passwords/hashes are intentionally not copied.
|
||||
safe = copy.deepcopy(config)
|
||||
for vhost in safe.get("vhosts", []):
|
||||
if "basic_auth" in vhost:
|
||||
vhost["basic_auth"]["users"] = "REDACTED fixture users"
|
||||
write_json(self.fixture.results / "configs" / f"{self.label}-{name}.json", safe)
|
||||
event = {"name": name, "sha256": sha256(config_path), "returncode": result.returncode,
|
||||
"expected_success": expected_success, "stdout": result.stdout[-2000:],
|
||||
"stderr": result.stderr[-3000:]}
|
||||
self.applies.append(event)
|
||||
write_json(self.fixture.results / f"{self.label}-config-applies.json", self.applies)
|
||||
if (result.returncode == 0) != expected_success:
|
||||
raise RuntimeError(f"config {name}: {result.stdout} {result.stderr}")
|
||||
return result
|
||||
|
||||
def reset(self):
|
||||
return self.apply(self.base, "restored-base")
|
||||
|
||||
def login(self):
|
||||
status, headers, body = http(self.admin_port, "/api/auth/login", "POST",
|
||||
{"Origin": f"http://127.0.0.1:{self.admin_port}", "Content-Type": "application/json",
|
||||
"X-RustProxy-Request": "1"},
|
||||
json.dumps({"username": self.username, "password": self.password}))
|
||||
if status != 200:
|
||||
raise RuntimeError(f"fixture login failed: HTTP {status}: {body[:200]!r}")
|
||||
self.cookie = headers["set-cookie"].split(";", 1)[0]
|
||||
self.csrf = json.loads(body)["csrf_token"]
|
||||
return status
|
||||
|
||||
def wait_quiescent(self, timeout=180, quiet_seconds=5, poll_seconds=.5):
|
||||
"""Wait for the real, one-time ACME bootstrap before any timed work.
|
||||
|
||||
This does not disable ACME or change the process environment. The
|
||||
private fixture has no issuers/jobs; a terminal unavailable state is
|
||||
valid for HTTP tests, but does not establish ACME issuance support.
|
||||
"""
|
||||
from resources import descendants, process_stats
|
||||
|
||||
if timeout <= 0 or quiet_seconds <= 0 or poll_seconds <= 0 or timeout <= quiet_seconds:
|
||||
raise ValueError("quiescence timeout must exceed the positive quiet period")
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
output = self.fixture.results / f"{self.label}-quiescence.json"
|
||||
report = {"completed": False, "label": self.label, "pid": self.process.pid,
|
||||
"timeout_seconds": timeout, "required_quiet_seconds": quiet_seconds,
|
||||
"poll_seconds": poll_seconds, "observations": [],
|
||||
"acme_issuance_tested": False}
|
||||
|
||||
def read_admin(path):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError(f"{self.label} exited during quiescence")
|
||||
status, _, body = self.admin(path, timeout=min(1, remaining))
|
||||
if status != 200:
|
||||
raise RuntimeError(f"{self.label} quiescence {path}: HTTP {status}")
|
||||
return json.loads(body)
|
||||
|
||||
try:
|
||||
root = process_stats(self.process.pid)
|
||||
if root is None:
|
||||
raise RuntimeError(f"{self.label} has no live root process")
|
||||
identity = (root["pid"], root["start_ticks"])
|
||||
report["start_ticks"] = root["start_ticks"]
|
||||
envelope = read_admin("/api/config")
|
||||
if not isinstance(envelope, dict) or not isinstance(envelope.get("config"), dict):
|
||||
raise RuntimeError("quiescence requires the actual configuration envelope")
|
||||
certificates = envelope["config"].get("tls", {}).get("certificates", [])
|
||||
if not isinstance(certificates, list) or any(not isinstance(cert, dict) for cert in certificates):
|
||||
raise RuntimeError("unexpected certificate configuration during quiescence")
|
||||
report["configured_issuers"] = sum(cert.get("issuer") is not None for cert in certificates)
|
||||
if report["configured_issuers"]:
|
||||
raise RuntimeError("performance fixture must have no configured ACME issuers")
|
||||
quiet_started, previous_terminal = None, None
|
||||
while True:
|
||||
payload = read_admin("/api/acme")
|
||||
if not isinstance(payload, dict) or not {"runtime", "job", "renewals"} <= payload.keys():
|
||||
raise RuntimeError("quiescence requires explicit ACME runtime/job/renewal state")
|
||||
if payload["job"] is not None or payload["renewals"] != {}:
|
||||
raise RuntimeError("performance fixture has an unexpected ACME job or renewal")
|
||||
runtime = payload["runtime"]
|
||||
if not isinstance(runtime, dict) or runtime.get("status") not in {
|
||||
"checking", "initializing", "ready", "unavailable"}:
|
||||
raise RuntimeError("unexpected ACME runtime state during quiescence")
|
||||
pids = descendants(self.process.pid)
|
||||
root = process_stats(self.process.pid)
|
||||
if root is None or (root["pid"], root["start_ticks"]) != identity:
|
||||
raise RuntimeError(f"{self.label} root process identity changed during quiescence")
|
||||
now = time.monotonic()
|
||||
if now >= deadline:
|
||||
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
|
||||
terminal = tuple(runtime.get(key) for key in ("status", "mode", "message"))
|
||||
quiet = runtime["status"] in {"ready", "unavailable"} and pids == [self.process.pid]
|
||||
if not quiet:
|
||||
quiet_started, previous_terminal = None, None
|
||||
elif quiet_started is None or terminal != previous_terminal:
|
||||
quiet_started, previous_terminal = now, terminal
|
||||
elapsed_quiet = now - quiet_started if quiet_started is not None else 0
|
||||
report["observations"].append({
|
||||
"elapsed_seconds": now - started, "process_count": len(pids),
|
||||
"acme_status": runtime["status"], "acme_mode": runtime.get("mode"),
|
||||
"acme_message": runtime.get("message"), "quiet_seconds": elapsed_quiet})
|
||||
if elapsed_quiet >= quiet_seconds:
|
||||
report.update({"completed": True, "elapsed_seconds": now - started,
|
||||
"terminal_status": runtime["status"], "process_count": len(pids)})
|
||||
write_json(output, report)
|
||||
return report
|
||||
write_json(output, report)
|
||||
time.sleep(min(poll_seconds, max(0, deadline - time.monotonic())))
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
report.update({"elapsed_seconds": time.monotonic() - started,
|
||||
"error_type": type(error).__name__, "error": str(error)})
|
||||
write_json(output, report)
|
||||
raise
|
||||
|
||||
def admin(self, path, method="GET", body=None, csrf=True, cookie=True, origin=None,
|
||||
timeout=10):
|
||||
headers = {"Origin": origin or f"http://127.0.0.1:{self.admin_port}", "X-RustProxy-Request": "1"}
|
||||
if cookie:
|
||||
headers["Cookie"] = self.cookie
|
||||
if csrf:
|
||||
headers["X-CSRF-Token"] = self.csrf
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
body = json.dumps(body)
|
||||
return http(self.admin_port, path, method, headers, body, timeout=timeout)
|
||||
Reference in new issue
Block a user