Skip to content

Commit 80e5f1f

Browse files
authored
test(discovery): add harness CLI (#75)
1 parent d73cb3c commit 80e5f1f

1 file changed

Lines changed: 155 additions & 0 deletions

File tree

‎Discovery/tests/cli.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""python3 -m tests.cli — the harness from one command.
2+
3+
Subcommands exist along the file boundaries the design is built on: a run can
4+
be synthesized, scored, and reported independently, so a failed run is
5+
re-scored without re-running it and a scoring change is replayed against every
6+
run ever recorded.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import json
13+
import os
14+
import sys
15+
from typing import Optional, Sequence
16+
17+
from . import manifest as manifest_module
18+
from .install import runner as runner_module
19+
from .provision.driver import DryDriver
20+
from .report import html as report_html
21+
from .scoring import schema, snapshot
22+
from .scoring.score import score as score_run
23+
from .tools import synthesize as synthesize_tool
24+
25+
RECORDED = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recorded")
26+
27+
28+
def main(argv: Optional[Sequence[str]] = None) -> int:
29+
parser = _parser()
30+
args = parser.parse_args(argv)
31+
if not getattr(args, "handler", None):
32+
parser.print_help()
33+
return 2
34+
return args.handler(args)
35+
36+
37+
def _parser() -> argparse.ArgumentParser:
38+
parser = argparse.ArgumentParser(prog="tests.cli", description=__doc__.splitlines()[0])
39+
sub = parser.add_subparsers(dest="command")
40+
41+
check = sub.add_parser("check", help="static manifest checks; no guest needed")
42+
check.set_defaults(handler=_check)
43+
44+
synth = sub.add_parser("synthesize", help="build a run directory without a guest")
45+
synth.add_argument("directory")
46+
synth.add_argument("--os", dest="platform", default="linux", choices=manifest_module.PLATFORMS)
47+
synth.add_argument("--fault", action="append", default=[], choices=synthesize_tool.FAULTS)
48+
synth.add_argument("--seed", type=int, default=7)
49+
synth.set_defaults(handler=_synthesize)
50+
51+
score = sub.add_parser("score", help="score a run directory")
52+
score.add_argument("directory")
53+
score.add_argument("--os", dest="platform", default=None)
54+
score.add_argument("--json", dest="json_out", default=None)
55+
score.add_argument("--html", dest="html_out", default=None)
56+
score.add_argument("--baseline", default=None, help="previous accepted score.json")
57+
score.set_defaults(handler=_score)
58+
59+
run = sub.add_parser("run", help="restore, install, scan and score")
60+
run.add_argument("--os", dest="platform", default="linux", choices=manifest_module.PLATFORMS)
61+
run.add_argument("--dry", action="store_true", help="record commands, touch no guest")
62+
run.add_argument("--out", default=None)
63+
run.set_defaults(handler=_run)
64+
65+
return parser
66+
67+
68+
def _check(args: argparse.Namespace) -> int:
69+
manifest = manifest_module.load()
70+
try:
71+
manifest.validate()
72+
except manifest_module.ManifestError as broken:
73+
print(str(broken), file=sys.stderr)
74+
return 1
75+
print(f"manifest ok — {len(manifest)} entries, {len(manifest.canary_names())} canaries")
76+
for platform in manifest_module.PLATFORMS:
77+
print(f" {platform:6s} {len(manifest.for_platform(platform))} applicable")
78+
return 0
79+
80+
81+
def _synthesize(args: argparse.Namespace) -> int:
82+
plan = synthesize_tool.Plan(platform=args.platform, faults=tuple(args.fault), seed=args.seed)
83+
written = synthesize_tool.build(args.directory, plan=plan)
84+
print(f"wrote {len(written)} files to {args.directory}")
85+
for name in sorted(written):
86+
print(f" {name}")
87+
return 0
88+
89+
90+
def _score(args: argparse.Namespace) -> int:
91+
run = snapshot.load(args.directory)
92+
manifest = manifest_module.load()
93+
result = score_run(run, manifest, platform=args.platform or run.os)
94+
95+
previous = None
96+
if args.baseline and os.path.isfile(args.baseline):
97+
with open(args.baseline, encoding="utf-8") as handle:
98+
previous = json.load(handle)
99+
gate = schema.gate(result, previous)
100+
101+
if args.json_out:
102+
_write(args.json_out, schema.to_json(result))
103+
if args.html_out:
104+
_write(args.html_out, report_html.render(result, gate))
105+
106+
_print(result, gate)
107+
return 0 if gate.passed else 1
108+
109+
110+
def _run(args: argparse.Namespace) -> int:
111+
if not args.dry:
112+
print("only --dry is implemented: the QEMU and tart drivers are not built yet",
113+
file=sys.stderr)
114+
return 2
115+
116+
manifest = manifest_module.load()
117+
driver = DryDriver()
118+
driver.restore()
119+
actual = runner_module.run(driver, manifest, platform=args.platform,
120+
image="dry", collector="dry")
121+
if args.out:
122+
os.makedirs(args.out, exist_ok=True)
123+
runner_module.write(actual, os.path.join(args.out, snapshot.ACTUAL))
124+
125+
print(f"{args.platform}: {actual['applicable']} applicable, "
126+
f"{actual['installed']} installed, {actual['unavailable']} unavailable, "
127+
f"{actual['failed']} failed")
128+
print(f"{len(driver.commands)} commands recorded; no guest was touched")
129+
return 0
130+
131+
132+
def _print(result, gate) -> None:
133+
totals = result.totals
134+
print(f"{result.os}: tp {totals.tp} fp {totals.fp} fn {totals.fn} dup {totals.dup}")
135+
print(f" recall {_ratio(totals.recall)} precision {_ratio(totals.precision)}")
136+
print(f" canaries planted {result.canaries.planted} leaked {result.canaries.leaked}")
137+
print(" gate: " + ("pass" if gate.passed else "FAIL"))
138+
for reason in gate.reasons:
139+
print(f" - {reason}")
140+
141+
142+
def _ratio(value: Optional[float]) -> str:
143+
return "—" if value is None else f"{value:.4g}"
144+
145+
146+
def _write(path: str, body: str) -> None:
147+
parent = os.path.dirname(os.path.abspath(path))
148+
if parent:
149+
os.makedirs(parent, exist_ok=True)
150+
with open(path, "w", encoding="utf-8") as handle:
151+
handle.write(body if body.endswith("\n") else body + "\n")
152+
153+
154+
if __name__ == "__main__":
155+
raise SystemExit(main())

0 commit comments

Comments
 (0)