rpki/scripts/experiments/feature035/run_feature035_experiment.py

1153 lines
40 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parents[2]
DEV_ROOT = REPO_ROOT.parents[1]
PORTABLE_ROOT = DEV_ROOT / "rpki-client-portable"
FEATURE_BUNDLE = SCRIPT_DIR / "feature035_bundle.py"
EXPERIMENTS_PATH = SCRIPT_DIR / "experiments.json"
FIXTURE_MANIFEST_PATH = SCRIPT_DIR / "fixture-manifest.json"
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
json.dump(value, handle, indent=2, ensure_ascii=False)
handle.write("\n")
def ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def git_commit(repo_root: Path) -> str:
result = run_local(
["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
capture=True,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else ""
def run_local(
argv: list[str],
*,
cwd: Path | None = None,
check: bool = True,
capture: bool = False,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
argv,
cwd=str(cwd) if cwd else None,
text=True,
check=False,
capture_output=capture,
env=env,
)
if check and result.returncode != 0:
raise SystemExit(
f"command failed ({result.returncode}): {' '.join(shlex.quote(item) for item in argv)}\n"
f"stdout:\n{result.stdout if result.stdout else ''}\n"
f"stderr:\n{result.stderr if result.stderr else ''}"
)
return result
def ssh_script(target: str, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["ssh", target, "bash", "-s"],
input=script,
text=True,
check=False,
)
if check and result.returncode != 0:
raise SystemExit(f"remote script failed ({result.returncode}) on {target}")
return result
def rsync_to_remote(target: str, source: Path, destination: str) -> None:
run_local(["rsync", "-a", str(source), f"{target}:{destination}"])
def rsync_dir_to_remote(target: str, source: Path, destination: str) -> None:
run_local(["rsync", "-a", f"{source}/", f"{target}:{destination}/"])
def rsync_from_remote(target: str, source: str, destination: Path) -> None:
ensure_dir(destination)
run_local(["rsync", "-a", f"{target}:{source}/", f"{destination}/"])
def rel_cmd(parts: list[str]) -> str:
return shlex.join(str(part) for part in parts)
def utc_stamp() -> str:
return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
def detect_libtls_path(rpki_client_bin: Path) -> Path:
ldd = run_local(["ldd", str(rpki_client_bin)], capture=True)
for line in ldd.stdout.splitlines():
if "libtls.so.28" not in line:
continue
if "=>" not in line:
continue
candidate = line.split("=>", 1)[1].strip().split(" ", 1)[0]
path = Path(candidate)
if path.is_file():
return path
fallback = DEV_ROOT / ".cache" / "rpki-client-9.8-cir" / "libtls.so.28"
if fallback.is_file():
return fallback
raise SystemExit("unable to locate libtls.so.28 for rpki-client")
def parse_elapsed_to_ms(raw: str) -> int:
raw = raw.strip()
if not raw:
return 0
if "-" in raw:
days, raw = raw.split("-", 1)
else:
days = "0"
parts = raw.split(":")
try:
if len(parts) == 4:
days = str(int(days) + int(parts[0]))
hours, minutes, seconds = parts[1:]
elif len(parts) == 3:
hours, minutes, seconds = parts
elif len(parts) == 2:
hours = "0"
minutes, seconds = parts
else:
hours = "0"
minutes = "0"
seconds = parts[0]
total_seconds = (
int(days) * 86400 + int(hours) * 3600 + int(minutes) * 60 + float(seconds)
)
except ValueError:
time_part = raw.rsplit(":", 1)[-1]
total_seconds = float(time_part)
return int(round(total_seconds * 1000))
def parse_time_file(path: Path) -> dict[str, Any]:
data: dict[str, Any] = {}
if not path.is_file():
return data
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if "Elapsed (wall clock) time" in line and ":" in line:
if "):" in line:
elapsed = line.rsplit("):", 1)[1]
else:
elapsed = line.rsplit(":", 1)[1]
data["wallMs"] = parse_elapsed_to_ms(elapsed)
elif "Maximum resident set size" in line and ":" in line:
try:
data["maxRssKb"] = int(line.rsplit(":", 1)[1].strip())
except ValueError:
pass
elif "User time (seconds)" in line and ":" in line:
try:
data["userSeconds"] = float(line.rsplit(":", 1)[1].strip())
except ValueError:
pass
elif "System time (seconds)" in line and ":" in line:
try:
data["systemSeconds"] = float(line.rsplit(":", 1)[1].strip())
except ValueError:
pass
return data
def path_within(base: Path, path: Path) -> str:
return path.relative_to(base).as_posix()
def load_report_counts(report_path: Path) -> dict[str, Any]:
report = load_json(report_path)
publication_points = report.get("publication_points", [])
tree = report.get("tree", {})
warnings = tree.get("warnings", [])
return {
"vrps": len(report.get("vrps", [])),
"aspas": len(report.get("aspas", [])),
"publicationPoints": len(publication_points),
"warnings": len(warnings)
+ sum(len(pp.get("warnings", [])) for pp in publication_points if isinstance(pp, dict)),
"treeInstancesProcessed": tree.get("instances_processed"),
"treeInstancesFailed": tree.get("instances_failed"),
"reportJson": report,
}
def load_rpki_client_counts(report_path: Path) -> dict[str, Any]:
report = load_json(report_path)
metadata = report.get("metadata", {})
roas = report.get("roas", [])
aspas = report.get("aspas", [])
return {
"vrps": int(metadata.get("vrps", len(roas))),
"uniqueVrps": int(metadata.get("uniquevrps", len(roas))),
"aspas": int(metadata.get("aspas", len(aspas))),
"uniqueVaps": int(metadata.get("uniquevaps", len(aspas))),
"repositories": int(metadata.get("repositories", 0)),
"warnings": 0,
"reportJson": report,
}
def load_cir_counts(cir_path: Path) -> dict[str, int]:
if not cir_path.is_file():
return {
"cirObjectCount": 0,
"cirRejectCount": 0,
"cirTrustAnchorCount": 0,
}
result = run_local(
[
str(REPO_ROOT / "target" / "release" / "cir_dump_reject_list"),
"--cir",
str(cir_path),
"--limit",
"0",
],
capture=True,
check=False,
)
if result.returncode != 0:
raise SystemExit(f"decode CIR failed: {cir_path}\n{result.stderr}")
values: dict[str, int] = {}
for line in result.stdout.splitlines():
if "=" not in line:
continue
key, value = line.split("=", 1)
if key in {"object_count", "trust_anchor_count", "reject_count"}:
values[key] = int(value)
return {
"cirObjectCount": values.get("object_count", 0),
"cirRejectCount": values.get("reject_count", 0),
"cirTrustAnchorCount": values.get("trust_anchor_count", 0),
}
def copy_rpki_client_outputs(run_dir: Path) -> None:
if (run_dir / "json").is_file():
shutil.copy2(run_dir / "json", run_dir / "report.json")
if (run_dir / "rpki.ccr").is_file():
shutil.copy2(run_dir / "rpki.ccr", run_dir / "result.ccr")
if (run_dir / "rpki.cir").is_file():
shutil.copy2(run_dir / "rpki.cir", run_dir / "result.cir")
def write_rpki_client_stage_timing(run_dir: Path) -> None:
report_path = run_dir / "report.json"
if not report_path.is_file():
return
report = load_json(report_path)
metadata = report.get("metadata", {})
stage_timing = {
"tool": "rpki-client-portable",
"metadata": {
"elapsedTimeSeconds": metadata.get("elapsedtime"),
"userTimeSeconds": metadata.get("usertime"),
"systemTimeSeconds": metadata.get("systemtime"),
},
"counts": {
"repositories": metadata.get("repositories"),
"vrps": metadata.get("vrps"),
"uniqueVrps": metadata.get("uniquevrps"),
"vaps": metadata.get("vaps"),
"uniqueVaps": metadata.get("uniquevaps"),
},
}
write_json(run_dir / "stage-timing.json", stage_timing)
def build_tool_binaries() -> None:
run_local(
[
"cargo",
"build",
"--release",
"--bin",
"rpki",
"--bin",
"triage_ccr_cir_pair",
"--bin",
"cir_dump_reject_list",
],
cwd=REPO_ROOT,
)
run_local(["make", "-j2"], cwd=PORTABLE_ROOT)
def build_fixture_proof(run_root: Path, rirs: list[str]) -> Path:
fixture_dir = REPO_ROOT / "tests" / "fixtures"
fixture_proof = run_root / "fixture-proof.json"
run_local(
[
sys.executable,
str(FEATURE_BUNDLE),
"fixture-proof",
"--fixture-dir",
str(fixture_dir),
"--repo-root",
str(run_root),
"--rirs",
",".join(rirs),
"--out",
str(fixture_proof),
]
)
return fixture_proof
def experiment_steps(exp: dict[str, Any]) -> list[dict[str, str]]:
return [
{
"step": "snapshot",
"left": exp["left"],
"right": exp["right"],
},
{
"step": "delta",
"left": exp["left"],
"right": exp["right"],
},
]
def render_experiment_plan(exp: dict[str, Any], run_root: Path, remote_root: Path, ssh_target: str, rirs: list[str]) -> dict[str, Any]:
plan_steps: list[dict[str, Any]] = []
for step in ("snapshot", "delta"):
plan_steps.append(
{
"step": step,
"leftRunDir": str(run_root / "experiments" / exp["id"] / "A" / step),
"rightRunDir": str(run_root / "experiments" / exp["id"] / "B" / step),
"remoteLeftRunDir": str(remote_root / "experiments" / exp["id"] / "A" / step),
"remoteRightRunDir": str(remote_root / "experiments" / exp["id"] / "B" / step),
"leftCommand": build_side_command(remote_root, exp["id"], exp["left"], "A", step, rirs),
"rightCommand": build_side_command(remote_root, exp["id"], exp["right"], "B", step, rirs),
}
)
return {
"id": exp["id"],
"left": exp["left"],
"right": exp["right"],
"sshTarget": ssh_target,
"remoteRoot": str(remote_root),
"runRoot": str(run_root),
"steps": plan_steps,
}
def build_side_command(
remote_root: Path,
exp_id: str,
side: dict[str, Any],
side_label: str,
step: str,
rirs: list[str],
) -> str:
run_dir = remote_root / "experiments" / exp_id / side_label / step
state_dir = remote_root / "experiments" / exp_id / side_label / "state" / ("ours" if side["rpKind"] == "ours" else "rpki-client")
fixture_root = remote_root / "fixtures"
if side["rpKind"] == "ours":
argv = [
str(remote_root / "bin" / "rpki"),
"--db",
str(state_dir / "work-db"),
"--raw-store-db",
str(state_dir / "raw-store.db"),
"--repo-bytes-db",
str(state_dir / "repo-bytes.db"),
]
for rir in rirs:
argv.extend(
[
"--tal-path",
str(fixture_root / "tal" / fixture_name(rir, "tal")),
"--ta-path",
str(fixture_root / "ta" / fixture_name(rir, "ta")),
]
)
if side["protocol"] == "rsync-only":
argv.append("--disable-rrdp")
if side["mode"] == "strict-name":
argv.extend(["--strict", "name"])
elif side["mode"] == "strict-cms-der":
argv.extend(["--strict", "cms-der"])
elif side["mode"] == "strict-signed-attrs":
argv.extend(["--strict", "signed-attrs"])
elif side["mode"] == "strict-all":
argv.extend(["--strict", "all"])
argv.extend(
[
"--report-json",
str(run_dir / "report.json"),
"--report-json-compact",
"--ccr-out",
str(run_dir / "result.ccr"),
"--cir-enable",
"--cir-out",
str(run_dir / "result.cir"),
]
)
for rir in rirs:
argv.extend(["--cir-tal-uri", cir_tal_uri_for_rir(rir)])
argv.extend(
[
"--vrps-csv-out",
str(run_dir / "vrps.csv"),
"--vaps-csv-out",
str(run_dir / "vaps.csv"),
"--compare-view-trust-anchor",
compare_view_trust_anchor(rirs),
]
)
return "cd {run_dir} && {prefix} /usr/bin/time -v -o process-time.txt -- {cmd} > stdout.log 2> stderr.log".format(
run_dir=shlex.quote(str(run_dir)),
prefix=_setup_prefix(step, state_dir, "ours"),
cmd=rel_cmd(argv),
)
argv = [
str(remote_root / "bin" / "rpki-client"),
"-vv",
"-S",
str(state_dir / "rpki-client-skiplist"),
]
if side["protocol"] == "rsync-only":
argv.append("-R")
for rir in rirs:
argv.extend(
[
"-t",
str(fixture_root / "tal" / fixture_name(rir, "tal")),
"-T",
f"{fixture_desc(rir)}:{state_dir / 'cache' / 'fixtures' / fixture_name(rir, 'ta')}",
]
)
argv.extend(["-d", str(state_dir / "cache"), str(run_dir)])
return "cd {run_dir} && {prefix} LD_LIBRARY_PATH={lib} /usr/bin/time -v -o process-time.txt -- {cmd} > stdout.log 2> stderr.log".format(
run_dir=shlex.quote(str(run_dir)),
prefix=_setup_prefix(step, state_dir, "rpki-client"),
lib=shlex.quote(str(remote_root / "lib")),
cmd=rel_cmd(argv),
)
def _setup_prefix(step: str, state_dir: Path, kind: str) -> str:
if step == "snapshot":
if kind == "ours":
return "rm -rf {state} && mkdir -p {state}/work-db {state}/rsync-mirror && chmod -R 0777 {state} && ".format(
state=shlex.quote(str(state_dir))
)
return "rm -rf {state} && mkdir -p {state}/cache {state}/cache/fixtures && touch {state}/rpki-client-skiplist && chmod -R 0777 {state} && ".format(
state=shlex.quote(str(state_dir))
)
if kind == "ours":
return "mkdir -p {state}/work-db {state}/rsync-mirror && chmod -R 0777 {state} && ".format(
state=shlex.quote(str(state_dir))
)
return "mkdir -p {state}/cache {state}/cache/fixtures && touch {state}/rpki-client-skiplist && chmod -R 0777 {state} && ".format(
state=shlex.quote(str(state_dir))
)
def fixture_name(rir: str, kind: str) -> str:
fixture_manifest = load_json(FIXTURE_MANIFEST_PATH)
return Path(fixture_manifest["rirs"][rir][kind]).name
def fixture_desc(rir: str) -> str:
return {
"afrinic": "afrinic",
"apnic": "apnic-rfc7730-https",
"arin": "arin",
"lacnic": "lacnic",
"ripe": "ripe-ncc",
}[rir]
def cir_tal_uri_for_rir(rir: str) -> str:
return {
"afrinic": "https://rpki.afrinic.net/tal/afrinic.tal",
"apnic": "https://rpki.apnic.net/tal/apnic-rfc7730-https.tal",
"arin": "https://www.arin.net/resources/manage/rpki/arin.tal",
"lacnic": "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal",
"ripe": "https://tal.rpki.ripe.net/ripe-ncc.tal",
}[rir]
def compare_view_trust_anchor(rirs: list[str]) -> str:
return "all5" if len(rirs) > 1 else rirs[0]
def sanitize_run_meta(
run_root: Path,
exp_id: str,
side_label: str,
step: str,
rp_kind: str,
rp_mode: str,
protocol: str,
strict_policies: str,
rirs: list[str],
run_dir: Path,
fixture_proof: Path,
result_ccr: Path,
result_cir: Path,
report_json: Path,
stage_timing_json: Path,
process_time: Path,
stdout_log: Path,
stderr_log: Path,
exit_code: int,
counts: dict[str, Any],
time_info: dict[str, Any],
fixture_pinned: bool,
) -> Path:
repo_root = run_root
run_meta_path = run_dir / "run-meta.json"
run_meta_args = [
sys.executable,
str(FEATURE_BUNDLE),
"run-meta",
"--out",
str(run_meta_path),
"--repo-root",
str(repo_root),
"--experiment-id",
exp_id,
"--side",
"left" if side_label == "A" else "right",
"--side-label",
side_label,
"--step",
step,
"--run-id",
f"{side_label}-{step}",
"--rp-kind",
rp_kind,
"--rp-binary",
"bin/rpki" if rp_kind == "ours" else "bin/rpki-client",
"--rp-version",
"portable" if rp_kind == "rpki-client" else "ours",
"--rp-mode",
rp_mode,
"--protocol-mode",
protocol,
"--strict-policies",
strict_policies,
"--rirs",
",".join(rirs),
"--argv-json",
json.dumps([]),
"--env-json",
json.dumps({}),
"--cwd",
str(run_root),
"--reset-before-run",
"--state-root",
str(run_dir.parent / "state"),
"--db",
str(run_dir.parent / "state" / "work-db"),
"--repo-bytes-db",
str(run_dir.parent / "state" / "repo-bytes.db"),
"--raw-store-db",
str(run_dir.parent / "state" / "raw-store.db"),
"--rsync-mirror-root",
str(run_dir.parent / "state" / "rsync-mirror"),
"--cache-root",
str(run_dir.parent / "state" / ("cache" if rp_kind == "rpki-client" else "work-db")),
"--ccr",
str(result_ccr),
"--cir",
str(result_cir),
"--fixture-proof",
str(fixture_proof),
"--fixture-proof-summary-json",
json.dumps(
{
"taFixturePinned": fixture_pinned,
"taOnlineFetchObserved": False,
"trustAnchorCount": len(rirs),
}
),
"--report-json",
str(report_json),
"--stage-timing-json",
str(stage_timing_json),
"--stdout-log",
str(stdout_log),
"--stderr-log",
str(stderr_log),
"--process-time",
str(process_time),
"--exit-code",
str(exit_code),
"--wall-ms",
str(time_info.get("wallMs", 0)),
"--max-rss-kb",
str(time_info.get("maxRssKb", 0)),
"--vrps",
str(counts.get("vrps", 0)),
"--vaps",
str(counts.get("aspas", 0)),
"--publication-points",
str(counts.get("publicationPoints", counts.get("repositories", 0))),
"--warnings",
str(counts.get("warnings", 0)),
"--cir-object-count",
str(counts.get("cirObjectCount", 0)),
"--cir-reject-count",
str(counts.get("cirRejectCount", 0)),
"--cir-trust-anchor-count",
str(counts.get("cirTrustAnchorCount", len(rirs))),
"--host",
os.uname().nodename,
"--platform",
sys.platform,
]
run_local(run_meta_args)
return run_meta_path
def run_remote_step(
ssh_target: str,
remote_root: Path,
exp_id: str,
side_label: str,
step: str,
side: dict[str, Any],
rirs: list[str],
) -> None:
exp_root = remote_root / "experiments" / exp_id
run_dir = exp_root / side_label / step
state_dir = exp_root / side_label / "state" / ("ours" if side["rpKind"] == "ours" else "rpki-client")
ensure = [
f"mkdir -p {shlex.quote(str(run_dir))}",
f"mkdir -p {shlex.quote(str(run_dir.parent))}",
f"chmod 0777 {shlex.quote(str(run_dir))}",
]
if side["rpKind"] == "ours":
ensure.extend(
[
f"mkdir -p {shlex.quote(str(state_dir / 'work-db'))}",
f"mkdir -p {shlex.quote(str(state_dir / 'rsync-mirror'))}",
f"chmod -R 0777 {shlex.quote(str(exp_root / side_label / 'state'))}",
]
)
if step == "snapshot":
ensure.insert(0, f"rm -rf {shlex.quote(str(exp_root / side_label / 'state' / 'ours'))}")
argv = [
str(remote_root / "bin" / "rpki"),
"--db",
str(state_dir / "work-db"),
"--raw-store-db",
str(state_dir / "raw-store.db"),
"--repo-bytes-db",
str(state_dir / "repo-bytes.db"),
]
for rir in rirs:
argv.extend(
[
"--tal-path",
str(remote_root / "fixtures" / "tal" / fixture_name(rir, "tal")),
"--ta-path",
str(remote_root / "fixtures" / "ta" / fixture_name(rir, "ta")),
]
)
if side["protocol"] == "rsync-only":
argv.append("--disable-rrdp")
if side["mode"] == "strict-name":
argv.extend(["--strict", "name"])
elif side["mode"] == "strict-cms-der":
argv.extend(["--strict", "cms-der"])
elif side["mode"] == "strict-signed-attrs":
argv.extend(["--strict", "signed-attrs"])
elif side["mode"] == "strict-all":
argv.extend(["--strict", "all"])
argv.extend(
[
"--report-json",
str(run_dir / "report.json"),
"--report-json-compact",
"--ccr-out",
str(run_dir / "result.ccr"),
"--cir-enable",
"--cir-out",
str(run_dir / "result.cir"),
]
)
for rir in rirs:
argv.extend(["--cir-tal-uri", cir_tal_uri_for_rir(rir)])
argv.extend(
[
"--vrps-csv-out",
str(run_dir / "vrps.csv"),
"--vaps-csv-out",
str(run_dir / "vaps.csv"),
"--compare-view-trust-anchor",
compare_view_trust_anchor(rirs),
]
)
else:
ensure.extend(
[
f"mkdir -p {shlex.quote(str(state_dir / 'cache'))}",
f"mkdir -p {shlex.quote(str(state_dir / 'cache' / 'fixtures'))}",
f"touch {shlex.quote(str(state_dir / 'rpki-client-skiplist'))}",
f"chmod -R 0777 {shlex.quote(str(exp_root / side_label / 'state'))}",
]
)
if step == "snapshot":
ensure.insert(0, f"rm -rf {shlex.quote(str(exp_root / side_label / 'state' / 'rpki-client'))}")
for rir in rirs:
ensure.append(
f"cp -f {shlex.quote(str(remote_root / 'fixtures' / 'ta' / fixture_name(rir, 'ta')))} {shlex.quote(str(state_dir / 'cache' / 'fixtures' / fixture_name(rir, 'ta')))}"
)
argv = [
str(remote_root / "bin" / "rpki-client"),
"-vv",
"-S",
str(state_dir / "rpki-client-skiplist"),
]
if side["protocol"] == "rsync-only":
argv.append("-R")
for rir in rirs:
argv.extend(
[
"-t",
str(remote_root / "fixtures" / "tal" / fixture_name(rir, "tal")),
"-T",
f"{fixture_desc(rir)}:{state_dir / 'cache' / 'fixtures' / fixture_name(rir, 'ta')}",
]
)
argv.extend(["-d", str(state_dir / "cache"), str(run_dir)])
time_prefix = "/usr/bin/time"
if side["rpKind"] == "ours":
time_prefix = "env RPKI_PROGRESS_LOG=1 RPKI_PROGRESS_SLOW_SECS=10 /usr/bin/time"
elif side["rpKind"] == "rpki-client":
time_prefix = f"env LD_LIBRARY_PATH={shlex.quote(str(remote_root / 'lib'))} /usr/bin/time"
command = (
"set -euo pipefail; "
+ "; ".join(ensure)
+ "; "
+ "set +e; "
+ time_prefix
+ " -v -o "
+ shlex.quote(str(run_dir / "process-time.txt"))
+ " -- "
+ rel_cmd(argv)
+ " > "
+ shlex.quote(str(run_dir / "stdout.log"))
+ " 2> "
+ shlex.quote(str(run_dir / "stderr.log"))
+ "; ec=$?; set -e; printf '%s\n' \"$ec\" > "
+ shlex.quote(str(run_dir / "exit-code.txt"))
+ "; true"
)
ssh_script(ssh_target, command)
if side["rpKind"] == "rpki-client":
copy_cmd = (
f"[ -f {shlex.quote(str(run_dir / 'json'))} ] && cp -f {shlex.quote(str(run_dir / 'json'))} {shlex.quote(str(run_dir / 'report.json'))} || true; "
f"[ -f {shlex.quote(str(run_dir / 'rpki.ccr'))} ] && cp -f {shlex.quote(str(run_dir / 'rpki.ccr'))} {shlex.quote(str(run_dir / 'result.ccr'))} || true; "
f"[ -f {shlex.quote(str(run_dir / 'rpki.cir'))} ] && cp -f {shlex.quote(str(run_dir / 'rpki.cir'))} {shlex.quote(str(run_dir / 'result.cir'))} || true; "
f"[ -f {shlex.quote(str(run_dir / 'report.json'))} ] && python3 - <<'PY' {shlex.quote(str(run_dir / 'report.json'))} {shlex.quote(str(run_dir / 'stage-timing.json'))} || true\n"
"import json, sys\n"
"report = json.load(open(sys.argv[1]))\n"
"meta = report.get('metadata', {})\n"
"stage = {\n"
" 'tool': 'rpki-client-portable',\n"
" 'metadata': {\n"
" 'elapsedTimeSeconds': meta.get('elapsedtime'),\n"
" 'userTimeSeconds': meta.get('usertime'),\n"
" 'systemTimeSeconds': meta.get('systemtime'),\n"
" },\n"
" 'counts': {\n"
" 'repositories': meta.get('repositories'),\n"
" 'vrps': meta.get('vrps'),\n"
" 'uniqueVrps': meta.get('uniquevrps'),\n"
" 'vaps': meta.get('vaps'),\n"
" 'uniqueVaps': meta.get('uniquevaps'),\n"
" }\n"
"}\n"
"json.dump(stage, open(sys.argv[2], 'w'), indent=2)\n"
"print()\n"
"PY"
)
ssh_script(ssh_target, copy_cmd)
def parse_exit_code(path: Path) -> int:
if not path.is_file():
return 1
return int(path.read_text(encoding="utf-8").strip() or "1")
def generate_run_meta(
local_exp_root: Path,
exp_id: str,
side_label: str,
step: str,
side: dict[str, Any],
rirs: list[str],
fixture_proof: Path,
run_dir: Path,
) -> Path:
result_ccr = run_dir / "result.ccr"
result_cir = run_dir / "result.cir"
report_json = run_dir / "report.json"
stage_timing_json = run_dir / "stage-timing.json"
process_time = run_dir / "process-time.txt"
stdout_log = run_dir / "stdout.log"
stderr_log = run_dir / "stderr.log"
exit_code = parse_exit_code(run_dir / "exit-code.txt")
time_info = parse_time_file(process_time)
if side["rpKind"] == "ours":
counts = load_report_counts(report_json)
counts.update(load_cir_counts(result_cir))
else:
counts = load_rpki_client_counts(report_json)
counts.update(load_cir_counts(result_cir))
strict_policies = ""
if side["mode"] == "strict-name":
strict_policies = "name"
elif side["mode"] == "strict-cms-der":
strict_policies = "cms-der"
elif side["mode"] == "strict-signed-attrs":
strict_policies = "signed-attrs"
elif side["mode"] == "strict-all":
strict_policies = "all"
meta_path = local_exp_root / side_label / step / "run-meta.json"
generate_meta_args = [
sys.executable,
str(FEATURE_BUNDLE),
"run-meta",
"--out",
str(meta_path),
"--repo-root",
str(local_exp_root),
"--experiment-id",
exp_id,
"--side",
"left" if side_label == "A" else "right",
"--side-label",
side_label,
"--step",
step,
"--run-id",
f"{side_label}-{step}",
"--rp-kind",
side["rpKind"],
"--rp-binary",
"bin/rpki" if side["rpKind"] == "ours" else "bin/rpki-client",
"--rp-version",
"portable-9.8" if side["rpKind"] == "rpki-client" else "ours-dev",
"--rp-git-commit",
git_commit(REPO_ROOT) or "",
"--rp-mode",
side["mode"],
"--protocol-mode",
side["protocol"],
"--strict-policies",
strict_policies,
"--rirs",
",".join(rirs),
"--argv-json",
json.dumps([]),
"--env-json",
json.dumps({}),
"--cwd",
str(local_exp_root),
"--state-root",
str(local_exp_root / side_label / "state"),
"--db",
str(local_exp_root / side_label / "state" / "ours" / "work-db"),
"--repo-bytes-db",
str(local_exp_root / side_label / "state" / "ours" / "repo-bytes.db"),
"--raw-store-db",
str(local_exp_root / side_label / "state" / "ours" / "raw-store.db"),
"--rsync-mirror-root",
str(local_exp_root / side_label / "state" / "ours" / "rsync-mirror"),
"--cache-root",
str(local_exp_root / side_label / "state" / "rpki-client" / "cache"),
"--ccr",
str(result_ccr),
"--cir",
str(result_cir),
"--fixture-proof",
str(fixture_proof),
"--fixture-proof-summary-json",
json.dumps(
{
"taFixturePinned": True,
"taOnlineFetchObserved": False,
"trustAnchorCount": len(rirs),
}
),
"--report-json",
str(report_json),
"--stage-timing-json",
str(stage_timing_json),
"--stdout-log",
str(stdout_log),
"--stderr-log",
str(stderr_log),
"--process-time",
str(process_time),
"--exit-code",
str(exit_code),
"--wall-ms",
str(int(time_info.get("wallMs", 0))),
"--max-rss-kb",
str(int(time_info.get("maxRssKb", 0))),
"--vrps",
str(int(counts.get("vrps", 0))),
"--vaps",
str(int(counts.get("aspas", 0))),
"--publication-points",
str(int(counts.get("publicationPoints", counts.get("repositories", 0)))),
"--warnings",
str(int(counts.get("warnings", 0))),
"--cir-object-count",
str(int(counts.get("cirObjectCount", 0))),
"--cir-reject-count",
str(int(counts.get("cirRejectCount", 0))),
"--cir-trust-anchor-count",
str(int(counts.get("cirTrustAnchorCount", len(rirs)))),
"--host",
os.uname().nodename,
"--platform",
sys.platform,
]
if step == "snapshot":
insert_at = generate_meta_args.index("--state-root")
generate_meta_args.insert(insert_at, "--reset-before-run")
run_local(generate_meta_args)
return meta_path
def run_experiment(
ssh_target: str,
local_run_root: Path,
remote_root: Path,
exp: dict[str, Any],
rirs: list[str],
dry_run: bool = False,
) -> dict[str, Any]:
exp_id = exp["id"]
local_exp_root = local_run_root / "experiments" / exp_id
remote_exp_root = remote_root / "experiments" / exp_id
ensure_dir(local_exp_root)
if dry_run:
return render_experiment_plan(exp, local_run_root, remote_root, ssh_target, rirs)
fixture_proof = build_fixture_proof(local_exp_root, rirs)
preflight = (
"set -euo pipefail; "
f"df -h /data / || true; "
"systemctl disable --now rpki-client.timer >/dev/null 2>&1 || true; "
"systemctl stop rpki-client.service >/dev/null 2>&1 || true; "
"pkill -f '[/]rpki-client([[:space:]]|$)' >/dev/null 2>&1 || true; "
"pkill -f '[/]routinator([[:space:]]|$)' >/dev/null 2>&1 || true; "
"id -u _rpki-client >/dev/null 2>&1 || useradd -r -M -s /usr/sbin/nologin _rpki-client || true; "
f"mkdir -p {shlex.quote(str(remote_root / 'bin'))} {shlex.quote(str(remote_root / 'lib'))} {shlex.quote(str(remote_root / 'fixtures' / 'tal'))} {shlex.quote(str(remote_root / 'fixtures' / 'ta'))} {shlex.quote(str(remote_exp_root))}"
)
ssh_script(ssh_target, preflight)
rsync_dir_to_remote(ssh_target, REPO_ROOT / "tests" / "fixtures" / "tal", remote_root / "fixtures" / "tal")
rsync_dir_to_remote(ssh_target, REPO_ROOT / "tests" / "fixtures" / "ta", remote_root / "fixtures" / "ta")
if exp["left"]["rpKind"] == "rpki-client" or exp["right"]["rpKind"] == "rpki-client":
rsync_to_remote(ssh_target, detect_libtls_path(PORTABLE_ROOT / "src" / "rpki-client"), str(remote_root / "lib" / "libtls.so.28"))
rsync_to_remote(ssh_target, PORTABLE_ROOT / "src" / "rpki-client", str(remote_root / "bin" / "rpki-client"))
rsync_to_remote(ssh_target, REPO_ROOT / "target" / "release" / "rpki", str(remote_root / "bin" / "rpki"))
rsync_to_remote(ssh_target, REPO_ROOT / "target" / "release" / "triage_ccr_cir_pair", str(remote_root / "bin" / "triage_ccr_cir_pair"))
rsync_to_remote(ssh_target, REPO_ROOT / "target" / "release" / "cir_dump_reject_list", str(remote_root / "bin" / "cir_dump_reject_list"))
step_results: list[dict[str, Any]] = []
for step in ("snapshot", "delta"):
for side_label, side in (("A", exp["left"]), ("B", exp["right"])):
run_remote_step(ssh_target, remote_root, exp_id, side_label, step, side, rirs)
local_step_root = local_exp_root / side_label / step
remote_step_root = remote_exp_root / side_label / step
rsync_from_remote(ssh_target, str(remote_step_root), local_step_root)
if side["rpKind"] == "rpki-client":
copy_rpki_client_outputs(local_step_root)
write_rpki_client_stage_timing(local_step_root)
# ensure ours has a stable stage timing artifact too
if not (local_step_root / "stage-timing.json").is_file() and (local_step_root / "report.json").is_file():
report = load_json(local_step_root / "report.json")
stage_timing = {
"tool": side["rpKind"],
"counts": {
"publicationPoints": len(report.get("publication_points", [])),
"vrps": len(report.get("vrps", [])),
"aspas": len(report.get("aspas", [])),
},
}
write_json(local_step_root / "stage-timing.json", stage_timing)
meta_path = generate_run_meta(
local_exp_root,
exp_id,
side_label,
step,
side,
rirs,
fixture_proof,
local_step_root,
)
step_results.append(
{
"side": side_label,
"step": step,
"runDir": str(local_step_root),
"meta": str(meta_path),
}
)
compare_dir = local_exp_root / "compare" / step
ensure_dir(compare_dir)
triage_cmd = [
str(REPO_ROOT / "target" / "release" / "triage_ccr_cir_pair"),
"--left-ccr",
str(local_exp_root / "A" / step / "result.ccr"),
"--left-cir",
str(local_exp_root / "A" / step / "result.cir"),
"--left-meta",
str(local_exp_root / "A" / step / "run-meta.json"),
"--right-ccr",
str(local_exp_root / "B" / step / "result.ccr"),
"--right-cir",
str(local_exp_root / "B" / step / "result.cir"),
"--right-meta",
str(local_exp_root / "B" / step / "run-meta.json"),
"--out-dir",
str(compare_dir),
"--sample-limit",
"200",
"--compare-view-trust-anchor",
compare_view_trust_anchor(rirs),
]
run_local(triage_cmd, cwd=local_exp_root)
step_summary = {
"step": step,
"compareDir": str(compare_dir),
"triage": load_json(compare_dir / "triage.json"),
}
write_json(local_exp_root / "compare" / f"{step}.summary.json", step_summary)
step_results.append(step_summary)
experiment_summary = {
"schemaVersion": 1,
"experimentId": exp_id,
"left": exp["left"],
"right": exp["right"],
"rirs": rirs,
"steps": step_results,
}
write_json(local_exp_root / "experiment-summary.json", experiment_summary)
with (local_exp_root / "experiment-summary.md").open("w", encoding="utf-8") as handle:
handle.write(f"# {exp_id}\n\n")
handle.write(json.dumps(experiment_summary, indent=2, ensure_ascii=False))
handle.write("\n")
rsync_dir_to_remote(ssh_target, local_exp_root, str(remote_exp_root))
return experiment_summary
def is_rpki_client_experiment(exp: dict[str, Any]) -> bool:
return exp["left"]["rpKind"] == "rpki-client" or exp["right"]["rpKind"] == "rpki-client"
def main() -> None:
parser = argparse.ArgumentParser(description="Feature #035 experiment driver")
parser.add_argument("--run-root", required=True)
parser.add_argument("--remote-root", required=True)
parser.add_argument("--ssh-target", default=os.environ.get("SSH_TARGET", "root@47.251.56.108"))
parser.add_argument("--experiment", action="append", help="Experiment id to run; repeatable")
parser.add_argument("--all", action="store_true", help="Run all experiments from experiments.json")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--rirs", default="afrinic,apnic,arin,lacnic,ripe")
args = parser.parse_args()
experiments_doc = load_json(EXPERIMENTS_PATH)
experiments = experiments_doc["experiments"]
selected_ids = set(args.experiment or [])
if args.all or not selected_ids:
selected = experiments
else:
selected = [exp for exp in experiments if exp["id"] in selected_ids]
if not selected:
raise SystemExit("no experiments selected")
rirs = [item.strip() for item in args.rirs.split(",") if item.strip()]
if not rirs:
raise SystemExit("--rirs must not be empty")
run_root = Path(args.run_root).resolve()
remote_root = Path(args.remote_root)
ensure_dir(run_root)
if not args.dry_run:
build_tool_binaries()
if args.dry_run:
plans = [
run_experiment(args.ssh_target, run_root, remote_root, exp, rirs, dry_run=True)
for exp in selected
]
print(json.dumps({"schemaVersion": 1, "dryRun": True, "experiments": plans}, indent=2, ensure_ascii=False))
return
summary = {
"schemaVersion": 1,
"generatedAtUtc": utc_stamp(),
"runRoot": str(run_root),
"remoteRoot": str(remote_root),
"sshTarget": args.ssh_target,
"experiments": [],
}
for exp in selected:
summary["experiments"].append(
run_experiment(args.ssh_target, run_root, remote_root, exp, rirs, dry_run=False)
)
write_json(run_root / "feature035-summary.json", summary)
print(json.dumps(summary, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()