Files
core/test/performance/cms.py
T

676 lines
33 KiB
Python

"""显式验收双源码 CMS 的真实身份、租户、发布链及专属内存预算。"""
import argparse
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
import http.client
from http.cookies import SimpleCookie
import json
import math
import os
from pathlib import Path
import secrets
import shutil
import signal
import socket
import subprocess
import tempfile
from threading import Event
import time
from urllib.parse import urlencode
from build import ROOT, fingerprint
from process import Budget, Process, observe, summarize, resource_trend
from settings import write_private_json
FORMATS = ("dever", "md")
ARTICLE_PATH = "/news/article/admin/manage"
ARTICLE_LIST_PATH = f"{ARTICLE_PATH}/list?size=100"
REVISION_PATH = "/content/revision/admin/manage/create"
SESSION_PATH = "/user/account/{site}/session/login"
PUBLICATIONS_PATH = "/news/article/front/browse/list?size=100"
def require(condition, message):
if not condition:
raise RuntimeError(message)
def execute(command, directory, log_name, *, envelope=False, timeout=180):
child = subprocess.Popen([str(value) for value in command], cwd=directory,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
failure = None
try:
stdout, stderr = child.communicate(timeout=timeout)
except BaseException as error:
failure = error
try:
signal_group(child.pid, signal.SIGTERM)
stdout, stderr = child.communicate(timeout=3)
except subprocess.TimeoutExpired:
signal_group(child.pid, signal.SIGKILL)
stdout, stderr = child.communicate(timeout=3)
(directory / f"{log_name}.stdout.log").write_bytes(stdout)
(directory / f"{log_name}.stderr.log").write_bytes(stderr)
if failure is not None:
raise failure
require(child.returncode == 0, f"{log_name} exited {child.returncode}: {directory}")
if envelope:
return decode_response(stdout, 200)
def signal_group(pid, signal_number):
try:
os.killpg(pid, signal_number)
except ProcessLookupError:
pass
def decode_response(body, status):
document = json.loads(body)
require(isinstance(document, dict) and set(document) == {"code", "message", "data"},
f"unexpected response envelope (HTTP {status})")
require(status == 200 and document["code"] == 0,
f"request failed: HTTP {status}, code={document['code']}, {document['message']}")
return document["data"]
def copy_cms_project(source, destination):
destination.mkdir()
for name in ("config", "module", "test"):
shutil.copytree(source / name, destination / name)
def build_cases(compiler, output):
output.mkdir(parents=True, exist_ok=False)
cases = {}
for source_format in FORMATS:
source = ROOT / "examples/cms" / source_format
project = output / source_format
copy_cms_project(source, project)
started = time.monotonic()
execute([compiler, "check", project], project, "check")
execute([compiler, "build", project, "--output", project / "cms-app"], project, "build", timeout=900)
executable = project / "cms-app"
cases[source_format] = {
"executable": str(executable), "config": str(project / "config/setting.json"),
"fingerprint": fingerprint(executable),
"build_seconds": time.monotonic() - started,
}
manifest = {"format": "dever-cms-publish-v1", "cases": cases}
write_private_json(output / "manifest.json", manifest)
return manifest
def available_port():
with socket.socket() as reservation:
reservation.bind(("127.0.0.1", 0))
return reservation.getsockname()[1]
def isolated_settings(settings, port):
settings = deepcopy(settings)
settings["runtime"]["mode"] = "api"
settings["http"] = {"host": "127.0.0.1", "port": port}
settings["auth"]["providers"]["session"]["jwtSecret"] = secrets.token_hex(32)
for site in settings["sites"].values():
site["origin"] = f"http://127.0.0.1:{port}"
database = settings["database"]["default"]
if database["type"] == "sqlite":
database["path"] = "data/db/platform.db"
database["tenant_directory"] = "data/tenants"
elif database["type"] == "postgres":
require(bool(database.get("url")) and bool(database.get("tenant_database_prefix")),
"PostgreSQL CMS acceptance requires an explicit isolated control database and tenant prefix")
else:
raise RuntimeError("CMS acceptance supports only SQLite and PostgreSQL")
settings["log"]["level"] = "error"
return settings
class CmsClient:
def __init__(self, port):
self.connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
self.origin = f"http://127.0.0.1:{port}"
self.cookies = {}
self.timings = {}
self.record_timings = True
def request(self, method, path, payload=None, *, site=None, expected=200):
headers = {"Content-Type": "application/json", "Origin": self.origin}
if site in self.cookies:
headers["Cookie"] = self.cookies[site]
body = None
if payload is not None:
if method in ("GET", "DELETE"):
query = {name: value if isinstance(value, str) else json.dumps(value)
for name, value in payload.items()}
path += ("&" if "?" in path else "?") + urlencode(query)
else:
body = json.dumps(payload)
started = time.monotonic()
self.connection.request(method, path, body, headers)
response = self.connection.getresponse()
body = response.read()
route = path.split("?")[0]
label = f"{method} {route}" + (f" expected {expected}" if expected != 200 else "")
if self.record_timings:
self.timings.setdefault(label, []).append(
(time.monotonic() - started) * 1000)
require(response.status == expected,
f"{method} {path}: HTTP {response.status}, expected {expected}: {body!r}")
if expected != 200:
document = json.loads(body)
require(document.get("code") != 0, "rejected request returned success")
return None
data = decode_response(body, response.status)
if path.endswith("/login"):
cookie = SimpleCookie()
for name, value in response.getheaders():
if name.lower() == "set-cookie":
cookie.load(value)
require(bool(cookie), "login did not return a session cookie")
self.cookies[site] = "; ".join(f"{name}={value.value}" for name, value in cookie.items())
return data
def close(self):
self.connection.close()
def wait_ready(process, client):
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
process.sample()
require(process.child.poll() is None, f"CMS exited before readiness: {process.directory}")
try:
client.request("GET", ARTICLE_LIST_PATH, expected=401)
return time.monotonic() - process.started
except (ConnectionError, http.client.HTTPException, OSError):
client.close()
time.sleep(0.02)
raise TimeoutError(f"CMS readiness timeout: {process.directory}")
def login_client(client, identity, password, tenant_key):
for site in ("admin", "front"):
data = client.request("POST", SESSION_PATH.format(site=site), {
"credential_id": identity["credential_id"], "password": password, "tenant": tenant_key,
}, site=site)
require(data["account"]["id"] == identity["user_id"]
and data["tenant_id"] == identity["tenant_id"] and data["site"] == site,
"login changed the trusted identity or tenant/site")
def publish_workload(client, process, identity, password, articles, *,
tenant_key="bench", title_prefix="Article"):
login_client(client, identity, password, tenant_key)
titles = []
ids = []
for index in range(articles):
slug, title = f"bench-{index}", f"{title_prefix} {index}"
created = client.request("POST", f"{ARTICLE_PATH}/create", {
"slug": f" {slug.upper()} ", "title": f" {title} ", "body": "Draft body",
}, site="admin")
require(created["slug"] == slug and created["title"] == title,
"article creation did not normalize input")
require("author_id" not in created and "status" not in created,
"article creation leaked private owner or publication state")
changed = client.request("POST", REVISION_PATH, {
"selected_article_id": created["id"], "expected_version": created["version"],
"title": f" {title} published ", "body": "Published body",
}, site="admin")
expected_title = f"{title} published"
require(changed["title"] == expected_title
and changed["article_id"] == created["id"]
and changed["version"] == created["version"] + 1,
"revision did not preserve normalized content and advance the article version")
published = client.request("POST", f"{ARTICLE_PATH}/publish", {
"article_id": created["id"],
}, site="admin")
require(published["slug"] == slug and published["title"] == expected_title
and published["body"] == "Published body",
"publication did not reflect the stored article")
titles.append(expected_title)
ids.append(created["id"])
process.sample()
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
require(listing["total"] == articles
and sorted(value["title"] for value in listing["items"]) == sorted(titles),
"front query did not return every published article exactly once")
client.request("POST", f"{ARTICLE_PATH}/publish", {"article_id": ids[0]},
site="admin", expected=409)
return {"articles": articles, "titles": sorted(titles), "duplicate_publish_status": 409}
def provision_identity(executable, directory, tenant_key):
password = secrets.token_urlsafe(24)
identity = execute([executable, "user.account.bootstrap", json.dumps({
"tenant_key": tenant_key, "tenant_name": f"Benchmark {tenant_key}",
"email": f"{tenant_key}@example.invalid", "display_name": "Benchmark",
"password": password,
})], directory, f"bootstrap-{tenant_key}", envelope=True)
execute([executable, "--dever-tenant-migrate", identity["tenant_id"]],
directory, f"migrate-{tenant_key}")
for site in ("admin", "front"):
execute([executable, "--dever-tenant-owner", identity["tenant_id"], site,
identity["user_id"]], directory, f"owner-{tenant_key}-{site}")
return identity, password
def latency_summary(timings):
result = {}
for action, samples in timings.items():
values = sorted(samples)
result[action] = {"samples": len(values), "p50_ms": values[len(values) // 2],
"max_ms": values[-1]}
return result
def catalog_key(catalog, component, domain, site, action, method):
matches = [entry["key"] for entry in catalog
if (entry["component"], entry["domain"], entry["site"],
entry["action"], entry["method"]) == (component, domain, site, action, method)]
require(len(matches) == 1 and bool(matches[0]),
f"permission catalog must uniquely identify {component}.{domain}.{site}.{action}")
return matches[0]
def run_postgres_rbac(executable, directory, port, owner, identity):
catalog = owner.request("GET", "/user/authorization/admin/manage/permissions", site="admin")
front_catalog = owner.request("GET", "/user/authorization/front/manage/permissions", site="front")
permissions = {
"admin_read": catalog_key(catalog, "news", "article", "admin", "list", "GET"),
"admin_permissions": catalog_key(catalog, "user", "authorization", "admin", "permissions", "GET"),
"front_list": catalog_key(front_catalog, "news", "article", "front", "list", "GET"),
}
require(len(set(permissions.values())) == 3, "RBAC acceptance reused a permission key")
password = secrets.token_urlsafe(24)
member = execute([executable, "user.account.fixture_member", json.dumps({
"selected_tenant_id": identity["tenant_id"], "email": f"member-{secrets.token_hex(8)}@example.invalid",
"display_name": "Acceptance member", "password": password,
})], directory, "rbac-member", envelope=True)
require(isinstance(member["user_id"], int) and not isinstance(member["user_id"], bool)
and member["user_id"] > 0 and member["user_id"] != identity["user_id"]
and member["tenant_id"] == identity["tenant_id"],
"RBAC acceptance did not create a distinct member in the owner's tenant")
probes = (("admin", ARTICLE_LIST_PATH),
("admin", "/user/authorization/admin/manage/permissions"),
("front", PUBLICATIONS_PATH))
client = CmsClient(port)
try:
login_client(client, member, password, "tenant_a")
for site, path in probes:
client.request("GET", path, site=site, expected=403)
roles = (("admin", "fixture-reader", "admin_read"),
("admin", "fixture-catalog", "admin_permissions"),
("front", "fixture-reader", "front_list"))
for site, role_id, permission in roles:
base = f"/user/authorization/{site}/manage"
owner.request("POST", f"{base}/save", {
"id": role_id, "name": role_id, "all_permissions": False,
"permission_keys": [permissions[permission]],
}, site=site)
owner.request("POST", f"{base}/grant", {
"user_id": member["user_id"], "role_id": role_id,
}, site=site)
for site, path in probes:
client.request("GET", path, site=site)
owner.request("DELETE", "/user/authorization/admin/manage/revoke", {
"user_id": member["user_id"], "role_id": "fixture-reader",
}, site="admin")
client.request("GET", ARTICLE_LIST_PATH, site="admin", expected=403)
client.request("GET", probes[1][1], site="admin")
client.request("GET", PUBLICATIONS_PATH, site="front")
for site, role_id, path in (("admin", "fixture-catalog", probes[1][1]),
("front", "fixture-reader", PUBLICATIONS_PATH)):
owner.request("DELETE", f"/user/authorization/{site}/manage/revoke", {
"user_id": member["user_id"], "role_id": role_id,
}, site=site)
client.request("GET", path, site=site, expected=403)
finally:
client.close()
return {"member_user_id": member["user_id"], "permissions": permissions,
"before_grant_denied": True, "both_admin_roles_allowed": True,
"revoked_role_denied": True, "second_role_still_allowed": True,
"front_same_named_role_still_allowed": True}
class ReadLatency:
"""固定内存的对数直方图;分位数报告桶上界,最大相邻桶比例约1.011。"""
def __init__(self):
self.buckets = [0] * 2048
self.count = 0
def add(self, milliseconds):
index = math.ceil(math.log2(1 + milliseconds) * 64)
require(0 <= index < len(self.buckets), "CMS latency exceeds histogram bounds")
self.buckets[index] += 1
self.count += 1
def merge(self, other):
self.buckets = [left + right for left, right in zip(self.buckets, other.buckets)]
self.count += other.count
def percentile(self, fraction):
threshold = math.ceil(self.count * fraction)
count = 0
for index, samples in enumerate(self.buckets):
count += samples
if count >= threshold:
return 2 ** (index / 64) - 1
raise ValueError("empty latency histogram")
def concurrent_reads(port, cookies, expected, process, *, concurrency, seconds, interval):
"""Each closed-loop client keeps its own connection and verifies every reply."""
stop = Event()
started = time.monotonic()
deadline = started + seconds
first = len(process.samples)
def read_publications():
client = CmsClient(port)
client.cookies = cookies.copy()
client.record_timings = False
latency = ReadLatency()
try:
while not stop.is_set() and time.monotonic() < deadline:
request_started = time.monotonic()
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
require(listing == expected, "concurrent CMS read changed publication results")
latency.add((time.monotonic() - request_started) * 1000)
return latency
except BaseException:
stop.set()
raise
finally:
client.close()
with ThreadPoolExecutor(max_workers=concurrency) as clients:
futures = [clients.submit(read_publications) for _ in range(concurrency)]
try:
while any(not future.done() for future in futures):
process.check()
observe([process], interval, interval)
elapsed = time.monotonic() - started
latency = ReadLatency()
for future in futures:
latency.merge(future.result())
finally:
stop.set()
require(latency.count > 0, "concurrent CMS read completed without a successful request")
require(elapsed >= seconds, "concurrent CMS read ended before the measurement window")
process.sample()
return {"mode": "closed_loop_authenticated_read", "concurrency": concurrency,
"requested_seconds": seconds, "elapsed_seconds": elapsed,
"succeeded": latency.count, "errors": 0, "result_checked_on_every_request": True,
"actual_success_qps": latency.count / elapsed,
"latency_measurement": "request and reply validation; logarithmic bucket upper bound, 64 buckets per octave",
"request_latency_ms": {name: latency.percentile(fraction)
for name, fraction in (("p50", .5), ("p95", .95), ("p99", .99))},
"resources": summarize(process.samples[first:]),
"trend": resource_trend(process.samples[first:]) if len(process.samples[first:]) >= 4 else None}
def sqlite_pool_descriptor_limits(settings, directory):
database = settings["database"]["default"]
require(database["type"] == "sqlite", "CMS read benchmark requires isolated SQLite")
databases = [directory / database["path"]]
databases.extend((directory / database["tenant_directory"]).glob("*.db"))
# 每个连接可持有主文件与WAL;同进程各连接共用一个SHM句柄。
return {str(path.resolve()) + suffix: limit for path in databases
for suffix, limit in (("", database["max_connections"]),
("-wal", database["max_connections"]), ("-shm", 1))}
def validate_read_descriptor_recovery(load):
before = Counter(load["descriptors_before"].values())
after = Counter(load["descriptors_after"].values())
for path, limit in load["pooled_descriptor_limits"].items():
require(after[path] <= limit, f"CMS SQLite descriptor count exceeds pool limit: {path}")
before.pop(path, None)
after.pop(path, None)
require(before == after,
f"CMS read descriptors did not recover: added={dict(after - before)}, missing={dict(before - after)}")
def run_case(case, directory, *, articles, cgroup_parent, memory_mib,
idle_seconds=0.2, interval=0.05, server_cpu=None, cpu_quota=None,
concurrency=0, read_seconds=5):
executable = Path(case["executable"])
require(fingerprint(executable) == case["fingerprint"], "CMS artifact fingerprint changed")
directory.mkdir(parents=True, exist_ok=False)
target = directory / "cms-app"
shutil.copy2(executable, target)
(directory / "config").mkdir()
(directory / "data").mkdir()
port = available_port()
settings = isolated_settings(json.loads(Path(case["config"]).read_text()), port)
write_private_json(directory / "config/setting.json", settings)
identity, password = provision_identity(target, directory, "bench")
budget = Budget(cgroup_parent, memory_mib, cpu_quota)
client = CmsClient(port)
failure = None
result = {}
try:
with Process([target], directory / "server", budget=budget,
cpus=(server_cpu,) if server_cpu is not None else None) as process:
result["raw"] = process.samples
ready_seconds = wait_ready(process, client)
started = time.monotonic()
contract = publish_workload(client, process, identity, password, articles)
elapsed = time.monotonic() - started
result.update(contract=contract, ready_seconds=ready_seconds,
workload_seconds=elapsed, published_articles_per_second=articles / elapsed,
latency=latency_summary(client.timings))
if concurrency:
expected = client.request("GET", PUBLICATIONS_PATH, site="front")
# 控制连接不参与长读负载,先关闭,避免其空闲超时改变基线。
client.close()
observe([process], 0.3, interval)
descriptors_before = process.descriptors()
pooled_limits = sqlite_pool_descriptor_limits(settings, directory)
read_load = {"descriptors_before": descriptors_before,
"pooled_descriptor_limits": pooled_limits}
result["concurrent_reads"] = read_load
read_load.update(concurrent_reads(port, client.cookies, expected, process,
concurrency=concurrency, seconds=read_seconds, interval=interval))
observe([process], 0.3, interval)
descriptors_after = process.descriptors()
read_load.update(descriptors_after=descriptors_after,
fd_delta_after_disconnect=len(descriptors_after) - len(descriptors_before))
validate_read_descriptor_recovery(read_load)
observe([process], idle_seconds, interval)
process.sample()
process.check()
require(process.child.poll() is None, "CMS exited during the publish workload")
resources = summarize(process.samples)
result.update(resources=resources, budget_before_stop=budget.sample())
except BaseException as error:
failure = error
finally:
client.close()
try:
budget.close()
except BaseException as error:
if failure is None:
failure = error
else:
failure.add_note(f"cannot close owned CMS budget: {error}")
result["cgroup"] = budget.final
if failure is None and budget.final is not None and (
budget.final["events"]["oom"] or budget.final["events"]["oom_kill"]):
failure = RuntimeError("CMS exceeded its memory budget")
result["status"] = "failed" if failure else "passed"
if failure is not None:
result["error"] = str(failure)
write_private_json(directory / "report.json", result)
if failure is not None:
raise failure
return result
def run_postgres_case(project, output, compiler, *, articles, rbac_fixture=False):
# 构建、临时密钥和端口只写测试副本,调用者的项目配置始终保持不变。
with tempfile.TemporaryDirectory(prefix="dever-cms-postgres-") as temporary:
staged = Path(temporary) / "project"
copy_cms_project(project, staged)
return run_staged_postgres_case(staged, output, compiler, articles=articles,
rbac_fixture=rbac_fixture)
def run_staged_postgres_case(project, output, compiler, *, articles, rbac_fixture):
require(1 <= articles <= 100, "PostgreSQL CMS acceptance requires 1..100 articles")
output.mkdir(parents=True, exist_ok=False)
report = {"status": "running", "source": "dever", "tenants": {},
"scope": "PostgreSQL HTTP owner flow and physical database tenant isolation; not non-owner RBAC revocation"}
if rbac_fixture:
report["scope"] = "PostgreSQL HTTP owner flow, physical database tenant isolation and non-owner multi-role/site grant-revoke"
clients = {}
try:
setting_path = project / "config/setting.json"
initial = json.loads(setting_path.read_text())
require(initial["database"]["default"]["type"] == "postgres",
"postgres-case requires a PostgreSQL project configuration")
port = available_port()
write_private_json(setting_path, isolated_settings(initial, port), replace=True)
execute([compiler, "check", project], output, "check")
executable = project / "cms-app"
execute([compiler, "build", project, "--output", executable], output, "build", timeout=900)
report["binary"] = fingerprint(executable)
identities = {key: provision_identity(executable, output, key)
for key in ("tenant_a", "tenant_b")}
require(identities["tenant_a"][0]["tenant_id"] != identities["tenant_b"][0]["tenant_id"],
"PostgreSQL acceptance reused a tenant identity")
clients = {key: CmsClient(port) for key in identities}
with Process([executable], output / "server") as process:
report["ready_seconds"] = wait_ready(process, clients["tenant_a"])
for key, (identity, password) in identities.items():
contract = publish_workload(clients[key], process, identity, password, articles,
tenant_key=key, title_prefix=key)
permissions = clients[key].request(
"GET", "/user/authorization/admin/manage/permissions", site="admin")
require(bool(permissions), "CMS permission catalog is empty")
report["tenants"][key] = {"tenant_id": identity["tenant_id"],
"user_id": identity["user_id"], "contract": contract}
for key, client in clients.items():
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
contract = report["tenants"][key]["contract"]
require(listing["total"] == articles
and sorted(value["title"] for value in listing["items"]) == contract["titles"],
"PostgreSQL tenant query leaked another tenant's articles")
require(sorted(value["slug"] for value in listing["items"])
== sorted(f"bench-{index}" for index in range(articles)),
"PostgreSQL tenants did not preserve the same slug namespace")
report["tenants"][key]["slugs"] = sorted(value["slug"] for value in listing["items"])
identity, password = identities["tenant_a"]
clients["tenant_a"].request("POST", SESSION_PATH.format(site="admin"), {
"credential_id": identity["credential_id"], "password": password, "tenant": "tenant_b",
}, site="admin", expected=401)
front_cookie = clients["tenant_a"].cookies["front"]
clients["tenant_a"].cookies["front"] = clients["tenant_a"].cookies["admin"]
clients["tenant_a"].request("GET", PUBLICATIONS_PATH, site="front", expected=401)
clients["tenant_a"].cookies["front"] = front_cookie
if rbac_fixture:
report["rbac"] = run_postgres_rbac(executable, output, port,
clients["tenant_a"], identity)
process.sample()
process.check()
require(process.child.poll() is None, "PostgreSQL CMS exited during tenant acceptance")
report["resources"] = summarize(process.samples)
report["cross_site_cookie_rejected"] = True
report["cross_tenant_login_rejected"] = True
report["status"] = "passed"
except BaseException as error:
report["status"] = "failed"
report["error"] = str(error)
raise
finally:
for client in clients.values():
client.close()
write_private_json(output / "report.json", report)
return report
def run_cases(manifest, output, *, articles, cgroup_parent, memory_mib,
concurrency=0, read_seconds=5):
require(manifest["format"] == "dever-cms-publish-v1", "unexpected CMS manifest format")
require(1 <= articles <= 100, "articles must be 1..100")
require(0 <= concurrency <= 64, "read concurrency must be 0..64")
require(math.isfinite(read_seconds) and 0.1 <= read_seconds <= 3600,
"read seconds must be finite and within 0.1..3600")
require((memory_mib is None) == (cgroup_parent is None),
"memory budget and cgroup parent must be provided together")
output.mkdir(parents=True, exist_ok=False)
report = {"format": manifest["format"], "memory_mib": memory_mib,
"scope": "owned loopback HTTP/1, sequential publish, isolated SQLite; not a saturation result",
"cases": {}}
if concurrency:
report["scope"] = "owned loopback HTTP/1, sequential publish and closed-loop authenticated reads, isolated SQLite"
try:
for source_format in FORMATS:
report["cases"][source_format] = run_case(manifest["cases"][source_format],
output / source_format, articles=articles,
cgroup_parent=cgroup_parent, memory_mib=memory_mib,
concurrency=concurrency, read_seconds=read_seconds)
require(report["cases"]["dever"]["contract"] == report["cases"]["md"]["contract"],
"Dever and Markdown CMS changed the publish contract")
report["status"] = "passed"
except BaseException as error:
report["status"] = "failed"
report["error"] = str(error)
raise
finally:
for source_format in FORMATS:
case_report = output / source_format / "report.json"
if source_format not in report["cases"] and case_report.is_file():
report["cases"][source_format] = json.loads(case_report.read_text())
write_private_json(output / "report.json", report)
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
actions = parser.add_subparsers(dest="action", required=True)
build = actions.add_parser("build")
build.add_argument("--compiler", type=Path, default=ROOT / "target/debug/dever")
build.add_argument("--output", type=Path, required=True)
run = actions.add_parser("run")
run.add_argument("--manifest", type=Path, required=True)
run.add_argument("--output", type=Path, required=True)
run.add_argument("--articles", type=int, default=16)
run.add_argument("--cgroup-parent", type=Path)
run.add_argument("--memory-mib", type=int, choices=(64, 128))
run.add_argument("--concurrency", type=int, default=0,
help="0 disables load; otherwise 1..64 independent authenticated read clients")
run.add_argument("--read-seconds", type=float, default=5)
postgres = actions.add_parser("postgres-case")
postgres.add_argument("--project", type=Path, required=True)
postgres.add_argument("--output", type=Path, required=True)
postgres.add_argument("--compiler", type=Path, default=ROOT / "target/debug/dever")
postgres.add_argument("--articles", type=int, default=2)
postgres.add_argument("--rbac-fixture", action="store_true",
help="run test-only member CMD provided by the Rust PostgreSQL fixture")
args = parser.parse_args()
if args.action == "build":
result = build_cases(args.compiler.resolve(strict=True), args.output.resolve())
elif args.action == "postgres-case":
report = run_postgres_case(args.project.resolve(strict=True), args.output.resolve(),
args.compiler.resolve(strict=True), articles=args.articles,
rbac_fixture=args.rbac_fixture)
result = {"status": report["status"], "report": str(args.output.resolve() / "report.json")}
else:
result = run_cases(json.loads(args.manifest.read_text()), args.output.resolve(),
articles=args.articles, cgroup_parent=args.cgroup_parent,
memory_mib=args.memory_mib, concurrency=args.concurrency,
read_seconds=args.read_seconds)
result = {"status": result["status"], "memory_mib": result["memory_mib"],
"report": str(args.output / "report.json")}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()