Skip to content

Commit b4f43c2

Browse files
authored
feat: implement benchmark environment fingerprinting and manifest artifacts (#28)
* feat: add benchmark runtime fingerprint capture for #11 * feat: enforce docker benchmark resource limits for #12 * feat: persist benchmark environment manifest for #13 * fix: emit metadata for per-target benchmark runs in #3 * fix: enforce results path boundaries for benchmark metadata
1 parent 7ec4544 commit b4f43c2

6 files changed

Lines changed: 472 additions & 3 deletions

File tree

Makefile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: benchmark benchmark-modkit benchmark-nestjs benchmark-baseline benchmark-wire benchmark-fx benchmark-do report test parity-check parity-check-modkit parity-check-nestjs
1+
.PHONY: benchmark benchmark-modkit benchmark-nestjs benchmark-baseline benchmark-wire benchmark-fx benchmark-do report test parity-check parity-check-modkit parity-check-nestjs benchmark-fingerprint-check benchmark-limits-check benchmark-manifest-check
22

33
benchmark:
44
bash scripts/run-all.sh
@@ -35,3 +35,12 @@ parity-check-modkit:
3535

3636
parity-check-nestjs:
3737
TARGET=http://localhost:3002 bash scripts/parity-check.sh
38+
39+
benchmark-fingerprint-check:
40+
python3 scripts/environment-manifest.py check-fingerprint --file results/latest/environment.fingerprint.json
41+
42+
benchmark-limits-check:
43+
python3 scripts/environment-manifest.py check-limits --compose docker-compose.yml
44+
45+
benchmark-manifest-check:
46+
python3 scripts/environment-manifest.py check-manifest --file results/latest/environment.manifest.json

docker-compose.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,37 @@ version: "3.9"
33
services:
44
modkit:
55
build: ./apps/modkit
6+
cpus: ${BENCHMARK_CPU_LIMIT:-1.00}
7+
mem_limit: ${BENCHMARK_MEMORY_LIMIT:-1024m}
68
ports:
79
- "3001:3000"
810
nestjs:
911
build: ./apps/nestjs
12+
cpus: ${BENCHMARK_CPU_LIMIT:-1.00}
13+
mem_limit: ${BENCHMARK_MEMORY_LIMIT:-1024m}
1014
ports:
1115
- "3002:3000"
1216
baseline:
1317
build: ./apps/baseline
18+
cpus: ${BENCHMARK_CPU_LIMIT:-1.00}
19+
mem_limit: ${BENCHMARK_MEMORY_LIMIT:-1024m}
1420
ports:
1521
- "3003:3000"
1622
wire:
1723
build: ./apps/wire
24+
cpus: ${BENCHMARK_CPU_LIMIT:-1.00}
25+
mem_limit: ${BENCHMARK_MEMORY_LIMIT:-1024m}
1826
ports:
1927
- "3004:3000"
2028
fx:
2129
build: ./apps/fx
30+
cpus: ${BENCHMARK_CPU_LIMIT:-1.00}
31+
mem_limit: ${BENCHMARK_MEMORY_LIMIT:-1024m}
2232
ports:
2333
- "3005:3000"
2434
do:
2535
build: ./apps/do
36+
cpus: ${BENCHMARK_CPU_LIMIT:-1.00}
37+
mem_limit: ${BENCHMARK_MEMORY_LIMIT:-1024m}
2638
ports:
2739
- "3006:3000"

docs/guides/benchmark-workflow.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,30 @@ make benchmark-modkit
1919
make benchmark-nestjs
2020
```
2121

22+
Per-target runs also emit `results/latest/environment.fingerprint.json` and `results/latest/environment.manifest.json`.
23+
24+
## Docker resource limits
25+
26+
Framework services use shared default limits from `docker-compose.yml`:
27+
28+
- CPU: `BENCHMARK_CPU_LIMIT` (default `1.00`)
29+
- memory: `BENCHMARK_MEMORY_LIMIT` (default `1024m`)
30+
31+
Override for local experimentation:
32+
33+
```bash
34+
BENCHMARK_CPU_LIMIT=2.00 BENCHMARK_MEMORY_LIMIT=1536m docker compose up --build
35+
```
36+
2237
## Parity gate
2338

2439
Benchmark scripts must run parity first for each target. If parity fails, skip benchmark for that target and record the skip reason.
2540

2641
## Artifacts
2742

2843
- `results/latest/raw/*.json` - raw benchmark outputs
44+
- `results/latest/environment.fingerprint.json` - runtime and toolchain versions for the run
45+
- `results/latest/environment.manifest.json` - timestamped runner metadata and result index
2946
- `results/latest/summary.json` - normalized summary
3047
- `results/latest/report.md` - markdown report
3148

scripts/environment-manifest.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
import os
5+
import platform
6+
import subprocess
7+
from datetime import datetime, timezone
8+
from pathlib import Path
9+
10+
11+
REQUIRED_VERSION_FIELDS = [
12+
"go",
13+
"node",
14+
"npm",
15+
"python",
16+
"wrk",
17+
"docker",
18+
"docker_compose",
19+
]
20+
21+
REPO_ROOT = Path(__file__).resolve().parent.parent
22+
RESULTS_ROOT = (REPO_ROOT / "results" / "latest").resolve()
23+
24+
25+
def parse_service_blocks(compose_text):
26+
in_services = False
27+
current = None
28+
blocks = {}
29+
for raw_line in compose_text.splitlines():
30+
line = raw_line.rstrip("\n")
31+
if line.strip() == "services:":
32+
in_services = True
33+
continue
34+
if not in_services:
35+
continue
36+
37+
if line.startswith(" ") and line.endswith(":") and not line.startswith(" "):
38+
current = line.strip().rstrip(":")
39+
blocks[current] = []
40+
continue
41+
42+
if current is None:
43+
continue
44+
45+
if line.startswith(" "):
46+
blocks[current].append(line.strip())
47+
continue
48+
49+
if line.strip() and not line.startswith(" "):
50+
break
51+
return blocks
52+
53+
54+
def run_first_line(command):
55+
try:
56+
completed = subprocess.run(
57+
command,
58+
capture_output=True,
59+
text=True,
60+
check=False,
61+
)
62+
except OSError:
63+
return "unavailable"
64+
output = (completed.stdout or completed.stderr or "").strip()
65+
if not output:
66+
return "unavailable"
67+
return output.splitlines()[0].strip()
68+
69+
70+
def git_metadata():
71+
return {
72+
"commit": run_first_line(["git", "rev-parse", "HEAD"]),
73+
"branch": run_first_line(["git", "rev-parse", "--abbrev-ref", "HEAD"]),
74+
}
75+
76+
77+
def runtime_versions():
78+
docker_compose = run_first_line(["docker", "compose", "version", "--short"])
79+
if docker_compose == "unavailable":
80+
docker_compose = run_first_line(["docker-compose", "version", "--short"])
81+
82+
return {
83+
"go": run_first_line(["go", "version"]),
84+
"node": run_first_line(["node", "--version"]),
85+
"npm": run_first_line(["npm", "--version"]),
86+
"python": run_first_line(["python3", "--version"]),
87+
"wrk": run_first_line(["wrk", "--version"]),
88+
"docker": run_first_line(["docker", "--version"]),
89+
"docker_compose": docker_compose,
90+
}
91+
92+
93+
def ensure_parent(path):
94+
path.parent.mkdir(parents=True, exist_ok=True)
95+
96+
97+
def ensure_under_results(path):
98+
resolved = path.resolve()
99+
if resolved == RESULTS_ROOT or RESULTS_ROOT in resolved.parents:
100+
return
101+
raise SystemExit(f"Refusing path outside results/latest: {path}")
102+
103+
104+
def write_json(path, payload):
105+
ensure_under_results(path)
106+
ensure_parent(path)
107+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
108+
109+
110+
def collect_fingerprint(out_path):
111+
payload = {
112+
"generated_at": datetime.now(timezone.utc).isoformat(),
113+
"versions": runtime_versions(),
114+
"git": git_metadata(),
115+
}
116+
write_json(out_path, payload)
117+
print(f"Wrote: {out_path}")
118+
119+
120+
def require_non_empty_string(data, key):
121+
value = data.get(key)
122+
if not isinstance(value, str) or not value.strip():
123+
raise SystemExit(f"Invalid or missing value for: {key}")
124+
125+
126+
def check_fingerprint(path):
127+
if not path.exists():
128+
raise SystemExit(f"Fingerprint file not found: {path}")
129+
payload = json.loads(path.read_text(encoding="utf-8"))
130+
131+
versions = payload.get("versions")
132+
if not isinstance(versions, dict):
133+
raise SystemExit("Missing versions object")
134+
for key in REQUIRED_VERSION_FIELDS:
135+
require_non_empty_string(versions, key)
136+
137+
git = payload.get("git")
138+
if not isinstance(git, dict):
139+
raise SystemExit("Missing git object")
140+
require_non_empty_string(git, "commit")
141+
require_non_empty_string(git, "branch")
142+
143+
print("Fingerprint check passed")
144+
145+
146+
def check_limits(compose_path):
147+
if not compose_path.exists():
148+
raise SystemExit(f"Compose file not found: {compose_path}")
149+
150+
compose_text = compose_path.read_text(encoding="utf-8")
151+
service_blocks = parse_service_blocks(compose_text)
152+
if not service_blocks:
153+
raise SystemExit("No service blocks found under services:")
154+
155+
missing = []
156+
for service, entries in service_blocks.items():
157+
has_cpu = any(item.startswith("cpus:") and item.split(":", 1)[1].strip() for item in entries)
158+
has_mem = any(item.startswith("mem_limit:") and item.split(":", 1)[1].strip() for item in entries)
159+
if not has_cpu or not has_mem:
160+
missing.append(service)
161+
162+
if missing:
163+
raise SystemExit("Missing cpu/memory limits for services: " + ", ".join(sorted(missing)))
164+
165+
print("Docker limits check passed")
166+
167+
168+
def read_json(path):
169+
return json.loads(path.read_text(encoding="utf-8"))
170+
171+
172+
def write_manifest(raw_dir, fingerprint_path, out_path):
173+
if not raw_dir.exists():
174+
raise SystemExit(f"Raw results directory not found: {raw_dir}")
175+
if not fingerprint_path.exists():
176+
raise SystemExit(f"Fingerprint file not found: {fingerprint_path}")
177+
178+
rows = []
179+
for path in sorted(raw_dir.glob("*.json")):
180+
payload = read_json(path)
181+
rows.append(
182+
{
183+
"file": path.name,
184+
"framework": payload.get("framework"),
185+
"status": payload.get("status"),
186+
"reason": payload.get("reason"),
187+
}
188+
)
189+
190+
manifest = {
191+
"generated_at": datetime.now(timezone.utc).isoformat(),
192+
"runner": {
193+
"user": os.environ.get("USER", "unknown"),
194+
"hostname": platform.node() or "unknown",
195+
"platform": platform.platform(),
196+
"machine": platform.machine() or "unknown",
197+
},
198+
"artifacts": {
199+
"raw_dir": str(raw_dir),
200+
"fingerprint_file": str(fingerprint_path),
201+
"targets": len(rows),
202+
},
203+
"fingerprint": read_json(fingerprint_path),
204+
"targets": rows,
205+
}
206+
write_json(out_path, manifest)
207+
print(f"Wrote: {out_path}")
208+
209+
210+
def check_manifest(path):
211+
if not path.exists():
212+
raise SystemExit(f"Manifest file not found: {path}")
213+
payload = read_json(path)
214+
215+
require_non_empty_string(payload, "generated_at")
216+
217+
runner = payload.get("runner")
218+
if not isinstance(runner, dict):
219+
raise SystemExit("Missing runner object")
220+
require_non_empty_string(runner, "user")
221+
require_non_empty_string(runner, "hostname")
222+
223+
artifacts = payload.get("artifacts")
224+
if not isinstance(artifacts, dict):
225+
raise SystemExit("Missing artifacts object")
226+
require_non_empty_string(artifacts, "raw_dir")
227+
require_non_empty_string(artifacts, "fingerprint_file")
228+
229+
fingerprint = payload.get("fingerprint")
230+
if not isinstance(fingerprint, dict):
231+
raise SystemExit("Missing fingerprint object")
232+
233+
versions = fingerprint.get("versions")
234+
if not isinstance(versions, dict):
235+
raise SystemExit("Missing fingerprint versions object")
236+
for key in REQUIRED_VERSION_FIELDS:
237+
require_non_empty_string(versions, key)
238+
239+
git = fingerprint.get("git")
240+
if not isinstance(git, dict):
241+
raise SystemExit("Missing fingerprint git object")
242+
require_non_empty_string(git, "commit")
243+
require_non_empty_string(git, "branch")
244+
245+
targets = payload.get("targets")
246+
if not isinstance(targets, list):
247+
raise SystemExit("Missing targets list")
248+
249+
print("Manifest check passed")
250+
251+
252+
def parse_args():
253+
parser = argparse.ArgumentParser(description="Benchmark environment metadata helpers")
254+
sub = parser.add_subparsers(dest="cmd", required=True)
255+
256+
collect = sub.add_parser("collect-fingerprint", help="Collect runtime/toolchain fingerprint")
257+
collect.add_argument("--out", required=True, type=Path)
258+
259+
check = sub.add_parser("check-fingerprint", help="Validate fingerprint file")
260+
check.add_argument("--file", required=True, type=Path)
261+
262+
limits = sub.add_parser("check-limits", help="Validate docker-compose cpu/memory limits")
263+
limits.add_argument("--compose", required=True, type=Path)
264+
265+
manifest = sub.add_parser("write-manifest", help="Write environment manifest")
266+
manifest.add_argument("--raw-dir", required=True, type=Path)
267+
manifest.add_argument("--fingerprint", required=True, type=Path)
268+
manifest.add_argument("--out", required=True, type=Path)
269+
270+
check_manifest_cmd = sub.add_parser("check-manifest", help="Validate environment manifest")
271+
check_manifest_cmd.add_argument("--file", required=True, type=Path)
272+
273+
return parser.parse_args()
274+
275+
276+
def main():
277+
args = parse_args()
278+
if args.cmd == "collect-fingerprint":
279+
collect_fingerprint(args.out)
280+
return
281+
if args.cmd == "check-fingerprint":
282+
check_fingerprint(args.file)
283+
return
284+
if args.cmd == "check-limits":
285+
check_limits(args.compose)
286+
return
287+
if args.cmd == "write-manifest":
288+
write_manifest(args.raw_dir, args.fingerprint, args.out)
289+
return
290+
if args.cmd == "check-manifest":
291+
check_manifest(args.file)
292+
return
293+
raise SystemExit(f"Unknown command: {args.cmd}")
294+
295+
296+
if __name__ == "__main__":
297+
main()

0 commit comments

Comments
 (0)