SysDeck/bridge/benchmark.py

178 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SysDeck - Benchmark Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Wraps system benchmark operations using sysbench and phoronix-test-suite,
with attribution to the cockpit-benchmark plugin
(https://github.com/ealier/cockpit-benchmark) which is MIT licensed
by ealier.
This bridge helper invokes benchmark tools as separate processes — the
suite (MIT) and sysbench (GPL-2.0) / phoronix-test-suite (GPL-3.0)
remain independent programs. No cockpit-benchmark code is bundled.
Usage:
python3 -m sysdeck.bridge.benchmark list-tests
python3 -m sysdeck.bridge.benchmark run-cpu
python3 -m sysdeck.bridge.benchmark run-memory
python3 -m sysdeck.bridge.benchmark run-io
python3 -m sysdeck.bridge.benchmark phoronix-list
"""
import json
import re
import subprocess
import sys
from typing import Any
BENCHMARK_LICENSE = "MIT (cockpit-benchmark) + GPL-2.0 (sysbench)"
BENCHMARK_AUTHOR = "ealier (cockpit-benchmark), sysbench project"
BENCHMARK_URL = "https://github.com/ealier/cockpit-benchmark"
def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure."""
try:
return subprocess.run(
argv, capture_output=True, text=True, check=True, timeout=300,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return ""
def list_tests() -> list[dict[str, str]]:
"""List available sysbench tests."""
raw = run(["sysbench", "--help"])
# Parse available test names from sysbench --help output.
tests: list[dict[str, str]] = []
known_tests = ["cpu", "memory", "fileio", "threads", "oltp_read_only", "oltp_write_only"]
for name in known_tests:
if name in raw:
tests.append({"name": name, "tool": "sysbench"})
return tests
def run_cpu() -> dict[str, Any]:
"""Run sysbench CPU benchmark and return structured results."""
raw = run(["sysbench", "cpu", "run"])
return _parse_sysbench(raw)
def run_memory() -> dict[str, Any]:
"""Run sysbench memory benchmark and return structured results."""
raw = run(["sysbench", "memory", "run"])
return _parse_sysbench(raw)
def run_io() -> dict[str, Any]:
"""Run sysbench file I/O benchmark and return structured results."""
# Prepare test files first.
run(["sysbench", "fileio", "prepare"])
raw = run(["sysbench", "fileio", "run"])
run(["sysbench", "fileio", "cleanup"])
return _parse_sysbench(raw)
def run_test(args: list[str]) -> dict[str, Any]:
"""Run an arbitrary sysbench test by name.
The benchmark.js panel lists tests returned by ``list-tests`` (cpu,
memory, fileio, threads, oltp_read_only, oltp_write_only) and renders
a "Run" button next to each. Clicking the button calls this helper
with the test name as the first argument.
Returns the parsed sysbench result (same shape as ``run_cpu`` etc.):
``{"raw": <str>, "events_per_sec": <float|None>, "latency_ms": <float|None>}``.
On failure (sysbench absent or the test name unknown), the captured
stderr is surfaced via the ``raw`` field so the panel can render a
useful message instead of an opaque empty result.
"""
if not args:
return {
"raw": "",
"events_per_sec": None,
"latency_ms": None,
"error": "no test name provided",
}
test_name = args[0]
# v0.1.4 SECURITY: the test name is passed to `sysbench <name> run`
# as one argv element — a leading dash makes it an OPTION (e.g.
# --config=…), so validate it as a plain identifier (the sysbench
# builtin test vocabulary is cpu/memory/threads/mutex/fileio/oltp_*).
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", test_name) or test_name.startswith("-"):
return {"raw": "", "events_per_sec": None, "latency_ms": None,
"error": f"invalid sysbench test name: {test_name!r}"}
# Some sysbench tests (fileio) require a prepare step before run.
# We deliberately keep this simple — for arbitrary test names, just
# invoke ``sysbench <name> run``. If the user wants fileio with
# prepare/cleanup, they should use the dedicated run-io button.
proc = subprocess.run(
["sysbench", test_name, "run"],
capture_output=True, text=True, timeout=300,
)
raw = proc.stdout if proc.returncode == 0 else (
proc.stdout + ("\n--- stderr ---\n" + proc.stderr if proc.stderr else "")
)
parsed = _parse_sysbench(raw)
if proc.returncode != 0:
parsed["error"] = f"sysbench exited {proc.returncode}"
return parsed
def phoronix_list() -> list[dict[str, str]]:
"""List available Phoronix Test Suite benchmarks."""
raw = run(["phoronix-test-suite", "list-tests"])
return [
{"name": line.strip(), "tool": "phoronix-test-suite"}
for line in raw.splitlines()
if line.strip() and not line.strip().startswith("#")
][:20] # Cap at 20 entries for display.
def _parse_sysbench(output: str) -> dict[str, Any]:
"""Parse sysbench text output into structured data."""
result: dict[str, Any] = {"raw": output, "events_per_sec": None, "latency_ms": None}
for line in output.splitlines():
if "events per second:" in line.lower():
try:
result["events_per_sec"] = float(line.split(":")[-1].strip())
except ValueError:
pass
if "avg:" in line.lower() and "latency_ms" not in result:
parts = line.split()
for i, p in enumerate(parts):
if p == "avg:" and i + 1 < len(parts):
try:
result["latency_ms"] = float(parts[i + 1])
except ValueError:
pass
return result
COMMANDS = {
"list-tests": lambda _args: list_tests(),
"run-cpu": lambda _args: run_cpu(),
"run-memory": lambda _args: run_memory(),
"run-io": lambda _args: run_io(),
"run-test": lambda args: run_test(args),
"phoronix-list": lambda _args: phoronix_list(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))