Compare commits

...

71 Commits

Author SHA1 Message Date
d0224d53f6 20260608 默认rsync scope切到module-root 2026-06-08 13:23:16 +08:00
8e6e2f1318 20260607 完成transport预热、roa cache和finalize长尾优化 2026-06-07 20:35:47 +08:00
4045f9a3a5 20260605 ROA增量验证和rsync host失败短路 2026-06-06 15:16:38 +08:00
e597c7c124 20260605 移除audit rule index和DB trace 2026-06-05 16:02:25 +08:00
9579f65501 20260601 sequence triage支持独立时间序列 2026-06-01 23:41:17 +08:00
902f3ba889 20260601 sequence triage按host聚合URI 2026-06-01 21:51:38 +08:00
938ef53173 20260601 sequence triage增加聚合视图 2026-06-01 20:55:39 +08:00
ae00e676d7 20260601 精简sequence triage并增加churn统计 2026-06-01 19:55:07 +08:00
bf8c924326 20260601 优化sequence远端产物拉取 2026-06-01 13:51:08 +08:00
00d7109503 20260531 拆分sequence triage工具并修复远端产物拉取 2026-06-01 11:01:42 +08:00
a29fe266a4 20260530 行为保持源码治理重构 2026-05-30 17:19:42 +08:00
2154870a43 20260529 实现序列三明治异常检测 2026-05-30 07:15:20 +08:00
9f7981e117 20260529 修复CIR输入与拒绝审计语义 2026-05-29 17:32:40 +08:00
57c23f19aa 20260527 增加repo同步监控面板指标 2026-05-27 15:47:09 +08:00
7e1c24fcc3 20260526 增加持续soak监控与本地回放工具 2026-05-26 18:02:40 +08:00
cda7fdb135 20260521 三RP性能对比支持rpki-client p4 2026-05-23 17:27:42 +08:00
fcd0bac070 20260519_2 收紧CCR/CIR四文件分诊 2026-05-20 07:07:13 +08:00
8c6fb44352 20260518 增加三RP性能对比测试驱动 2026-05-19 22:13:34 +08:00
615f8709af 20260514 rsync默认限定发布点scope 2026-05-15 17:17:41 +08:00
137b3516d0 20260512 完成CCR CIR行为差异实验能力 2026-05-13 05:11:17 +08:00
f2fbb20a29 20260511 CIR V3 trust anchors self-contained 2026-05-11 18:12:41 +08:00
51e483d924 20260510 增加strict策略模式并隔离多TA失败 2026-05-11 11:32:17 +08:00
265b6f65d0 20260509 add portable soak package 2026-05-09 19:03:23 +08:00
c6b408c0f9 20260507_2 增加CCR/CIR差异定位链路 2026-05-09 10:02:47 +08:00
752e746b97 20260506_2 为CIR增加reject list基础能力 2026-05-06 20:53:57 +08:00
51663a9410 20260506 清理废弃bundle代码并收敛ccr compare view 2026-05-06 16:49:23 +08:00
f843eedda9 20260504 优化phase2 finalize调度长尾 2026-05-06 12:05:02 +08:00
b3b44d50c6 20260501 修复CurrentRepoIndex完整性并优化replay guard 2026-05-03 08:26:18 +08:00
ad61caf271 20260428 daemon运行化和delta复用warning修复 2026-04-29 08:13:10 +08:00
3b2a160c5c 20260428 降低all5 CIR replay内存峰值 2026-04-28 09:58:43 +08:00
0295fd3262 20260427_5 支持all5 CIR replay多TAL 2026-04-28 00:20:12 +08:00
e2901df3ac 20260427_4 默认禁用work-db BlobDB 2026-04-27 22:08:33 +08:00
26aec5ff35 20260427_3 增强db_stats归因work-db体积 2026-04-27 18:00:34 +08:00
87275b5c57 20260427_2 移除parallel phase开关,默认全量并行 2026-04-27 17:13:32 +08:00
eaa375c5ec 20260427 拆分独立repo-bytes.db数据库文件,cir materialize 移除旧兼容 2026-04-27 16:01:33 +08:00
944ea6ca00 20260421 优化内存布局,降低内存峰值需求压低到4.5GB左右,在4c8g 机器上,稳定跑snapshot + delta 2026-04-21 15:21:25 +08:00
542bd7be80 20260420_2 完成输出report和ccr优化,snapshot耗时优化到70多秒 2026-04-20 18:28:59 +08:00
f6a601e16c 20260418_2 phase2 并行优化 mix quick 耗时105/48秒 2026-04-19 00:08:29 +08:00
417c82bef6 20260418 优化去掉冗余存储repo object,mix quick最快170s 2026-04-18 14:10:47 +08:00
f485786470 20260147 迭代优化全量测试和覆盖率测试,时间从325秒降低到90+秒,覆盖率维持在90% 2026-04-17 17:18:05 +08:00
224ae10052 20260416_2 并行优化phase1后进行snapshot fast path优化,通过四方面关键优化技术消除了验证主链路上snapshot构建是的db访问性能热点问题,性能热点转移到ROA验证处理本身,目前APNIC+ARIN全量同步从500秒压缩到212秒,离rpkclient 112秒差距变小 2026-04-17 14:58:47 +08:00
38421b1ae7 20260415_2 支持多个RIR并行混合执行,APNIC+ARIN运行1 snapshot + 1 delta,对比rpki-client,snapshot比rpki-client慢4分钟(398vs137),输出未收敛,delta时输出收敛(348vs181),评估应该是正确性没有问题,下一步进一步优化性能 2026-04-16 11:33:52 +08:00
585c41b83b 20260413_2 并行化架构优化第一阶段,apnic 串行98s->并行74s,传输层任务并行,同repo内发布点串行 2026-04-15 09:53:11 +08:00
af1c2c7f88 20260413 增加定时周期任务与rpki-client对比,发现rpki-client rsync降权问题,改到tmp目录执行,执行两轮step发现输入基本对齐 2026-04-13 16:30:36 +08:00
e45830d79f 20260411 apply snapahot内存优化,采用流式写文件和分块处理降低运行内存需求 2026-04-11 14:45:08 +08:00
77fc2f1a41 20260410 完成五个rir 基于cir的三方回放,raw by hash 独立db,发现内存占用大,连续大rir 录制发生oom 2026-04-11 11:24:32 +08:00
e083fe4daa 20260408_2 增加CIR sequence,未验证drop analysis,遇到问题是static pool保存太慢,拖慢整体录制,待解决 2026-04-09 16:08:11 +08:00
c9ef5aaf4c 20260407 & 20260408 基于cir 三方replay对齐,并且materialize 使用hard link优化 2026-04-08 16:27:46 +08:00
34fb9657f1 20260401 live recorder 扩展到1+N个delta,完成5个RIR录制,以及三方replay,结果三方均对齐vrps和vaps 2026-04-03 16:44:27 +08:00
6edc420ce2 20260330 完成live bundle录制,远程录制,以及与routinator/rpki-client replay对比 2026-03-31 17:34:32 +08:00
cd0ba15286 20260326 完成数据库model迁移;20260327 增加一键replay脚本 2026-03-27 11:24:34 +08:00
fe8b89d829 20260324_2 增加ccr & router key support 2026-03-26 11:52:06 +08:00
d6d44669b4 20260324 完成和routinator对齐snapshot delta replay correctness一致 2026-03-24 10:10:04 +08:00
557a69cbd2 20260316迭代 增加delta replay以及multi-rir
replay 对比,五个RIR 输出vrp与routinator一致
2026-03-16 22:54:48 +08:00
73d8ebb5c1 增加 payload replay for snapshot,20260313 迭代 2026-03-15 22:49:06 +08:00
cf764c35bb 将fetch pp cache改成使用vcir结构,跑通apnic全量同步 2026-03-13 14:45:41 +08:00
e3339533b8 增加delta设计草图 2026-03-11 10:03:15 +08:00
afc50364f8 全量同步测试增加download过程审计输出 2026-03-06 11:52:59 +08:00
6276d13814 手动执行全量同步 2026-03-04 11:12:53 +08:00
0f3d65254e 5 TAL test pass 2026-02-28 10:10:03 +08:00
13516c4f73 add delta sync and fail fetch process 2026-02-27 18:02:01 +08:00
68cbd3c500 优化数据对象decode + profile validate;benchmark对比routinator geomean 0.8 2026-02-26 17:01:51 +08:00
1cc3351bef manifest decode & profile validate optimization 2026-02-25 11:16:02 +08:00
2a6a963ecd fetch cache pp imporved 2026-02-11 10:07:24 +08:00
6e135b9d7a run tree from tal pass 2026-02-10 12:09:59 +08:00
afc31c02ab 串行验证通过 2026-02-09 19:35:54 +08:00
7be865d7f1 优化时间表示 2026-02-06 15:30:26 +08:00
a58e507f92 重构error code 2026-02-04 17:02:17 +08:00
cc9f3f21de 增加rc,完成所有模型的解析,优化error code 的RFC引用 2026-02-03 16:50:52 +08:00
56ae2ca4fc 增加aspa对象解析 2026-02-02 15:42:30 +08:00
bcd4829486 add signed object, manifest impl. add coverage script 2026-02-02 15:42:01 +08:00
451 changed files with 149206 additions and 1369 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
target/ target/
Cargo.lock Cargo.lock
perf.*
specs/* copy.excalidraw

View File

@ -3,10 +3,34 @@ name = "rpki"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[features]
default = ["full"]
# Full build used by the main RP implementation (includes RocksDB-backed storage).
full = ["dep:rocksdb"]
profile = ["dep:pprof", "dep:flate2"]
[dependencies] [dependencies]
der-parser = "10.0.0" asn1-rs = "0.7.1"
der-parser = { version = "10.0.0", features = ["serialize"] }
hex = "0.4.3" hex = "0.4.3"
base64 = "0.22.1"
sha2 = "0.10.8"
thiserror = "2.0.18" thiserror = "2.0.18"
time = "0.3.45" time = "0.3.45"
ring = "0.17.14"
x509-parser = { version = "0.18.0", features = ["verify"] } x509-parser = { version = "0.18.0", features = ["verify"] }
url = "2.5.8" url = "2.5.8"
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.140"
toml = "0.8.20"
rocksdb = { version = "0.22.0", optional = true, default-features = false, features = ["lz4"] }
serde_cbor = "0.11.2"
roxmltree = "0.20.0"
quick-xml = "0.37.2"
uuid = { version = "1.7.0", features = ["v4"] }
reqwest = { version = "0.12.12", default-features = false, features = ["blocking", "rustls-tls", "gzip", "brotli", "deflate"] }
pprof = { version = "0.14.1", optional = true, features = ["flamegraph", "prost-codec"] }
flate2 = { version = "1.0.35", optional = true }
tempfile = "3.16.0"
[dev-dependencies]

View File

@ -9,3 +9,55 @@ cargo test
cargo test -- --nocapture cargo test -- --nocapture
``` ```
# 覆盖率cargo-llvm-cov
安装工具:
```
rustup component add llvm-tools-preview
cargo install cargo-llvm-cov --locked
```
统计行覆盖率并要求 >=90%
```
./scripts/coverage.sh
# 或
cargo llvm-cov --fail-under-lines 90
```
默认会复用现有插桩产物,不会先 clean。需要强制全量重编译时
```
COVERAGE_FORCE_CLEAN=1 ./scripts/coverage.sh
```
说明:
- 默认行为适合本地重复确认覆盖率,避免每次都重编译整套插桩目标;
- 默认还会设置 `RPKI_SKIP_HEAVY_SCRIPT_REPLAY_TESTS=1`,跳过会拉起 shell replay pipeline 的重型集成测试,避免 coverage 期间额外触发 `target/release` 构建;
- 默认还会设置 `RPKI_SKIP_HEAVY_BLACKBOX_TESTS=1`,跳过更慢的 blackbox CLI / CIR record 脚本测试,进一步降低日常 coverage 成本;
- 默认还会设置 `RPKI_SKIP_HEAVY_CRYPTO_TESTS=1`,跳过需要大量 OpenSSL 生成证书/CRL 的重型密码学测试,进一步压缩日常 coverage 时长;
- 如需把这批脚本回放测试也纳入 coverage可显式关闭该开关
```
RPKI_SKIP_HEAVY_SCRIPT_REPLAY_TESTS=0 ./scripts/coverage.sh
```
如需连同第二批 blackbox 测试一起跑:
```
RPKI_SKIP_HEAVY_BLACKBOX_TESTS=0 ./scripts/coverage.sh
```
如需连同重型 OpenSSL 证书路径测试一起跑:
```
RPKI_SKIP_HEAVY_CRYPTO_TESTS=0 ./scripts/coverage.sh
```
- replay 脚本现在也支持通过环境变量注入现成二进制,避免找不到二进制时自动 `cargo build --release`
- `RPKI_BIN`
- `CIR_MATERIALIZE_BIN`
- `CIR_EXTRACT_INPUTS_BIN`
- `CCR_TO_COMPARE_VIEWS_BIN`
- `COVERAGE_FORCE_CLEAN=1` 适合需要完全从零重建插桩目标时使用。

View File

@ -0,0 +1,8 @@
[package]
name = "ours-manifest-bench"
version = "0.1.0"
edition = "2024"
[dependencies]
rpki = { path = "../..", default-features = false }

View File

@ -0,0 +1,145 @@
use rpki::data_model::manifest::ManifestObject;
use std::hint::black_box;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, Clone)]
struct Config {
sample: Option<String>,
manifest_path: Option<PathBuf>,
iterations: u64,
warmup_iterations: u64,
repeats: u32,
}
fn usage_and_exit() -> ! {
eprintln!(
"Usage:\n ours-manifest-bench (--sample <name> | --manifest <path>) [--iterations N] [--warmup-iterations N] [--repeats N]\n\nExamples:\n cargo run --release -- --sample small-01 --iterations 20000 --warmup-iterations 2000 --repeats 3\n cargo run --release -- --manifest ../../tests/benchmark/selected_der/small-01.mft"
);
std::process::exit(2);
}
fn parse_args() -> Config {
let mut sample: Option<String> = None;
let mut manifest_path: Option<PathBuf> = None;
let mut iterations: u64 = 20_000;
let mut warmup_iterations: u64 = 2_000;
let mut repeats: u32 = 3;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--sample" => sample = Some(args.next().unwrap_or_else(|| usage_and_exit())),
"--manifest" => {
manifest_path = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit())))
}
"--iterations" => {
iterations = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"--warmup-iterations" => {
warmup_iterations = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"--repeats" => {
repeats = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"-h" | "--help" => usage_and_exit(),
_ => usage_and_exit(),
}
}
if sample.is_none() && manifest_path.is_none() {
usage_and_exit();
}
if sample.is_some() && manifest_path.is_some() {
usage_and_exit();
}
Config {
sample,
manifest_path,
iterations,
warmup_iterations,
repeats,
}
}
fn derive_manifest_path(sample: &str) -> PathBuf {
// Assumes current working directory is `rpki/benchmark/ours_manifest_bench`.
PathBuf::from(format!("../../tests/benchmark/selected_der/{sample}.mft"))
}
fn main() {
let cfg = parse_args();
let manifest_path = cfg
.manifest_path
.clone()
.unwrap_or_else(|| derive_manifest_path(cfg.sample.as_deref().unwrap()));
let bytes = std::fs::read(&manifest_path).unwrap_or_else(|e| {
eprintln!("read manifest fixture failed: {e}; path={}", manifest_path.display());
std::process::exit(1);
});
let decoded_once = ManifestObject::decode_der(&bytes).unwrap_or_else(|e| {
eprintln!("decode failed: {e}; path={}", manifest_path.display());
std::process::exit(1);
});
let file_count = decoded_once.manifest.file_count();
let mut round_ns_per_op: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
let mut round_ops_per_s: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
for _round in 0..cfg.repeats {
for _ in 0..cfg.warmup_iterations {
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("warmup decode");
black_box(obj);
}
let start = Instant::now();
for _ in 0..cfg.iterations {
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("timed decode");
black_box(obj);
}
let elapsed = start.elapsed();
let ns_per_op = (elapsed.as_secs_f64() * 1e9) / (cfg.iterations as f64);
let ops_per_s = (cfg.iterations as f64) / elapsed.as_secs_f64();
round_ns_per_op.push(ns_per_op);
round_ops_per_s.push(ops_per_s);
}
let avg_ns_per_op = round_ns_per_op.iter().sum::<f64>() / (round_ns_per_op.len() as f64);
let avg_ops_per_s = round_ops_per_s.iter().sum::<f64>() / (round_ops_per_s.len() as f64);
let sample_name = cfg.sample.clone().unwrap_or_else(|| {
manifest_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| manifest_path.display().to_string())
});
let sample_name = sample_name
.strip_suffix(".mft")
.unwrap_or(&sample_name)
.to_string();
println!("fixture: {}", manifest_path.display());
println!();
println!("| sample | avg ns/op | ops/s | file count |");
println!("|---|---:|---:|---:|");
println!(
"| {} | {:.2} | {:.2} | {} |",
sample_name, avg_ns_per_op, avg_ops_per_s, file_count
);
}

View File

@ -0,0 +1,8 @@
[package]
name = "routinator-object-bench"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
rpki = { version = "=0.19.1", features = ["repository"] }

View File

@ -0,0 +1,552 @@
use rpki::repository::cert::Cert;
use rpki::repository::crl::Crl;
use rpki::repository::manifest::Manifest;
use rpki::repository::roa::Roa;
use rpki::repository::aspa::Aspa;
use rpki::repository::resources::{AsResources, IpResources};
use std::hint::black_box;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum ObjType {
Cer,
Crl,
Manifest,
Roa,
Aspa,
}
impl ObjType {
fn parse(s: &str) -> Result<Self, String> {
match s {
"cer" => Ok(Self::Cer),
"crl" => Ok(Self::Crl),
"manifest" => Ok(Self::Manifest),
"roa" => Ok(Self::Roa),
"aspa" => Ok(Self::Aspa),
_ => Err("type must be one of: cer, crl, manifest, roa, aspa".into()),
}
}
fn as_str(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "manifest",
ObjType::Roa => "roa",
ObjType::Aspa => "aspa",
}
}
fn ext(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "mft",
ObjType::Roa => "roa",
ObjType::Aspa => "asa",
}
}
}
#[derive(Clone, Debug)]
struct Sample {
obj_type: ObjType,
name: String,
path: PathBuf,
}
#[derive(Clone, Debug)]
struct Config {
dir: PathBuf,
type_filter: Option<ObjType>,
sample_filter: Option<String>,
fixed_iters: Option<u64>,
warmup_iters: u64,
rounds: u64,
min_round_ms: u64,
max_adaptive_iters: u64,
strict: bool,
cert_inspect: bool,
out_csv: Option<PathBuf>,
out_md: Option<PathBuf>,
}
fn usage_and_exit(err: Option<&str>) -> ! {
if let Some(err) = err {
eprintln!("error: {err}");
eprintln!();
}
eprintln!(
"Usage:\n\
cargo run --release --manifest-path rpki/benchmark/routinator_object_bench/Cargo.toml -- [OPTIONS]\n\
\n\
Options:\n\
--dir <PATH> Fixtures root dir (default: ../../tests/benchmark/selected_der_v2)\n\
--type <cer|crl|manifest|roa|aspa> Filter by type\n\
--sample <NAME> Filter by sample name (e.g. p50)\n\
--iters <N> Fixed iterations per round (optional; otherwise adaptive)\n\
--warmup-iters <N> Warmup iterations (default: 50)\n\
--rounds <N> Rounds (default: 5)\n\
--min-round-ms <MS> Adaptive: minimum round time (default: 200)\n\
--max-iters <N> Adaptive: maximum iters (default: 1_000_000)\n\
--strict <true|false> Strict DER where applicable (default: true)\n\
--cert-inspect Also run Cert::inspect_ca/inspect_ee where applicable (default: false)\n\
--out-csv <PATH> Write CSV output\n\
--out-md <PATH> Write Markdown output\n\
"
);
std::process::exit(2);
}
fn parse_bool(s: &str, name: &str) -> bool {
match s {
"1" | "true" | "TRUE" | "yes" | "YES" => true,
"0" | "false" | "FALSE" | "no" | "NO" => false,
_ => usage_and_exit(Some(&format!("{name} must be true/false"))),
}
}
fn parse_u64(s: &str, name: &str) -> u64 {
s.parse::<u64>()
.unwrap_or_else(|_| usage_and_exit(Some(&format!("{name} must be an integer"))))
}
fn default_samples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/benchmark/selected_der_v2")
}
fn parse_args() -> Config {
let mut dir: PathBuf = default_samples_dir();
let mut type_filter: Option<ObjType> = None;
let mut sample_filter: Option<String> = None;
let mut fixed_iters: Option<u64> = None;
let mut warmup_iters: u64 = 50;
let mut rounds: u64 = 5;
let mut min_round_ms: u64 = 200;
let mut max_adaptive_iters: u64 = 1_000_000;
let mut strict: bool = true;
let mut cert_inspect: bool = false;
let mut out_csv: Option<PathBuf> = None;
let mut out_md: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--dir" => dir = PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None))),
"--type" => {
type_filter = Some(ObjType::parse(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
)
.unwrap_or_else(|e| usage_and_exit(Some(&e))))
}
"--sample" => {
sample_filter = Some(args.next().unwrap_or_else(|| usage_and_exit(None)))
}
"--iters" => {
fixed_iters = Some(parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--iters",
))
}
"--warmup-iters" => {
warmup_iters = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--warmup-iters",
)
}
"--rounds" => {
rounds = parse_u64(&args.next().unwrap_or_else(|| usage_and_exit(None)), "--rounds")
}
"--min-round-ms" => {
min_round_ms = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--min-round-ms",
)
}
"--max-iters" => {
max_adaptive_iters = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--max-iters",
)
}
"--strict" => {
strict = parse_bool(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--strict",
)
}
"--cert-inspect" => cert_inspect = true,
"--out-csv" => out_csv = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
"--out-md" => out_md = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
"-h" | "--help" => usage_and_exit(None),
_ => usage_and_exit(Some(&format!("unknown argument: {arg}"))),
}
}
if warmup_iters == 0 {
usage_and_exit(Some("--warmup-iters must be > 0"));
}
if rounds == 0 {
usage_and_exit(Some("--rounds must be > 0"));
}
if min_round_ms == 0 {
usage_and_exit(Some("--min-round-ms must be > 0"));
}
if max_adaptive_iters == 0 {
usage_and_exit(Some("--max-iters must be > 0"));
}
if let Some(n) = fixed_iters {
if n == 0 {
usage_and_exit(Some("--iters must be > 0"));
}
}
Config {
dir,
type_filter,
sample_filter,
fixed_iters,
warmup_iters,
rounds,
min_round_ms,
max_adaptive_iters,
strict,
cert_inspect,
out_csv,
out_md,
}
}
fn read_samples(root: &Path) -> Vec<Sample> {
let mut out = Vec::new();
for obj_type in [
ObjType::Cer,
ObjType::Crl,
ObjType::Manifest,
ObjType::Roa,
ObjType::Aspa,
] {
let dir = root.join(obj_type.as_str());
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for ent in rd.flatten() {
let path = ent.path();
if path.extension().and_then(|s| s.to_str()) != Some(obj_type.ext()) {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
out.push(Sample { obj_type, name, path });
}
}
out.sort_by(|a, b| a.obj_type.cmp(&b.obj_type).then_with(|| a.name.cmp(&b.name)));
out
}
fn choose_iters_adaptive<F: FnMut()>(mut op: F, min_round_ms: u64, max_iters: u64) -> u64 {
let min_secs = (min_round_ms as f64) / 1e3;
let mut iters: u64 = 1;
loop {
let start = Instant::now();
for _ in 0..iters {
op();
}
let elapsed = start.elapsed().as_secs_f64();
if elapsed >= min_secs {
return iters;
}
if iters >= max_iters {
return iters;
}
iters = (iters.saturating_mul(2)).min(max_iters);
}
}
fn count_ip(res: &IpResources) -> u64 {
if res.is_inherited() {
return 1;
}
let Ok(blocks) = res.to_blocks() else {
return 0;
};
blocks.iter().count() as u64
}
fn count_as(res: &AsResources) -> u64 {
if res.is_inherited() {
return 1;
}
let Ok(blocks) = res.to_blocks() else {
return 0;
};
blocks.iter().count() as u64
}
fn complexity(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) -> u64 {
match obj_type {
ObjType::Cer => {
let cert = Cert::decode(bytes).expect("decode cert");
if cert_inspect {
if cert.is_ca() {
cert.inspect_ca(strict).expect("inspect ca");
} else {
cert.inspect_ee(strict).expect("inspect ee");
}
}
count_ip(cert.v4_resources())
.saturating_add(count_ip(cert.v6_resources()))
.saturating_add(count_as(cert.as_resources()))
}
ObjType::Crl => {
let crl = Crl::decode(bytes).expect("decode crl");
crl.revoked_certs().iter().count() as u64
}
ObjType::Manifest => {
let mft = Manifest::decode(bytes, strict).expect("decode manifest");
if cert_inspect {
mft.cert().inspect_ee(strict).expect("inspect ee");
}
mft.content().len() as u64
}
ObjType::Roa => {
let roa = Roa::decode(bytes, strict).expect("decode roa");
if cert_inspect {
roa.cert().inspect_ee(strict).expect("inspect ee");
}
roa.content().iter().count() as u64
}
ObjType::Aspa => {
let asa = Aspa::decode(bytes, strict).expect("decode aspa");
if cert_inspect {
asa.cert().inspect_ee(strict).expect("inspect ee");
}
asa.content().provider_as_set().len() as u64
}
}
}
fn decode_profile(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) {
match obj_type {
ObjType::Cer => {
let cert = Cert::decode(black_box(bytes)).expect("decode cert");
if cert_inspect {
if cert.is_ca() {
cert.inspect_ca(strict).expect("inspect ca");
} else {
cert.inspect_ee(strict).expect("inspect ee");
}
}
black_box(cert);
}
ObjType::Crl => {
let crl = Crl::decode(black_box(bytes)).expect("decode crl");
black_box(crl);
}
ObjType::Manifest => {
let mft = Manifest::decode(black_box(bytes), strict).expect("decode manifest");
if cert_inspect {
mft.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(mft);
}
ObjType::Roa => {
let roa = Roa::decode(black_box(bytes), strict).expect("decode roa");
if cert_inspect {
roa.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(roa);
}
ObjType::Aspa => {
let asa = Aspa::decode(black_box(bytes), strict).expect("decode aspa");
if cert_inspect {
asa.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(asa);
}
}
}
#[derive(Clone, Debug)]
struct ResultRow {
obj_type: String,
sample: String,
size_bytes: usize,
complexity: u64,
avg_ns_per_op: f64,
ops_per_sec: f64,
}
fn render_markdown(title: &str, rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str(&format!("# {title}\n\n"));
out.push_str("| type | sample | size_bytes | complexity | avg ns/op | ops/s |\n");
out.push_str("|---|---|---:|---:|---:|---:|\n");
for r in rows {
out.push_str(&format!(
"| {} | {} | {} | {} | {:.2} | {:.2} |\n",
r.obj_type, r.sample, r.size_bytes, r.complexity, r.avg_ns_per_op, r.ops_per_sec
));
}
out
}
fn render_csv(rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str("type,sample,size_bytes,complexity,avg_ns_per_op,ops_per_sec\n");
for r in rows {
let sample = r.sample.replace('"', "\"\"");
out.push_str(&format!(
"{},{},{},{},{:.6},{:.6}\n",
r.obj_type,
format!("\"{}\"", sample),
r.size_bytes,
r.complexity,
r.avg_ns_per_op,
r.ops_per_sec
));
}
out
}
fn create_parent_dirs(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
panic!("create_dir_all {}: {e}", parent.display());
});
}
}
fn write_text_file(path: &Path, content: &str) {
create_parent_dirs(path);
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
fn main() {
let cfg = parse_args();
let mut samples = read_samples(&cfg.dir);
if samples.is_empty() {
usage_and_exit(Some(&format!(
"no samples found under: {}",
cfg.dir.display()
)));
}
if let Some(t) = cfg.type_filter {
samples.retain(|s| s.obj_type == t);
if samples.is_empty() {
usage_and_exit(Some(&format!("no sample matched --type {}", t.as_str())));
}
}
if let Some(filter) = cfg.sample_filter.as_deref() {
samples.retain(|s| s.name == filter);
if samples.is_empty() {
usage_and_exit(Some(&format!("no sample matched --sample {filter}")));
}
}
println!("# Routinator baseline (rpki crate) decode benchmark (selected_der_v2)");
println!();
println!("- dir: {}", cfg.dir.display());
println!("- strict: {}", cfg.strict);
println!("- cert_inspect: {}", cfg.cert_inspect);
if let Some(t) = cfg.type_filter {
println!("- type: {}", t.as_str());
}
if let Some(s) = cfg.sample_filter.as_deref() {
println!("- sample: {}", s);
}
if let Some(n) = cfg.fixed_iters {
println!("- iters: {} (fixed)", n);
} else {
println!(
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})",
cfg.warmup_iters, cfg.rounds, cfg.min_round_ms, cfg.max_adaptive_iters
);
}
if let Some(p) = cfg.out_csv.as_ref() {
println!("- out_csv: {}", p.display());
}
if let Some(p) = cfg.out_md.as_ref() {
println!("- out_md: {}", p.display());
}
println!();
println!("| type | sample | size_bytes | complexity | avg ns/op | ops/s |");
println!("|---|---|---:|---:|---:|---:|");
let mut rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
for sample in &samples {
let bytes = std::fs::read(&sample.path)
.unwrap_or_else(|e| panic!("read {}: {e}", sample.path.display()));
let size_bytes = bytes.len();
let complexity = complexity(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
for _ in 0..cfg.warmup_iters {
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
}
let mut per_round_ns_per_op = Vec::with_capacity(cfg.rounds as usize);
for _round in 0..cfg.rounds {
let iters = if let Some(n) = cfg.fixed_iters {
n
} else {
choose_iters_adaptive(
|| decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect),
cfg.min_round_ms,
cfg.max_adaptive_iters,
)
};
let start = Instant::now();
for _ in 0..iters {
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
}
let elapsed = start.elapsed();
let total_ns = elapsed.as_secs_f64() * 1e9;
per_round_ns_per_op.push(total_ns / (iters as f64));
}
let avg_ns = per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
let ops_per_sec = 1e9_f64 / avg_ns;
println!(
"| {} | {} | {} | {} | {:.2} | {:.2} |",
sample.obj_type.as_str(),
sample.name,
size_bytes,
complexity,
avg_ns,
ops_per_sec
);
rows.push(ResultRow {
obj_type: sample.obj_type.as_str().to_string(),
sample: sample.name.clone(),
size_bytes,
complexity,
avg_ns_per_op: avg_ns,
ops_per_sec,
});
}
if let Some(path) = cfg.out_md.as_ref() {
let md = render_markdown(
"Routinator baseline (rpki crate) decode+inspect (selected_der_v2)",
&rows,
);
write_text_file(path, &md);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = cfg.out_csv.as_ref() {
let csv = render_csv(&rows);
write_text_file(path, &csv);
eprintln!("Wrote {}", path.display());
}
}

2734
model.txt Normal file

File diff suppressed because it is too large Load Diff

131
monitor/README.md Normal file
View File

@ -0,0 +1,131 @@
# Ours RP Prometheus / Grafana Monitor
本目录提供本地开发监控栈,用于采集 `rpki_artifact_metrics` 暴露的 ours RP soak 指标。
## 前置条件
1. Docker + Docker Compose v2
2. 宿主机已启动 `rpki_artifact_metrics`,并监听 Docker 网桥可访问的地址,例如 `0.0.0.0:9556`
3. Prometheus 容器通过 `host.docker.internal:9556` 访问宿主 sidecar。
Linux Docker 下 compose 已配置:
```yaml
extra_hosts:
- host.docker.internal:host-gateway
```
## 启动
```bash
cd rpki_2/rpki/monitor
docker compose up -d
```
默认镜像使用官方 Docker Hub 镜像:
```text
prom/prometheus:v2.55.1
grafana/grafana:11.3.1
```
如需切到其它镜像源:
```bash
PROMETHEUS_IMAGE=<mirror>/prom/prometheus:v2.55.1 \
GRAFANA_IMAGE=<mirror>/grafana/grafana:11.3.1 \
docker compose up -d
```
默认端口:
- Prometheus: <http://localhost:9090>
- Grafana: <http://localhost:3000>
- Grafana 默认账号密码:`admin` / `admin`
如端口冲突:
```bash
PROMETHEUS_PORT=19090 GRAFANA_PORT=13000 docker compose up -d
```
## 停止
```bash
cd rpki_2/rpki/monitor
docker compose down
```
保留数据 volume。若要清理数据
```bash
docker compose down -v
```
## 典型本地联调命令
先启动 APNIC soak 和 metrics sidecar例如
```bash
# soak .env 关键配置
MAX_RUNS=-1
RIRS=apnic
RETAIN_RUNS=5
INTERVAL_SECS=0
# metrics sidecar
rpki_artifact_metrics \
--run-root /path/to/portable-soak \
--listen 0.0.0.0:9556 \
--poll-secs 5 \
--instance local-apnic-continuous
```
再启动监控栈:
```bash
cd rpki_2/rpki/monitor
docker compose up -d
```
## 验证
Prometheus target
```bash
curl -s 'http://localhost:9090/api/v1/targets' | python3 -m json.tool
```
Prometheus query
```bash
curl -G 'http://localhost:9090/api/v1/query' \
--data-urlencode 'query=up{job="ours-rp-artifact-metrics"}'
curl -G 'http://localhost:9090/api/v1/query' \
--data-urlencode 'query=ours_rp_run_completed_total{status="success"}'
```
Grafana health
```bash
curl -s http://localhost:3000/api/health | python3 -m json.tool
```
Grafana dashboard
- 打开 <http://localhost:3000/d/ours-rp-soak-overview/ours-rp-soak-overview>
## 主要指标
- `ours_rp_metrics_service_up`
- `ours_rp_run_completed_total`
- `ours_rp_run_duration_seconds`
- `ours_rp_run_max_rss_bytes`
- `ours_rp_vrps`
- `ours_rp_vaps`
- `ours_rp_publication_points`
- `ours_rp_repo_sync_phase_count`
- `ours_rp_large_publication_points{object_count_gt="10|50|100|..."}`
- `ours_rp_cir_objects`
- `ours_rp_ccr_state_items`

View File

@ -0,0 +1,38 @@
services:
prometheus:
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}
container_name: ours-rp-prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-7d}
- --web.enable-lifecycle
extra_hosts:
- host.docker.internal:host-gateway
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
restart: unless-stopped
grafana:
image: ${GRAFANA_IMAGE:-grafana/grafana:11.3.1}
container_name: ours-rp-grafana
depends_on:
- prometheus
ports:
- "${GRAFANA_PORT:-3000}:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
restart: unless-stopped
volumes:
prometheus-data:
grafana-data:

View File

@ -0,0 +1,761 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_publication_points",
"legendFormat": "publication points",
"refId": "A"
}
],
"title": "Publication Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 0
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_ok\"}",
"legendFormat": "rrdp ok",
"refId": "A"
}
],
"title": "RRDP OK Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "fallback",
"refId": "A"
}
],
"title": "Rsync Fallback Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 0
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_repo_terminal_state_count{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache",
"refId": "A"
}
],
"title": "Failed No Cache Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 4
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"repo_sync_total\"}",
"legendFormat": "repo sync total",
"refId": "A"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"rrdp_download_total\"}",
"legendFormat": "rrdp download",
"refId": "B"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"rsync_download_total\"}",
"legendFormat": "rsync download",
"refId": "C"
}
],
"title": "Repo Sync Download Durations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 12,
"w": 12,
"h": 8
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count",
"legendFormat": "{{phase}}",
"refId": "A"
}
],
"title": "Repo Sync Phase Counts",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 12,
"w": 12,
"h": 8
},
"id": 7,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "rrdp failed, rsync ok",
"refId": "A"
},
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_failed\"}",
"legendFormat": "rrdp failed, rsync failed",
"refId": "B"
},
{
"expr": "ours_rp_repo_terminal_state_count{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache",
"refId": "C"
},
{
"expr": "ours_rp_tree_instances{state=\"failed\"}",
"legendFormat": "tree failed",
"refId": "D"
}
],
"title": "Repo Failure / Fallback Counts",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 20,
"w": 12,
"h": 8
},
"id": 8,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_duration_seconds_total{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "rsync fallback duration",
"refId": "A"
},
{
"expr": "ours_rp_repo_sync_phase_duration_seconds_total{phase=\"rrdp_failed_rsync_failed\"}",
"legendFormat": "failed duration",
"refId": "B"
},
{
"expr": "ours_rp_repo_terminal_state_duration_seconds_total{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache duration",
"refId": "C"
}
],
"title": "Repo Failure / Fallback Durations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 29,
"w": 12,
"h": 9
},
"id": 9,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_rrdp_rsync_failed_repository_duration_seconds",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "RRDP + Rsync Failed Repositories",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"terminal_state": true,
"rank": true,
"transport": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"phase": 2,
"uri": 3,
"Value": 4
},
"renameByName": {
"Value": "duration"
}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 29,
"w": 12,
"h": 9
},
"id": 11,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "topk(20, ours_rp_top_repository_sync_duration_seconds)",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Top 20 Repositories by Sync Duration",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"terminal_state": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"phase": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"rank": 2,
"transport": 3,
"uri": 4,
"Value": 5
},
"renameByName": {
"Value": "value"
}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 38,
"w": 24,
"h": 9
},
"id": 10,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "topk(20, ours_rp_top_publication_point_object_count)",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Top Publication Points by Objects",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"phase": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"rank": 2,
"terminal_state": 3,
"transport": 4,
"uri": 5,
"Value": 6
},
"renameByName": {}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"description": "Per-repository sync success in the latest successful run; 1 means successful, 0 means failed or failed_no_cache.",
"fieldConfig": {
"defaults": {
"unit": "bool"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 47
},
"id": 12,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"expr": "ours_rp_repository_sync_success",
"legendFormat": "{{host}} {{repo_id}}",
"refId": "A"
}
],
"title": "Repository Sync Success by Repo",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"description": "Per-repository total sync duration aggregated from publication point repo_sync_duration_ms.",
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 55
},
"id": 13,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"expr": "ours_rp_repository_sync_duration_seconds{stat=\"sum\"}",
"legendFormat": "{{host}} {{repo_id}}",
"refId": "A"
}
],
"title": "Repository Sync Duration by Repo",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"description": "Per-repository downloaded bytes attributed from report.json downloads events.",
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 63
},
"id": 14,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"expr": "ours_rp_repository_download_bytes",
"legendFormat": "{{host}} {{repo_id}}",
"refId": "A"
}
],
"title": "Repository Download Bytes by Repo",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 40,
"tags": [
"ours-rp",
"rpki",
"soak",
"repo-sync"
],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Ours RP Repo Sync",
"uid": "ours-rp-repo-sync",
"version": 3,
"weekStart": ""
}

View File

@ -0,0 +1,582 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_cir_trust_anchors",
"legendFormat": "RIRs",
"refId": "A"
}
],
"title": "Current Run RIRs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_run_duration_seconds",
"legendFormat": "wall",
"refId": "A"
}
],
"title": "Latest Wall Time",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_run_max_rss_bytes",
"legendFormat": "rss",
"refId": "A"
}
],
"title": "Latest Max RSS",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_publication_points",
"legendFormat": "publication points",
"refId": "A"
}
],
"title": "Publication Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 8,
"w": 12,
"h": 8
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_duration_seconds",
"legendFormat": "wall",
"refId": "A"
},
{
"expr": "ours_rp_stage_duration_seconds{stage=\"validation\"}",
"legendFormat": "validation",
"refId": "B"
}
],
"title": "Run / Validation Duration",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 8,
"w": 12,
"h": 8
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_vrps",
"legendFormat": "VRPs",
"refId": "A"
},
{
"expr": "ours_rp_vaps",
"legendFormat": "VAPs",
"refId": "B"
},
{
"expr": "ours_rp_cir_objects",
"legendFormat": "CIR objects",
"refId": "C"
}
],
"title": "Output and Input Sizes",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 16,
"w": 12,
"h": 8
},
"id": 8,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_large_publication_points",
"legendFormat": "> {{object_count_gt}} objects",
"refId": "A"
}
],
"title": "Large Publication Points by Object Count",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 4,
"w": 6,
"h": 4
},
"id": 9,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_run_sequence",
"legendFormat": "seq",
"refId": "A"
}
],
"title": "Latest Run Sequence",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 6,
"y": 4,
"w": 6,
"h": 4
},
"id": 10,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_run_success",
"legendFormat": "success",
"refId": "A"
}
],
"title": "Latest Run Success",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 4,
"w": 6,
"h": 4
},
"id": 11,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_vrps",
"legendFormat": "VRPs",
"refId": "A"
}
],
"title": "VRPs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 18,
"y": 4,
"w": 6,
"h": 4
},
"id": 12,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_vaps",
"legendFormat": "VAPs",
"refId": "A"
}
],
"title": "VAPs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 16,
"w": 12,
"h": 8
},
"id": 13,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"validation\"}",
"legendFormat": "validation",
"refId": "A"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"report_write\"}",
"legendFormat": "report write",
"refId": "E"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"ccr_write\"}",
"legendFormat": "ccr write",
"refId": "F"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"cir_write\"}",
"legendFormat": "cir write",
"refId": "G"
}
],
"title": "Output Stage Durations",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 40,
"tags": [
"ours-rp",
"rpki",
"soak"
],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Ours RP Soak Overview",
"uid": "ours-rp-soak-overview",
"version": 4,
"weekStart": ""
}

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: ours-rp
orgId: 1
folder: Ours RP
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards

View File

@ -0,0 +1,10 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true

View File

@ -0,0 +1,13 @@
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: ours-rp-artifact-metrics
metrics_path: /metrics
static_configs:
- targets:
- host.docker.internal:9556
labels:
rp: ours-rp
source: artifact-sidecar

View File

@ -0,0 +1,70 @@
# RPKI Benchmarks (Stage2, selected_der_v2)
This directory contains a reproducible, one-click benchmark to measure **decode + profile validate**
performance for all supported object types and compare **OURS** against the **Routinator baseline**
(`rpki` crate `=0.19.1` with `repository` feature).
## What it measures
Dataset:
- Fixtures: `rpki/tests/benchmark/selected_der_v2/`
- Objects: `cer`, `crl`, `manifest` (`.mft`), `roa`, `aspa` (`.asa`)
- Samples: 10 quantiles per type (`min/p01/p10/p25/p50/p75/p90/p95/p99/max`) → 50 files total
Metrics:
- **decode+validate**: `decode_der` (parse + profile validate) for each object file
- **landing** (OURS only): `PackFile::from_bytes_compute_sha256` + CBOR encode + `RocksDB put_raw`
- **compare**: ratio `ours_ns/op ÷ rout_ns/op` for decode+validate
## Default benchmark settings
Both OURS and Routinator baseline use the same run settings:
- warmup: `10` iterations
- rounds: `3`
- adaptive loop target: `min_round_ms=200` (with an internal max of `1_000_000` iters)
- strict DER: `true` (baseline)
- cert inspect: `false` (baseline)
You can override the settings via environment variables in the runner script:
- `BENCH_WARMUP_ITERS` (default `10`)
- `BENCH_ROUNDS` (default `3`)
- `BENCH_MIN_ROUND_MS` (default `200`)
## One-click run (OURS + Routinator compare)
From the `rpki/` crate directory:
```bash
./scripts/benchmark/run_stage2_selected_der_v2_release.sh
```
Outputs are written under:
- `rpki/target/bench/`
- OURS decode+validate: `stage2_selected_der_v2_decode_release_<TS>.{md,csv}`
- OURS landing: `stage2_selected_der_v2_landing_release_<TS>.{md,csv}`
- Routinator: `stage2_selected_der_v2_routinator_decode_release_<TS>.{md,csv}`
- Compare: `stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_<TS>.{md,csv}`
- Summary: `stage2_selected_der_v2_compare_summary_<TS>.md`
### Why decode and landing are separated
The underlying benchmark can run in `BENCH_MODE=both`, but the **landing** part writes to RocksDB
and may trigger background work (e.g., compactions) that can **skew subsequent decode timings**.
For a fair OURS-vs-Routinator comparison, the runner script:
- runs `BENCH_MODE=decode_validate` for comparison, and
- runs `BENCH_MODE=landing` separately for landing-only numbers.
## Notes
- The Routinator baseline benchmark is implemented in-repo under:
- `rpki/benchmark/routinator_object_bench/`
- It pins `rpki = "=0.19.1"` in its `Cargo.toml`.
- This benchmark is implemented as an `#[ignore]` integration test:
- `rpki/tests/bench_stage2_decode_profile_selected_der_v2.rs`
- The runner script invokes it with `cargo test --release ... -- --ignored --nocapture`.

View File

@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
# Stage2 (selected_der_v2) decode+profile validate benchmark.
# Runs:
# 1) OURS decode+validate benchmark and writes MD/CSV.
# 2) OURS landing benchmark and writes MD/CSV.
# 3) Routinator baseline decode benchmark (rpki crate =0.19.1).
# 4) Produces a joined compare CSV/MD and a short geomean summary.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
OUT_DIR="$ROOT_DIR/target/bench"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
WARMUP_ITERS="${BENCH_WARMUP_ITERS:-10}"
ROUNDS="${BENCH_ROUNDS:-3}"
MIN_ROUND_MS="${BENCH_MIN_ROUND_MS:-200}"
OURS_MD="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.md"
OURS_CSV="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.csv"
OURS_LANDING_MD="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.md"
OURS_LANDING_CSV="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.csv"
ROUT_MD="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.md"
ROUT_CSV="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.csv"
COMPARE_MD="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.md"
COMPARE_CSV="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.csv"
SUMMARY_MD="$OUT_DIR/stage2_selected_der_v2_compare_summary_${TS}.md"
echo "[1/4] OURS: decode+validate benchmark (release)..." >&2
BENCH_MODE="decode_validate" \
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
BENCH_ROUNDS="$ROUNDS" \
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
BENCH_OUT_MD="$OURS_MD" \
BENCH_OUT_CSV="$OURS_CSV" \
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
echo "[2/4] OURS: landing benchmark (release)..." >&2
BENCH_MODE="landing" \
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
BENCH_ROUNDS="$ROUNDS" \
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
BENCH_OUT_MD_LANDING="$OURS_LANDING_MD" \
BENCH_OUT_CSV_LANDING="$OURS_LANDING_CSV" \
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
echo "[3/4] Routinator baseline + compare join..." >&2
OURS_CSV="$OURS_CSV" \
ROUT_CSV="$ROUT_CSV" \
ROUT_MD="$ROUT_MD" \
COMPARE_CSV="$COMPARE_CSV" \
COMPARE_MD="$COMPARE_MD" \
WARMUP_ITERS="$WARMUP_ITERS" \
ROUNDS="$ROUNDS" \
MIN_ROUND_MS="$MIN_ROUND_MS" \
scripts/stage2_perf_compare_m4.sh >/dev/null
echo "[4/4] Summary (geomean ratios)..." >&2
python3 - "$COMPARE_CSV" "$SUMMARY_MD" <<'PY'
import csv
import math
import sys
from pathlib import Path
from datetime import datetime, timezone
in_csv = Path(sys.argv[1])
out_md = Path(sys.argv[2])
rows = list(csv.DictReader(in_csv.open(newline="")))
ratios = {}
for r in rows:
ratios.setdefault(r["type"], []).append(float(r["ratio_ours_over_rout"]))
def geomean(vals):
return math.exp(sum(math.log(v) for v in vals) / len(vals))
def p50(vals):
v = sorted(vals)
n = len(v)
if n % 2 == 1:
return v[n // 2]
return (v[n // 2 - 1] + v[n // 2]) / 2.0
all_vals = [float(r["ratio_ours_over_rout"]) for r in rows]
types = ["all"] + sorted(ratios.keys())
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lines = []
lines.append("# Stage2 selected_der_v2 compare summary (release)\n\n")
lines.append(f"- recorded_at_utc: `{now}`\n")
lines.append(f"- inputs_csv: `{in_csv}`\n\n")
lines.append("| type | n | min | p50 | geomean | max | >1 count |\n")
lines.append("|---|---:|---:|---:|---:|---:|---:|\n")
for t in types:
vals = all_vals if t == "all" else ratios[t]
vals_sorted = sorted(vals)
lines.append(
f"| {t} | {len(vals_sorted)} | {vals_sorted[0]:.4f} | {p50(vals_sorted):.4f} | "
f"{geomean(vals_sorted):.4f} | {vals_sorted[-1]:.4f} | {sum(1 for v in vals_sorted if v>1.0)} |\n"
)
out_md.write_text("".join(lines), encoding="utf-8")
print(out_md)
PY
echo "Done." >&2
echo "- OURS decode MD: $OURS_MD" >&2
echo "- OURS decode CSV: $OURS_CSV" >&2
echo "- OURS landing MD: $OURS_LANDING_MD" >&2
echo "- OURS landing CSV: $OURS_LANDING_CSV" >&2
echo "- Routinator: $ROUT_MD" >&2
echo "- Compare MD: $COMPARE_MD" >&2
echo "- Compare CSV: $COMPARE_CSV" >&2
echo "- Summary MD: $SUMMARY_MD" >&2

56
scripts/cir/README.md Normal file
View File

@ -0,0 +1,56 @@
# CIR Scripts
## `cir-rsync-wrapper`
一个用于 CIR 黑盒 replay 的 rsync wrapper。
### 环境变量
- `REAL_RSYNC_BIN`
- 真实 rsync 二进制路径
- 默认优先 `/usr/bin/rsync`
- `CIR_MIRROR_ROOT`
- 本地镜像树根目录
- 当命令行中出现 `rsync://...` source 时必需
### 语义
- 仅改写 `rsync://host/path` 类型参数
- 其它参数原样透传给真实 rsync
- 改写目标:
- `rsync://example.net/repo/a.roa`
- →
- `<CIR_MIRROR_ROOT>/example.net/repo/a.roa`
### 兼容目标
- Routinator `--rsync-command`
- `rpki-client -e rsync_prog`
## 其它脚本
- `run_cir_replay_ours.sh`
- `run_cir_replay_routinator.sh`
- `run_cir_replay_rpki_client.sh`
- `run_cir_replay_matrix.sh`
## `cir-local-link-sync.py`
`CIR_LOCAL_LINK_MODE=1` 且 wrapper 检测到 source 已经被改写为本地 mirror 路径时,
wrapper 不再调用真实 `rsync`,而是调用这个 helper 完成:
- `hardlink` 优先的本地树同步
- 失败时回退到 copy
- 支持 `--delete`
`run_cir_replay_matrix.sh` 会顺序执行:
- `ours`
- Routinator
- `rpki-client`
并汇总生成:
- `summary.json`
- `summary.md`
- `detail.md`

View File

@ -0,0 +1,136 @@
#!/usr/bin/env python3
import argparse
import errno
import os
import shutil
from pathlib import Path
def _same_inode(src: Path, dst: Path) -> bool:
try:
src_stat = src.stat()
dst_stat = dst.stat()
except FileNotFoundError:
return False
return (src_stat.st_dev, src_stat.st_ino) == (dst_stat.st_dev, dst_stat.st_ino)
def _remove_path(path: Path) -> None:
if not path.exists() and not path.is_symlink():
return
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def _prune_empty_dirs(root: Path) -> None:
if not root.exists():
return
for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
try:
path.rmdir()
except OSError:
pass
def _link_or_copy(src: Path, dst: Path) -> str:
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists() or dst.is_symlink():
if _same_inode(src, dst):
return "reused"
_remove_path(dst)
try:
os.link(src, dst)
return "linked"
except OSError as err:
if err.errno not in (errno.EXDEV, errno.EPERM, errno.EMLINK, errno.ENOTSUP, errno.EACCES):
raise
shutil.copy2(src, dst)
return "copied"
def _file_map(src_arg: str, dest_arg: str) -> tuple[Path, dict[str, Path]]:
src = Path(src_arg.rstrip(os.sep))
if not src.exists():
raise FileNotFoundError(src)
mapping: dict[str, Path] = {}
if src.is_dir():
copy_contents = src_arg.endswith(os.sep)
if copy_contents:
root = src
for path in root.rglob("*"):
if path.is_file():
mapping[path.relative_to(root).as_posix()] = path
else:
root = src
base = src.name
for path in root.rglob("*"):
if path.is_file():
rel = Path(base) / path.relative_to(root)
mapping[rel.as_posix()] = path
else:
dest_path = Path(dest_arg)
if dest_arg.endswith(os.sep) or dest_path.is_dir():
mapping[src.name] = src
else:
mapping[dest_path.name] = src
return Path(dest_arg), mapping
def sync_local_tree(src_arg: str, dst_arg: str, delete: bool) -> dict[str, int]:
dst_root, mapping = _file_map(src_arg, dst_arg)
dst_root.mkdir(parents=True, exist_ok=True)
expected = {dst_root / rel for rel in mapping.keys()}
deleted = 0
if delete and dst_root.exists():
for path in sorted(dst_root.rglob("*"), key=lambda p: len(p.parts), reverse=True):
if path.is_dir():
continue
if path not in expected:
_remove_path(path)
deleted += 1
_prune_empty_dirs(dst_root)
linked = 0
copied = 0
reused = 0
for rel, src in mapping.items():
dst = dst_root / rel
result = _link_or_copy(src, dst)
if result == "linked":
linked += 1
elif result == "copied":
copied += 1
else:
reused += 1
return {
"files": len(mapping),
"linked": linked,
"copied": copied,
"reused": reused,
"deleted": deleted,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Sync a local CIR mirror tree using hardlinks when possible.")
parser.add_argument("--delete", action="store_true", help="Delete target files not present in source")
parser.add_argument("source")
parser.add_argument("dest")
args = parser.parse_args()
summary = sync_local_tree(args.source, args.dest, args.delete)
print(
"local-link-sync files={files} linked={linked} copied={copied} reused={reused} deleted={deleted}".format(
**summary
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

127
scripts/cir/cir-rsync-wrapper Executable file
View File

@ -0,0 +1,127 @@
#!/usr/bin/env python3
import os
import shutil
import sys
from pathlib import Path
from urllib.parse import urlparse
def real_rsync_bin() -> str:
env = os.environ.get("REAL_RSYNC_BIN")
if env:
return env
default = "/usr/bin/rsync"
if Path(default).exists():
return default
found = shutil.which("rsync")
if found:
return found
raise SystemExit("cir-rsync-wrapper: REAL_RSYNC_BIN is not set and rsync was not found")
def rewrite_arg(arg: str, mirror_root: str | None) -> str:
if not arg.startswith("rsync://"):
return arg
if not mirror_root:
raise SystemExit(
"cir-rsync-wrapper: CIR_MIRROR_ROOT is required when an rsync:// source is present"
)
parsed = urlparse(arg)
if parsed.scheme != "rsync" or not parsed.hostname:
raise SystemExit(f"cir-rsync-wrapper: invalid rsync URI: {arg}")
path = parsed.path.lstrip("/")
local = Path(mirror_root).resolve() / parsed.hostname
if path:
local = local / path
local_str = str(local)
if local.exists() and local.is_dir() and not local_str.endswith("/"):
local_str += "/"
elif arg.endswith("/") and not local_str.endswith("/"):
local_str += "/"
return local_str
def filter_args(args: list[str]) -> list[str]:
mirror_root = os.environ.get("CIR_MIRROR_ROOT")
rewritten_any = any(arg.startswith("rsync://") for arg in args)
out: list[str] = []
i = 0
while i < len(args):
arg = args[i]
if rewritten_any:
if arg == "--address":
i += 2
continue
if arg.startswith("--address="):
i += 1
continue
if arg == "--contimeout":
i += 2
continue
if arg.startswith("--contimeout="):
i += 1
continue
out.append(rewrite_arg(arg, mirror_root))
i += 1
return out
def local_link_mode_enabled() -> bool:
value = os.environ.get("CIR_LOCAL_LINK_MODE", "")
return value.lower() in {"1", "true", "yes", "on"}
def extract_source_and_dest(args: list[str]) -> tuple[str, str]:
expects_value = {
"--timeout",
"--min-size",
"--max-size",
"--include",
"--exclude",
"--compare-dest",
}
positionals: list[str] = []
i = 0
while i < len(args):
arg = args[i]
if arg in expects_value:
i += 2
continue
if any(arg.startswith(prefix + "=") for prefix in expects_value):
i += 1
continue
if arg.startswith("-"):
i += 1
continue
positionals.append(arg)
i += 1
if len(positionals) < 2:
raise SystemExit("cir-rsync-wrapper: expected source and destination arguments")
return positionals[-2], positionals[-1]
def maybe_exec_local_link_sync(args: list[str], rewritten_any: bool) -> None:
if not rewritten_any or not local_link_mode_enabled():
return
source, dest = extract_source_and_dest(args)
if source.startswith("rsync://"):
raise SystemExit("cir-rsync-wrapper: expected rewritten local source for CIR_LOCAL_LINK_MODE")
helper = Path(__file__).with_name("cir-local-link-sync.py")
cmd = [sys.executable, str(helper)]
if "--delete" in args:
cmd.append("--delete")
cmd.extend([source, dest])
os.execv(sys.executable, cmd)
def main() -> int:
args = sys.argv[1:]
rewritten_any = any(arg.startswith("rsync://") for arg in args)
rewritten = filter_args(args)
maybe_exec_local_link_sync(rewritten, rewritten_any)
os.execv(real_rsync_bin(), [real_rsync_bin(), *rewritten])
return 127
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/fetch_cir_sequence_from_remote.sh \
--ssh-target <user@host> \
--remote-path <path> \
--local-path <path>
EOF
}
SSH_TARGET=""
REMOTE_PATH=""
LOCAL_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-path) REMOTE_PATH="$2"; shift 2 ;;
--local-path) LOCAL_PATH="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SSH_TARGET" && -n "$REMOTE_PATH" && -n "$LOCAL_PATH" ]] || { usage >&2; exit 2; }
mkdir -p "$(dirname "$LOCAL_PATH")"
rsync -a "$SSH_TARGET:$REMOTE_PATH/" "$LOCAL_PATH/"
echo "done: $LOCAL_PATH"

50
scripts/cir/json_to_vaps_csv.py Executable file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
def normalize_asn(value: str | int) -> str:
text = str(value).strip().upper()
if text.startswith("AS"):
text = text[2:]
return f"AS{int(text)}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--csv-out", required=True, type=Path)
args = parser.parse_args()
obj = json.loads(args.input.read_text(encoding="utf-8"))
rows: list[tuple[str, str, str]] = []
for aspa in obj.get("aspas", []):
providers = sorted(
{normalize_asn(item) for item in aspa.get("providers", [])},
key=lambda s: int(s[2:]),
)
rows.append(
(
normalize_asn(aspa["customer"]),
";".join(providers),
str(aspa.get("ta", "")).strip().lower(),
)
)
rows.sort(key=lambda row: (int(row[0][2:]), row[1], row[2]))
args.csv_out.parent.mkdir(parents=True, exist_ok=True)
with args.csv_out.open("w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["Customer ASN", "Providers", "Trust Anchor"])
writer.writerows(rows)
print(args.csv_out)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_drop_sequence.sh \
--sequence-root <path> \
[--drop-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
DROP_BIN="${DROP_BIN:-$ROOT_DIR/target/release/cir_drop_report}"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--drop-bin) DROP_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" ]] || { usage >&2; exit 2; }
python3 - <<'PY' "$SEQUENCE_ROOT" "$DROP_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1]).resolve()
drop_bin = sys.argv[2]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
repo_bytes_db = sequence_root / sequence["repoBytesDbPath"]
summaries = []
for step in sequence["steps"]:
step_id = step["stepId"]
out_dir = sequence_root / "drop" / step_id
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
drop_bin,
"--cir",
str(sequence_root / step["cirPath"]),
"--ccr",
str(sequence_root / step["ccrPath"]),
"--report-json",
str(sequence_root / step["reportPath"]),
"--json-out",
str(out_dir / "drop.json"),
"--md-out",
str(out_dir / "drop.md"),
]
cmd.extend(["--repo-bytes-db", str(repo_bytes_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"drop report failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
result = json.loads((out_dir / "drop.json").read_text(encoding="utf-8"))
summaries.append(
{
"stepId": step_id,
"droppedVrpCount": result["summary"]["droppedVrpCount"],
"droppedObjectCount": result["summary"]["droppedObjectCount"],
"reportPath": str(out_dir / "drop.json"),
}
)
summary = {"version": 1, "steps": summaries}
(sequence_root / "drop-summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@ -0,0 +1,173 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_full_delta.sh \
--out-dir <path> \
--tal-path <path> \
--ta-path <path> \
--cir-tal-uri <url> \
--payload-replay-archive <path> \
--payload-replay-locks <path> \
--payload-base-archive <path> \
--payload-base-locks <path> \
--payload-delta-archive <path> \
--payload-delta-locks <path> \
[--base-validation-time <rfc3339>] \
[--delta-validation-time <rfc3339>] \
[--max-depth <n>] \
[--max-instances <n>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR=""
TAL_PATH=""
TA_PATH=""
CIR_TAL_URI=""
PAYLOAD_REPLAY_ARCHIVE=""
PAYLOAD_REPLAY_LOCKS=""
PAYLOAD_BASE_ARCHIVE=""
PAYLOAD_BASE_LOCKS=""
PAYLOAD_DELTA_ARCHIVE=""
PAYLOAD_DELTA_LOCKS=""
BASE_VALIDATION_TIME=""
DELTA_VALIDATION_TIME=""
MAX_DEPTH=0
MAX_INSTANCES=1
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--out-dir) OUT_DIR="$2"; shift 2 ;;
--tal-path) TAL_PATH="$2"; shift 2 ;;
--ta-path) TA_PATH="$2"; shift 2 ;;
--cir-tal-uri) CIR_TAL_URI="$2"; shift 2 ;;
--payload-replay-archive) PAYLOAD_REPLAY_ARCHIVE="$2"; shift 2 ;;
--payload-replay-locks) PAYLOAD_REPLAY_LOCKS="$2"; shift 2 ;;
--payload-base-archive) PAYLOAD_BASE_ARCHIVE="$2"; shift 2 ;;
--payload-base-locks) PAYLOAD_BASE_LOCKS="$2"; shift 2 ;;
--payload-delta-archive) PAYLOAD_DELTA_ARCHIVE="$2"; shift 2 ;;
--payload-delta-locks) PAYLOAD_DELTA_LOCKS="$2"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="$2"; shift 2 ;;
--delta-validation-time) DELTA_VALIDATION_TIME="$2"; shift 2 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OUT_DIR" && -n "$TAL_PATH" && -n "$TA_PATH" && -n "$CIR_TAL_URI" && -n "$PAYLOAD_REPLAY_ARCHIVE" && -n "$PAYLOAD_REPLAY_LOCKS" && -n "$PAYLOAD_BASE_ARCHIVE" && -n "$PAYLOAD_BASE_LOCKS" && -n "$PAYLOAD_DELTA_ARCHIVE" && -n "$PAYLOAD_DELTA_LOCKS" ]] || {
usage >&2
exit 2
}
if [[ ! -x "$RPKI_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki
)
fi
resolve_validation_time() {
local path="$1"
python3 - <<'PY' "$path"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['validationTime'])
PY
}
if [[ -z "$BASE_VALIDATION_TIME" ]]; then
BASE_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_REPLAY_LOCKS")"
fi
if [[ -z "$DELTA_VALIDATION_TIME" ]]; then
DELTA_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_DELTA_LOCKS")"
fi
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/full" "$OUT_DIR/delta-001"
REPO_BYTES_DB="$OUT_DIR/repo-bytes.db"
FULL_DB="$OUT_DIR/full/db"
DELTA_DB="$OUT_DIR/delta-001/db"
"$RPKI_BIN" \
--db "$FULL_DB" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE" \
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS" \
--validation-time "$BASE_VALIDATION_TIME" \
--max-depth "$MAX_DEPTH" \
--max-instances "$MAX_INSTANCES" \
--ccr-out "$OUT_DIR/full/result.ccr" \
--report-json "$OUT_DIR/full/report.json" \
--cir-enable \
--cir-out "$OUT_DIR/full/input.cir" \
--repo-bytes-db "$REPO_BYTES_DB" \
--cir-tal-uri "$CIR_TAL_URI" \
>"$OUT_DIR/full/run.stdout.log" 2>"$OUT_DIR/full/run.stderr.log"
"$RPKI_BIN" \
--db "$DELTA_DB" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--payload-base-archive "$PAYLOAD_BASE_ARCHIVE" \
--payload-base-locks "$PAYLOAD_BASE_LOCKS" \
--payload-delta-archive "$PAYLOAD_DELTA_ARCHIVE" \
--payload-delta-locks "$PAYLOAD_DELTA_LOCKS" \
--payload-base-validation-time "$BASE_VALIDATION_TIME" \
--validation-time "$DELTA_VALIDATION_TIME" \
--max-depth "$MAX_DEPTH" \
--max-instances "$MAX_INSTANCES" \
--ccr-out "$OUT_DIR/delta-001/result.ccr" \
--report-json "$OUT_DIR/delta-001/report.json" \
--cir-enable \
--cir-out "$OUT_DIR/delta-001/input.cir" \
--repo-bytes-db "$REPO_BYTES_DB" \
--cir-tal-uri "$CIR_TAL_URI" \
>"$OUT_DIR/delta-001/run.stdout.log" 2>"$OUT_DIR/delta-001/run.stderr.log"
python3 - <<'PY' "$OUT_DIR" "$BASE_VALIDATION_TIME" "$DELTA_VALIDATION_TIME"
import json
import os
import sys
from pathlib import Path
out = Path(sys.argv[1])
base_validation_time = sys.argv[2]
delta_validation_time = sys.argv[3]
summary = {
"version": 1,
"kind": "cir_pair",
"baseValidationTime": base_validation_time,
"deltaValidationTime": delta_validation_time,
"repoBytesDbPath": "repo-bytes.db",
"steps": [
{
"kind": "full",
"cirPath": "full/input.cir",
"ccrPath": "full/result.ccr",
"reportPath": "full/report.json",
},
{
"kind": "delta",
"cirPath": "delta-001/input.cir",
"ccrPath": "delta-001/result.ccr",
"reportPath": "delta-001/report.json",
"previous": "full",
},
],
"repoBytesDbExists": (out / "repo-bytes.db").exists(),
}
(out / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
PY
echo "done: $OUT_DIR"

View File

@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_multi_rir_offline.sh \
[--bundle-root <path>] \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--delta-count <n>] \
[--full-repo] \
[--out-root <path>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CASE_INFO="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
SINGLE_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_record_sequence_offline.sh"
BUNDLE_ROOT="/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3"
RIRS="afrinic,apnic,arin,lacnic,ripe"
DELTA_COUNT=2
FULL_REPO=0
OUT_ROOT="$ROOT_DIR/target/replay/cir_sequence_multi_rir_offline_$(date -u +%Y%m%dT%H%M%SZ)"
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-root) BUNDLE_ROOT="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--out-root) OUT_ROOT="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
mkdir -p "$OUT_ROOT"
SUMMARY_JSON="$OUT_ROOT/summary.json"
SUMMARY_MD="$OUT_ROOT/summary.md"
IFS=',' read -r -a RIR_ITEMS <<< "$RIRS"
for rir in "${RIR_ITEMS[@]}"; do
CASE_JSON="$(python3 "$CASE_INFO" --bundle-root "$BUNDLE_ROOT" --repo-root "$ROOT_DIR" --rir "$rir")"
TAL_PATH="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['tal_path'])
PY
)"
TA_PATH="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['ta_path'])
PY
)"
BASE_ARCHIVE="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['base_archive'])
PY
)"
BASE_LOCKS="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['base_locks'])
PY
)"
DELTA_ARCHIVE="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['delta_archive'])
PY
)"
DELTA_LOCKS="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['delta_locks'])
PY
)"
OUT_DIR="$OUT_ROOT/$rir"
args=(
"$SINGLE_SCRIPT"
--out-dir "$OUT_DIR" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--cir-tal-uri "https://example.test/$rir.tal" \
--payload-replay-archive "$BASE_ARCHIVE" \
--payload-replay-locks "$BASE_LOCKS" \
--payload-base-archive "$BASE_ARCHIVE" \
--payload-base-locks "$BASE_LOCKS" \
--payload-delta-archive "$DELTA_ARCHIVE" \
--payload-delta-locks "$DELTA_LOCKS" \
--delta-count "$DELTA_COUNT" \
--rpki-bin "$RPKI_BIN"
)
if [[ "$FULL_REPO" -ne 1 ]]; then
args+=(--max-depth 0 --max-instances 1)
else
args+=(--full-repo)
fi
"${args[@]}"
done
python3 - <<'PY' "$OUT_ROOT" "$RIRS" "$SUMMARY_JSON" "$SUMMARY_MD"
import json, sys
from pathlib import Path
out_root = Path(sys.argv[1])
rirs = [item for item in sys.argv[2].split(',') if item]
summary_json = Path(sys.argv[3])
summary_md = Path(sys.argv[4])
items = []
for rir in rirs:
root = out_root / rir
seq = json.loads((root / "sequence.json").read_text(encoding="utf-8"))
summ = json.loads((root / "summary.json").read_text(encoding="utf-8"))
items.append({
"rir": rir,
"root": str(root),
"stepCount": len(seq["steps"]),
"repoBytesDbExists": summ.get("repoBytesDbExists", False),
})
summary = {"version": 1, "rirs": items}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = ["# Multi-RIR Offline CIR Sequence Summary", ""]
for item in items:
lines.append(f"- `{item['rir']}`: `stepCount={item['stepCount']}` `repoBytesDbExists={item['repoBytesDbExists']}` `root={item['root']}`")
summary_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "done: $OUT_ROOT"

View File

@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_offline.sh \
--out-dir <path> \
--tal-path <path> \
--ta-path <path> \
--cir-tal-uri <url> \
--payload-replay-archive <path> \
--payload-replay-locks <path> \
--payload-base-archive <path> \
--payload-base-locks <path> \
--payload-delta-archive <path> \
--payload-delta-locks <path> \
[--delta-count <n>] \
[--base-validation-time <rfc3339>] \
[--delta-validation-time <rfc3339>] \
[--full-repo] \
[--max-depth <n>] \
[--max-instances <n>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR=""
TAL_PATH=""
TA_PATH=""
CIR_TAL_URI=""
PAYLOAD_REPLAY_ARCHIVE=""
PAYLOAD_REPLAY_LOCKS=""
PAYLOAD_BASE_ARCHIVE=""
PAYLOAD_BASE_LOCKS=""
PAYLOAD_DELTA_ARCHIVE=""
PAYLOAD_DELTA_LOCKS=""
BASE_VALIDATION_TIME=""
DELTA_VALIDATION_TIME=""
DELTA_COUNT=2
FULL_REPO=0
MAX_DEPTH=0
MAX_INSTANCES=1
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--out-dir) OUT_DIR="$2"; shift 2 ;;
--tal-path) TAL_PATH="$2"; shift 2 ;;
--ta-path) TA_PATH="$2"; shift 2 ;;
--cir-tal-uri) CIR_TAL_URI="$2"; shift 2 ;;
--payload-replay-archive) PAYLOAD_REPLAY_ARCHIVE="$2"; shift 2 ;;
--payload-replay-locks) PAYLOAD_REPLAY_LOCKS="$2"; shift 2 ;;
--payload-base-archive) PAYLOAD_BASE_ARCHIVE="$2"; shift 2 ;;
--payload-base-locks) PAYLOAD_BASE_LOCKS="$2"; shift 2 ;;
--payload-delta-archive) PAYLOAD_DELTA_ARCHIVE="$2"; shift 2 ;;
--payload-delta-locks) PAYLOAD_DELTA_LOCKS="$2"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="$2"; shift 2 ;;
--delta-validation-time) DELTA_VALIDATION_TIME="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OUT_DIR" && -n "$TAL_PATH" && -n "$TA_PATH" && -n "$CIR_TAL_URI" && -n "$PAYLOAD_REPLAY_ARCHIVE" && -n "$PAYLOAD_REPLAY_LOCKS" && -n "$PAYLOAD_BASE_ARCHIVE" && -n "$PAYLOAD_BASE_LOCKS" && -n "$PAYLOAD_DELTA_ARCHIVE" && -n "$PAYLOAD_DELTA_LOCKS" ]] || {
usage >&2
exit 2
}
if [[ ! -x "$RPKI_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki
)
fi
resolve_validation_time() {
local path="$1"
python3 - <<'PY' "$path"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['validationTime'])
PY
}
if [[ -z "$BASE_VALIDATION_TIME" ]]; then
BASE_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_REPLAY_LOCKS")"
fi
if [[ -z "$DELTA_VALIDATION_TIME" ]]; then
DELTA_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_DELTA_LOCKS")"
fi
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/full"
REPO_BYTES_DB="$OUT_DIR/repo-bytes.db"
run_step() {
local kind="$1"
local step_dir="$2"
local db_dir="$3"
shift 3
mkdir -p "$step_dir"
local -a cmd=(
"$RPKI_BIN"
--db "$db_dir" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--ccr-out "$step_dir/result.ccr" \
--report-json "$step_dir/report.json" \
--cir-enable \
--cir-out "$step_dir/input.cir" \
--repo-bytes-db "$REPO_BYTES_DB" \
--cir-tal-uri "$CIR_TAL_URI"
)
if [[ "$FULL_REPO" -ne 1 ]]; then
cmd+=(--max-depth "$MAX_DEPTH" --max-instances "$MAX_INSTANCES")
fi
cmd+=("$@")
"${cmd[@]}" >"$step_dir/run.stdout.log" 2>"$step_dir/run.stderr.log"
}
run_step \
full \
"$OUT_DIR/full" \
"$OUT_DIR/full/db" \
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE" \
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS" \
--validation-time "$BASE_VALIDATION_TIME"
for idx in $(seq 1 "$DELTA_COUNT"); do
step_id="$(printf 'delta-%03d' "$idx")"
run_step \
delta \
"$OUT_DIR/$step_id" \
"$OUT_DIR/$step_id/db" \
--payload-base-archive "$PAYLOAD_BASE_ARCHIVE" \
--payload-base-locks "$PAYLOAD_BASE_LOCKS" \
--payload-delta-archive "$PAYLOAD_DELTA_ARCHIVE" \
--payload-delta-locks "$PAYLOAD_DELTA_LOCKS" \
--payload-base-validation-time "$BASE_VALIDATION_TIME" \
--validation-time "$DELTA_VALIDATION_TIME"
done
python3 - <<'PY' "$OUT_DIR" "$BASE_VALIDATION_TIME" "$DELTA_VALIDATION_TIME" "$DELTA_COUNT"
import json
import sys
from pathlib import Path
out = Path(sys.argv[1])
base_validation_time = sys.argv[2]
delta_validation_time = sys.argv[3]
delta_count = int(sys.argv[4])
steps = [
{
"stepId": "full",
"kind": "full",
"validationTime": base_validation_time,
"cirPath": "full/input.cir",
"ccrPath": "full/result.ccr",
"reportPath": "full/report.json",
"previousStepId": None,
}
]
previous = "full"
for idx in range(1, delta_count + 1):
step_id = f"delta-{idx:03d}"
steps.append(
{
"stepId": step_id,
"kind": "delta",
"validationTime": delta_validation_time,
"cirPath": f"{step_id}/input.cir",
"ccrPath": f"{step_id}/result.ccr",
"reportPath": f"{step_id}/report.json",
"previousStepId": previous,
}
)
previous = step_id
summary = {
"version": 1,
"kind": "cir_sequence_offline",
"repoBytesDbPath": "repo-bytes.db",
"steps": steps,
}
(out / "sequence.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
(out / "summary.json").write_text(
json.dumps(
{
"version": 1,
"stepCount": len(steps),
"repoBytesDbPath": "repo-bytes.db",
"repoBytesDbExists": (out / "repo-bytes.db").exists(),
},
indent=2,
),
encoding="utf-8",
)
PY
echo "done: $OUT_DIR"

View File

@ -0,0 +1,246 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_remote.sh \
--rir <name> \
--remote-root <path> \
[--ssh-target <user@host>] \
[--out-subdir <path>] \
[--delta-count <n>] \
[--sleep-secs <n>] \
[--full-repo] \
[--max-depth <n>] \
[--max-instances <n>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
RIR=""
REMOTE_ROOT=""
OUT_SUBDIR=""
DELTA_COUNT=2
SLEEP_SECS=30
FULL_REPO=0
MAX_DEPTH=0
MAX_INSTANCES=1
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIR="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--out-subdir) OUT_SUBDIR="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--sleep-secs) SLEEP_SECS="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RIR" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
case "$RIR" in
afrinic) TAL_REL="tests/fixtures/tal/afrinic.tal"; TA_REL="tests/fixtures/ta/afrinic-ta.cer" ;;
apnic) TAL_REL="tests/fixtures/tal/apnic-rfc7730-https.tal"; TA_REL="tests/fixtures/ta/apnic-ta.cer" ;;
arin) TAL_REL="tests/fixtures/tal/arin.tal"; TA_REL="tests/fixtures/ta/arin-ta.cer" ;;
lacnic) TAL_REL="tests/fixtures/tal/lacnic.tal"; TA_REL="tests/fixtures/ta/lacnic-ta.cer" ;;
ripe) TAL_REL="tests/fixtures/tal/ripe-ncc.tal"; TA_REL="tests/fixtures/ta/ripe-ncc-ta.cer" ;;
*) echo "unsupported rir: $RIR" >&2; exit 2 ;;
esac
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_ROOT/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT/target/release'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_ROOT/target/release/"
ssh "$SSH_TARGET" \
RIR="$RIR" \
REMOTE_ROOT="$REMOTE_ROOT" \
OUT_SUBDIR="$OUT_SUBDIR" \
DELTA_COUNT="$DELTA_COUNT" \
SLEEP_SECS="$SLEEP_SECS" \
FULL_REPO="$FULL_REPO" \
MAX_DEPTH="$MAX_DEPTH" \
MAX_INSTANCES="$MAX_INSTANCES" \
TAL_REL="$TAL_REL" \
TA_REL="$TA_REL" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_ROOT"
if [[ -n "${OUT_SUBDIR}" ]]; then
OUT="${OUT_SUBDIR}"
else
OUT="target/replay/cir_sequence_remote_${RIR}_$(date -u +%Y%m%dT%H%M%SZ)"
fi
mkdir -p "$OUT"
DB="$OUT/work-db"
RAW_STORE_DB="$OUT/raw-store.db"
REPO_BYTES_DB="$OUT/repo-bytes.db"
ROWS="$OUT/.sequence_rows.tsv"
: > "$ROWS"
write_step_timing() {
local path="$1"
local start_ms="$2"
local end_ms="$3"
local started_at="$4"
local finished_at="$5"
python3 - <<'PY' "$path" "$start_ms" "$end_ms" "$started_at" "$finished_at"
import json, sys
path, start_ms, end_ms, started_at, finished_at = sys.argv[1:]
start_ms = int(start_ms)
end_ms = int(end_ms)
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"durationMs": end_ms - start_ms,
"startedAt": started_at,
"finishedAt": finished_at,
},
fh,
indent=2,
)
PY
}
run_step() {
local step_id="$1"
local kind="$2"
local previous_step_id="$3"
shift 3
local started_at_iso started_at_ms finished_at_iso finished_at_ms prefix
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
prefix="${started_at_iso}-test"
local cir_out="$OUT/${prefix}.cir"
local ccr_out="$OUT/${prefix}.ccr"
local report_out="$OUT/${prefix}.report.json"
local timing_out="$OUT/${prefix}.timing.json"
local stdout_out="$OUT/${prefix}.stdout.log"
local stderr_out="$OUT/${prefix}.stderr.log"
local -a cmd=(
target/release/rpki
--db "$DB"
--raw-store-db "$RAW_STORE_DB"
--repo-bytes-db "$REPO_BYTES_DB"
--tal-path "$TAL_REL"
--ta-path "$TA_REL"
--ccr-out "$ccr_out"
--report-json "$report_out"
--cir-enable
--cir-out "$cir_out"
--cir-tal-uri "https://example.test/${RIR}.tal"
)
if [[ "$FULL_REPO" -ne 1 ]]; then
cmd+=(--max-depth "$MAX_DEPTH" --max-instances "$MAX_INSTANCES")
fi
cmd+=("$@")
env RPKI_PROGRESS_LOG=1 "${cmd[@]}" >"$stdout_out" 2>"$stderr_out"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
write_step_timing "$timing_out" "$started_at_ms" "$finished_at_ms" "$started_at_iso" "$finished_at_iso"
local validation_time
validation_time="$(python3 - <<'PY' "$report_out"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['meta']['validation_time_rfc3339_utc'])
PY
)"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_id" \
"$kind" \
"$validation_time" \
"$(basename "$cir_out")" \
"$(basename "$ccr_out")" \
"$(basename "$report_out")" \
"$(basename "$timing_out")" \
"$(basename "$stdout_out")" \
"$(basename "$stderr_out")" >> "$ROWS"
}
run_step "full" "full" ""
prev="full"
for idx in $(seq 1 "$DELTA_COUNT"); do
sleep "$SLEEP_SECS"
step="$(printf 'delta-%03d' "$idx")"
run_step "$step" "delta" "$prev"
prev="$step"
done
python3 - <<'PY' "$OUT" "$ROWS" "$RIR"
import json, sys
from pathlib import Path
out = Path(sys.argv[1])
rows = Path(sys.argv[2]).read_text(encoding='utf-8').splitlines()
rir = sys.argv[3]
steps = []
for idx, row in enumerate(rows):
step_id, kind, validation_time, cir_name, ccr_name, report_name, timing_name, stdout_name, stderr_name = row.split('\t')
steps.append({
"stepId": step_id,
"kind": kind,
"validationTime": validation_time,
"cirPath": cir_name,
"ccrPath": ccr_name,
"reportPath": report_name,
"timingPath": timing_name,
"stdoutLogPath": stdout_name,
"stderrLogPath": stderr_name,
"artifactPrefix": cir_name[:-4], # strip .cir
"previousStepId": None if idx == 0 else steps[idx - 1]["stepId"],
})
(out / "sequence.json").write_text(
json.dumps({"version": 1, "repoBytesDbPath": "repo-bytes.db", "steps": steps}, indent=2),
encoding="utf-8",
)
summary = {
"version": 1,
"rir": rir,
"stepCount": len(steps),
"steps": [],
}
for step in steps:
timing = json.loads((out / step["timingPath"]).read_text(encoding="utf-8"))
summary["steps"].append({
"stepId": step["stepId"],
"kind": step["kind"],
"validationTime": step["validationTime"],
"artifactPrefix": step["artifactPrefix"],
**timing,
})
(out / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
PY
rm -f "$ROWS"
echo "$OUT"
EOS

View File

@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_remote_multi_rir.sh \
--remote-root <path> \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--ssh-target <user@host>] \
[--out-subdir-root <path>] \
[--delta-count <n>] \
[--sleep-secs <n>] \
[--full-repo] \
[--max-depth <n>] \
[--max-instances <n>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
RIRS="afrinic,apnic,arin,lacnic,ripe"
OUT_SUBDIR_ROOT=""
DELTA_COUNT=2
SLEEP_SECS=30
FULL_REPO=0
MAX_DEPTH=0
MAX_INSTANCES=1
SINGLE="$ROOT_DIR/scripts/cir/run_cir_record_sequence_remote.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--out-subdir-root) OUT_SUBDIR_ROOT="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--sleep-secs) SLEEP_SECS="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
if [[ -z "$OUT_SUBDIR_ROOT" ]]; then
OUT_SUBDIR_ROOT="target/replay/cir_sequence_remote_multi_rir_$(date -u +%Y%m%dT%H%M%SZ)"
fi
IFS=',' read -r -a ITEMS <<< "$RIRS"
for rir in "${ITEMS[@]}"; do
args=(
"$SINGLE"
--rir "$rir" \
--remote-root "$REMOTE_ROOT" \
--ssh-target "$SSH_TARGET" \
--out-subdir "$OUT_SUBDIR_ROOT/$rir" \
--delta-count "$DELTA_COUNT" \
--sleep-secs "$SLEEP_SECS" \
)
if [[ "$FULL_REPO" -eq 1 ]]; then
args+=(--full-repo)
else
args+=(--max-depth "$MAX_DEPTH" --max-instances "$MAX_INSTANCES")
fi
"${args[@]}"
done
echo "$OUT_SUBDIR_ROOT"

View File

@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_ta_only_multi_rir.sh \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--delta-count <n>] \
[--out-root <path>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HELPER_BIN="${HELPER_BIN:-$ROOT_DIR/target/release/cir_ta_only_fixture}"
MATERIALIZE_BIN="${MATERIALIZE_BIN:-$ROOT_DIR/target/release/cir_materialize}"
EXTRACT_BIN="${EXTRACT_BIN:-$ROOT_DIR/target/release/cir_extract_inputs}"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
RIRS="afrinic,apnic,arin,lacnic,ripe"
DELTA_COUNT=2
OUT_ROOT="$ROOT_DIR/target/replay/cir_sequence_multi_rir_ta_only_$(date -u +%Y%m%dT%H%M%SZ)"
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIRS="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--out-root) OUT_ROOT="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
if [[ ! -x "$HELPER_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin cir_ta_only_fixture --bin rpki --bin cir_materialize --bin cir_extract_inputs
)
fi
case_paths() {
case "$1" in
afrinic) echo "tests/fixtures/tal/afrinic.tal tests/fixtures/ta/afrinic-ta.cer" ;;
apnic) echo "tests/fixtures/tal/apnic-rfc7730-https.tal tests/fixtures/ta/apnic-ta.cer" ;;
arin) echo "tests/fixtures/tal/arin.tal tests/fixtures/ta/arin-ta.cer" ;;
lacnic) echo "tests/fixtures/tal/lacnic.tal tests/fixtures/ta/lacnic-ta.cer" ;;
ripe) echo "tests/fixtures/tal/ripe-ncc.tal tests/fixtures/ta/ripe-ncc-ta.cer" ;;
*) return 1 ;;
esac
}
mkdir -p "$OUT_ROOT"
IFS=',' read -r -a ITEMS <<< "$RIRS"
for rir in "${ITEMS[@]}"; do
read -r tal_rel ta_rel < <(case_paths "$rir")
rir_root="$OUT_ROOT/$rir"
mkdir -p "$rir_root/full"
repo_bytes_db="$rir_root/repo-bytes.db"
"$HELPER_BIN" \
--tal-path "$ROOT_DIR/$tal_rel" \
--ta-path "$ROOT_DIR/$ta_rel" \
--tal-uri "https://example.test/$rir.tal" \
--validation-time "2026-04-09T00:00:00Z" \
--cir-out "$rir_root/full/input.cir" \
--repo-bytes-db "$repo_bytes_db"
"$EXTRACT_BIN" --cir "$rir_root/full/input.cir" --tals-dir "$rir_root/.tmp/tals" --meta-json "$rir_root/.tmp/meta.json"
"$MATERIALIZE_BIN" --cir "$rir_root/full/input.cir" --repo-bytes-db "$repo_bytes_db" --mirror-root "$rir_root/.tmp/mirror"
FIRST_TAL="$(python3 - <<'PY' "$rir_root/.tmp/meta.json"
import json,sys
print(json.load(open(sys.argv[1]))["talFiles"][0]["path"])
PY
)"
export CIR_MIRROR_ROOT="$rir_root/.tmp/mirror"
export REAL_RSYNC_BIN=/usr/bin/rsync
export CIR_LOCAL_LINK_MODE=1
"$RPKI_BIN" \
--db "$rir_root/full/db" \
--tal-path "$FIRST_TAL" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--validation-time "2026-04-09T00:00:00Z" \
--ccr-out "$rir_root/full/result.ccr" \
--report-json "$rir_root/full/report.json" >/dev/null 2>&1
for idx in $(seq 1 "$DELTA_COUNT"); do
step="$(printf 'delta-%03d' "$idx")"
mkdir -p "$rir_root/$step"
cp "$rir_root/full/input.cir" "$rir_root/$step/input.cir"
cp "$rir_root/full/result.ccr" "$rir_root/$step/result.ccr"
cp "$rir_root/full/report.json" "$rir_root/$step/report.json"
done
python3 - <<'PY' "$rir_root" "$DELTA_COUNT"
import json, sys
from pathlib import Path
root = Path(sys.argv[1]); delta_count = int(sys.argv[2])
steps = [{"stepId":"full","kind":"full","validationTime":"2026-04-09T00:00:00Z","cirPath":"full/input.cir","ccrPath":"full/result.ccr","reportPath":"full/report.json","previousStepId":None}]
prev = "full"
for i in range(1, delta_count + 1):
step = f"delta-{i:03d}"
steps.append({"stepId":step,"kind":"delta","validationTime":"2026-04-09T00:00:00Z","cirPath":f"{step}/input.cir","ccrPath":f"{step}/result.ccr","reportPath":f"{step}/report.json","previousStepId":prev})
prev = step
(root/"sequence.json").write_text(json.dumps({"version":1,"repoBytesDbPath":"repo-bytes.db","steps":steps}, indent=2), encoding="utf-8")
(root/"summary.json").write_text(json.dumps({"version":1,"stepCount":len(steps)}, indent=2), encoding="utf-8")
PY
done
python3 - <<'PY' "$OUT_ROOT" "$RIRS"
import json, sys
from pathlib import Path
root = Path(sys.argv[1]); rirs = [x for x in sys.argv[2].split(',') if x]
items=[]
for rir in rirs:
seq=json.loads((root/rir/'sequence.json').read_text())
items.append({"rir":rir,"stepCount":len(seq['steps'])})
(root/'summary.json').write_text(json.dumps({"version":1,"rirs":items}, indent=2), encoding='utf-8')
PY
echo "done: $OUT_ROOT"

View File

@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_ta_only_remote_multi_rir.sh \
--remote-root <path> \
[--ssh-target <user@host>] \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--delta-count <n>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
RIRS="afrinic,apnic,arin,lacnic,ripe"
DELTA_COUNT=2
while [[ $# -gt 0 ]]; do
case "$1" in
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_ROOT/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT/target/release'"
for bin in rpki cir_ta_only_fixture cir_materialize cir_extract_inputs; do
rsync -a "$ROOT_DIR/target/release/$bin" "$SSH_TARGET:$REMOTE_ROOT/target/release/"
done
ssh "$SSH_TARGET" "bash -lc '
set -euo pipefail
cd $REMOTE_ROOT
OUT=target/replay/cir_sequence_remote_ta_only_\$(date -u +%Y%m%dT%H%M%SZ)
./scripts/cir/run_cir_record_sequence_ta_only_multi_rir.sh --rir $RIRS --delta-count $DELTA_COUNT --out-root \"\$OUT\"
echo \"\$OUT\"
'"

View File

@ -0,0 +1,293 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_matrix.sh \
--cir <path> \
--repo-bytes-db <path> \
--out-dir <path> \
--reference-ccr <path> \
--rpki-client-build-dir <path> \
[--keep-db] \
[--rpki-bin <path>] \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CIR=""
REPO_BYTES_DB=""
OUT_DIR=""
REFERENCE_CCR=""
RPKI_CLIENT_BUILD_DIR=""
KEEP_DB=0
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
OURS_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_ours.sh"
ROUTINATOR_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_routinator.sh"
RPKI_CLIENT_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_rpki_client.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--repo-bytes-db) REPO_BYTES_DB="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--rpki-client-build-dir) RPKI_CLIENT_BUILD_DIR="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$REPO_BYTES_DB" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" && -n "$RPKI_CLIENT_BUILD_DIR" ]] || {
usage >&2
exit 2
}
mkdir -p "$OUT_DIR"
run_with_timing() {
local summary_path="$1"
local timing_path="$2"
shift 2
local start end status
start="$(python3 - <<'PY'
import time
print(time.perf_counter_ns())
PY
)"
if "$@"; then
status=0
else
status=$?
fi
end="$(python3 - <<'PY'
import time
print(time.perf_counter_ns())
PY
)"
python3 - <<'PY' "$summary_path" "$timing_path" "$status" "$start" "$end"
import json, sys
summary_path, timing_path, status, start, end = sys.argv[1:]
duration_ms = max(0, (int(end) - int(start)) // 1_000_000)
data = {"exitCode": int(status), "durationMs": duration_ms}
try:
with open(summary_path, "r", encoding="utf-8") as f:
data["compare"] = json.load(f)
except FileNotFoundError:
data["compare"] = None
with open(timing_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
PY
return "$status"
}
OURS_OUT="$OUT_DIR/ours"
ROUTINATOR_OUT="$OUT_DIR/routinator"
RPKI_CLIENT_OUT="$OUT_DIR/rpki-client"
mkdir -p "$OURS_OUT" "$ROUTINATOR_OUT" "$RPKI_CLIENT_OUT"
ours_cmd=(
"$OURS_SCRIPT"
--cir "$CIR"
--repo-bytes-db "$REPO_BYTES_DB"
--out-dir "$OURS_OUT"
--reference-ccr "$REFERENCE_CCR"
--rpki-bin "$RPKI_BIN"
--real-rsync-bin "$REAL_RSYNC_BIN"
)
routinator_cmd=(
"$ROUTINATOR_SCRIPT"
--cir "$CIR"
--repo-bytes-db "$REPO_BYTES_DB"
--out-dir "$ROUTINATOR_OUT"
--reference-ccr "$REFERENCE_CCR"
--routinator-root "$ROUTINATOR_ROOT"
--routinator-bin "$ROUTINATOR_BIN"
--real-rsync-bin "$REAL_RSYNC_BIN"
)
rpki_client_cmd=(
"$RPKI_CLIENT_SCRIPT"
--cir "$CIR"
--repo-bytes-db "$REPO_BYTES_DB"
--out-dir "$RPKI_CLIENT_OUT"
--reference-ccr "$REFERENCE_CCR"
--build-dir "$RPKI_CLIENT_BUILD_DIR"
--real-rsync-bin "$REAL_RSYNC_BIN"
)
if [[ "$KEEP_DB" -eq 1 ]]; then
ours_cmd+=(--keep-db)
routinator_cmd+=(--keep-db)
rpki_client_cmd+=(--keep-db)
fi
ours_status=0
routinator_status=0
rpki_client_status=0
if run_with_timing "$OURS_OUT/compare-summary.json" "$OURS_OUT/timing.json" "${ours_cmd[@]}"; then
:
else
ours_status=$?
fi
if run_with_timing "$ROUTINATOR_OUT/compare-summary.json" "$ROUTINATOR_OUT/timing.json" "${routinator_cmd[@]}"; then
:
else
routinator_status=$?
fi
if run_with_timing "$RPKI_CLIENT_OUT/compare-summary.json" "$RPKI_CLIENT_OUT/timing.json" "${rpki_client_cmd[@]}"; then
:
else
rpki_client_status=$?
fi
SUMMARY_JSON="$OUT_DIR/summary.json"
SUMMARY_MD="$OUT_DIR/summary.md"
DETAIL_MD="$OUT_DIR/detail.md"
python3 - <<'PY' \
"$CIR" \
"$REPO_BYTES_DB" \
"$REFERENCE_CCR" \
"$OURS_OUT" \
"$ROUTINATOR_OUT" \
"$RPKI_CLIENT_OUT" \
"$SUMMARY_JSON" \
"$SUMMARY_MD" \
"$DETAIL_MD"
import json
import sys
from pathlib import Path
cir_path, repo_bytes_db, reference_ccr, ours_out, routinator_out, rpki_client_out, summary_json, summary_md, detail_md = sys.argv[1:]
participants = []
all_match = True
for name, out_dir in [
("ours", ours_out),
("routinator", routinator_out),
("rpki-client", rpki_client_out),
]:
out = Path(out_dir)
timing = json.loads((out / "timing.json").read_text(encoding="utf-8"))
compare = timing.get("compare") or {}
vrps = compare.get("vrps") or {}
vaps = compare.get("vaps") or {}
participant = {
"name": name,
"outDir": str(out),
"tmpRoot": str(out / ".tmp"),
"mirrorPath": str(out / ".tmp" / "mirror"),
"timingPath": str(out / "timing.json"),
"summaryPath": str(out / "compare-summary.json"),
"exitCode": timing["exitCode"],
"durationMs": timing["durationMs"],
"compareMode": compare.get("compareMode"),
"talCount": compare.get("talCount"),
"talPaths": compare.get("talPaths", []),
"vrps": vrps,
"vaps": vaps,
"match": bool(vrps.get("match")) and bool(vaps.get("match")) and timing["exitCode"] == 0,
"logPaths": [str(path) for path in sorted(out.glob("*.log"))],
}
participants.append(participant)
all_match = all_match and participant["match"]
summary = {
"cirPath": cir_path,
"repoBytesDb": repo_bytes_db,
"referenceCcr": reference_ccr,
"participants": participants,
"allMatch": all_match,
}
Path(summary_json).write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = [
"# CIR Replay Matrix Summary",
"",
f"- `cir`: `{cir_path}`",
f"- `repo_bytes_db`: `{repo_bytes_db}`",
f"- `reference_ccr`: `{reference_ccr}`",
f"- `all_match`: `{all_match}`",
"",
"| Participant | Exit | Duration (ms) | TALs | Compare mode | VRP actual/ref | VRP match | VAP actual/ref | VAP match | Log |",
"| --- | ---: | ---: | ---: | --- | --- | --- | --- | --- | --- |",
]
for participant in participants:
vrps = participant["vrps"] or {}
vaps = participant["vaps"] or {}
log_path = participant["logPaths"][0] if participant["logPaths"] else ""
lines.append(
"| {name} | {exit_code} | {duration_ms} | {tal_count} | {compare_mode} | {vrp_actual}/{vrp_ref} | {vrp_match} | {vap_actual}/{vap_ref} | {vap_match} | `{log_path}` |".format(
name=participant["name"],
exit_code=participant["exitCode"],
duration_ms=participant["durationMs"],
tal_count=participant.get("talCount") if participant.get("talCount") is not None else "-",
compare_mode=participant.get("compareMode") or "-",
vrp_actual=vrps.get("actual", "-"),
vrp_ref=vrps.get("reference", "-"),
vrp_match=vrps.get("match", False),
vap_actual=vaps.get("actual", "-"),
vap_ref=vaps.get("reference", "-"),
vap_match=vaps.get("match", False),
log_path=log_path,
)
)
Path(summary_md).write_text("\n".join(lines) + "\n", encoding="utf-8")
detail_lines = [
"# CIR Replay Matrix Detail",
"",
]
for participant in participants:
vrps = participant["vrps"] or {}
vaps = participant["vaps"] or {}
detail_lines.extend([
f"## {participant['name']}",
f"- `exit_code`: `{participant['exitCode']}`",
f"- `duration_ms`: `{participant['durationMs']}`",
f"- `out_dir`: `{participant['outDir']}`",
f"- `tmp_root`: `{participant['tmpRoot']}`",
f"- `mirror_path`: `{participant['mirrorPath']}`",
f"- `summary_path`: `{participant['summaryPath']}`",
f"- `timing_path`: `{participant['timingPath']}`",
f"- `compare_mode`: `{participant.get('compareMode')}`",
f"- `tal_count`: `{participant.get('talCount')}`",
f"- `log_paths`: `{', '.join(participant['logPaths'])}`",
f"- `vrps`: `actual={vrps.get('actual', '-')}` `reference={vrps.get('reference', '-')}` `match={vrps.get('match', False)}`",
f"- `vaps`: `actual={vaps.get('actual', '-')}` `reference={vaps.get('reference', '-')}` `match={vaps.get('match', False)}`",
f"- `vrps.only_in_actual`: `{vrps.get('only_in_actual', [])}`",
f"- `vrps.only_in_reference`: `{vrps.get('only_in_reference', [])}`",
f"- `vaps.only_in_actual`: `{vaps.get('only_in_actual', [])}`",
f"- `vaps.only_in_reference`: `{vaps.get('only_in_reference', [])}`",
"",
])
Path(detail_md).write_text("\n".join(detail_lines), encoding="utf-8")
PY
if [[ "$ours_status" -ne 0 || "$routinator_status" -ne 0 || "$rpki_client_status" -ne 0 ]]; then
exit 1
fi
all_match="$(python3 - <<'PY' "$SUMMARY_JSON"
import json,sys
print("true" if json.load(open(sys.argv[1]))["allMatch"] else "false")
PY
)"
if [[ "$all_match" != "true" ]]; then
exit 1
fi
echo "done: $OUT_DIR"

View File

@ -0,0 +1,252 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_ours.sh \
--cir <path> \
--repo-bytes-db <path> \
--out-dir <path> \
--reference-ccr <path> \
[--keep-db] \
[--write-actual-ccr] \
[--write-report-json] \
[--report-json-compact] \
[--phase2-object-workers <n>] \
[--phase2-worker-queue-capacity <n>] \
[--rpki-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CIR=""
REPO_BYTES_DB=""
OUT_DIR=""
REFERENCE_CCR=""
KEEP_DB=0
WRITE_ACTUAL_CCR=0
WRITE_REPORT_JSON=0
REPORT_JSON_COMPACT=0
PHASE2_OBJECT_WORKERS="${CIR_REPLAY_PHASE2_OBJECT_WORKERS:-4}"
PHASE2_WORKER_QUEUE_CAPACITY="${CIR_REPLAY_PHASE2_WORKER_QUEUE_CAPACITY:-64}"
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
CIR_MATERIALIZE_BIN="${CIR_MATERIALIZE_BIN:-$ROOT_DIR/target/release/cir_materialize}"
CIR_EXTRACT_INPUTS_BIN="${CIR_EXTRACT_INPUTS_BIN:-$ROOT_DIR/target/release/cir_extract_inputs}"
CCR_TO_COMPARE_VIEWS_BIN="${CCR_TO_COMPARE_VIEWS_BIN:-$ROOT_DIR/target/release/ccr_to_compare_views}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--repo-bytes-db) REPO_BYTES_DB="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--write-actual-ccr) WRITE_ACTUAL_CCR=1; shift ;;
--write-report-json) WRITE_REPORT_JSON=1; shift ;;
--report-json-compact) WRITE_REPORT_JSON=1; REPORT_JSON_COMPACT=1; shift ;;
--phase2-object-workers) PHASE2_OBJECT_WORKERS="$2"; shift 2 ;;
--phase2-worker-queue-capacity) PHASE2_WORKER_QUEUE_CAPACITY="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$REPO_BYTES_DB" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" ]] || {
usage >&2
exit 2
}
mkdir -p "$OUT_DIR"
needs_build=0
if [[ ! -x "$RPKI_BIN" || ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
needs_build=1
elif [[ "$RPKI_BIN" == "$ROOT_DIR/target/release/rpki" ]] && find "$ROOT_DIR/src" "$ROOT_DIR/Cargo.toml" -newer "$RPKI_BIN" -print -quit | grep -q .; then
needs_build=1
fi
if [[ "$needs_build" -eq 1 ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views
)
fi
TMP_ROOT="$OUT_DIR/.tmp"
TALS_DIR="$TMP_ROOT/tals"
META_JSON="$TMP_ROOT/meta.json"
MIRROR_ROOT="$TMP_ROOT/mirror"
DB_DIR="$TMP_ROOT/work-db"
REPLAY_RAW_STORE_DB="$TMP_ROOT/replay-raw-store.db"
REPLAY_REPO_BYTES_DB="$TMP_ROOT/replay-repo-bytes.db"
ACTUAL_CCR="$OUT_DIR/actual.ccr"
ACTUAL_REPORT="$OUT_DIR/report.json"
ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv"
ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv"
REF_VRPS="$OUT_DIR/reference-vrps.csv"
REF_VAPS="$OUT_DIR/reference-vaps.csv"
COMPARE_JSON="$OUT_DIR/compare-summary.json"
RUN_LOG="$OUT_DIR/run.log"
rm -rf "$TMP_ROOT"
mkdir -p "$TMP_ROOT"
"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON"
materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --repo-bytes-db "$REPO_BYTES_DB" --mirror-root "$MIRROR_ROOT")
if [[ "$KEEP_DB" -eq 1 ]]; then
materialize_cmd+=(--keep-db)
fi
"${materialize_cmd[@]}"
VALIDATION_TIME="$(python3 - <<'PY' "$META_JSON"
import json,sys
print(json.load(open(sys.argv[1]))["validationTime"])
PY
)"
mapfile -t TAL_PATHS < <(python3 - <<'PY' "$META_JSON"
import json, sys
for item in json.load(open(sys.argv[1], encoding="utf-8"))["talFiles"]:
print(item["path"])
PY
)
TAL_ARGS=()
for tal_path in "${TAL_PATHS[@]}"; do
TAL_ARGS+=(--tal-path "$tal_path")
done
export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
REPORT_JSON_ARGS=(--skip-report-build)
VCIR_ARGS=(--skip-vcir-persist)
if [[ "$WRITE_REPORT_JSON" -eq 1 ]]; then
REPORT_JSON_ARGS=(--report-json "$ACTUAL_REPORT")
if [[ "$REPORT_JSON_COMPACT" -eq 1 ]]; then
REPORT_JSON_ARGS+=(--report-json-compact)
fi
fi
CCR_ARGS=()
if [[ "$WRITE_ACTUAL_CCR" -eq 1 ]]; then
CCR_ARGS=(--ccr-out "$ACTUAL_CCR")
fi
"$RPKI_BIN" \
--db "$DB_DIR" \
--raw-store-db "$REPLAY_RAW_STORE_DB" \
--repo-bytes-db "$REPLAY_REPO_BYTES_DB" \
"${TAL_ARGS[@]}" \
--parallel-phase2-object-workers "$PHASE2_OBJECT_WORKERS" \
--parallel-phase2-worker-queue-capacity "$PHASE2_WORKER_QUEUE_CAPACITY" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--validation-time "$VALIDATION_TIME" \
"${CCR_ARGS[@]}" \
--vrps-csv-out "$ACTUAL_VRPS" \
--vaps-csv-out "$ACTUAL_VAPS" \
--compare-view-trust-anchor unknown \
"${VCIR_ARGS[@]}" \
"${REPORT_JSON_ARGS[@]}" \
>"$RUN_LOG" 2>&1
sort_compare_csv() {
local path="$1"
local tmp="${path}.sorted.tmp"
{
head -n 1 "$path"
tail -n +2 "$path" | LC_ALL=C sort -u
} >"$tmp"
mv "$tmp" "$path"
}
sort_compare_csv "$ACTUAL_VRPS"
sort_compare_csv "$ACTUAL_VAPS"
"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor unknown
python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$COMPARE_JSON" "$META_JSON" "$WRITE_REPORT_JSON" "$WRITE_ACTUAL_CCR" "$PHASE2_OBJECT_WORKERS" "$PHASE2_WORKER_QUEUE_CAPACITY"
import csv, json, sys
def next_row(reader):
try:
return tuple(next(reader))
except StopIteration:
return None
def compare_sorted_csv(actual_path, ref_path):
actual_count = 0
ref_count = 0
only_actual_count = 0
only_ref_count = 0
only_actual_sample = []
only_ref_sample = []
with open(actual_path, newline="") as actual_file, open(ref_path, newline="") as ref_file:
actual_reader = csv.reader(actual_file)
ref_reader = csv.reader(ref_file)
next(actual_reader, None)
next(ref_reader, None)
actual = next_row(actual_reader)
ref = next_row(ref_reader)
while actual is not None or ref is not None:
if ref is None or (actual is not None and actual < ref):
actual_count += 1
only_actual_count += 1
if len(only_actual_sample) < 20:
only_actual_sample.append(list(actual))
actual = next_row(actual_reader)
elif actual is None or ref < actual:
ref_count += 1
only_ref_count += 1
if len(only_ref_sample) < 20:
only_ref_sample.append(list(ref))
ref = next_row(ref_reader)
else:
actual_count += 1
ref_count += 1
actual = next_row(actual_reader)
ref = next_row(ref_reader)
return {
"actual": actual_count,
"reference": ref_count,
"only_in_actual": only_actual_sample,
"only_in_reference": only_ref_sample,
"only_in_actual_count": only_actual_count,
"only_in_reference_count": only_ref_count,
"match": only_actual_count == 0 and only_ref_count == 0,
}
vrps = compare_sorted_csv(sys.argv[1], sys.argv[2])
vaps = compare_sorted_csv(sys.argv[3], sys.argv[4])
meta = json.load(open(sys.argv[6], encoding="utf-8"))
summary = {
"compareMode": "trust-anchor-agnostic",
"talCount": len(meta["talFiles"]),
"talPaths": [item["path"] for item in meta["talFiles"]],
"actualCcrWritten": sys.argv[8] == "1",
"reportJsonWritten": sys.argv[7] == "1",
"replayParallelism": {
"phase2ObjectWorkers": int(sys.argv[9]),
"phase2WorkerQueueCapacity": int(sys.argv[10]),
},
"vrps": vrps,
"vaps": vaps,
}
with open(sys.argv[5], "w") as f:
json.dump(summary, f, indent=2)
PY
if [[ "$KEEP_DB" -ne 1 ]]; then
rm -rf "$TMP_ROOT"
fi
echo "done: $OUT_DIR"

View File

@ -0,0 +1,239 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_routinator.sh \
--cir <path> \
--repo-bytes-db <path> \
--out-dir <path> \
--reference-ccr <path> \
[--keep-db] \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RPKI_DEV_ROOT="${RPKI_DEV_ROOT:-$ROOT_DIR}"
CIR=""
REPO_BYTES_DB=""
OUT_DIR=""
REFERENCE_CCR=""
KEEP_DB=0
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
CIR_MATERIALIZE_BIN="${CIR_MATERIALIZE_BIN:-$ROOT_DIR/target/release/cir_materialize}"
CIR_EXTRACT_INPUTS_BIN="${CIR_EXTRACT_INPUTS_BIN:-$ROOT_DIR/target/release/cir_extract_inputs}"
CCR_TO_COMPARE_VIEWS_BIN="${CCR_TO_COMPARE_VIEWS_BIN:-$ROOT_DIR/target/release/ccr_to_compare_views}"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
JSON_TO_VAPS="$ROOT_DIR/scripts/cir/json_to_vaps_csv.py"
FAKETIME_LIB="${FAKETIME_LIB:-$ROOT_DIR/target/tools/faketime_pkg/extracted/libfaketime/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1}"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--repo-bytes-db) REPO_BYTES_DB="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$REPO_BYTES_DB" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" ]] || {
usage >&2
exit 2
}
if [[ ! -x "$ROUTINATOR_BIN" ]]; then
echo "routinator binary not executable: $ROUTINATOR_BIN" >&2
exit 2
fi
mkdir -p "$OUT_DIR"
if [[ ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views
)
fi
TMP_ROOT="$OUT_DIR/.tmp"
TALS_DIR="$TMP_ROOT/tals"
META_JSON="$TMP_ROOT/meta.json"
MIRROR_ROOT="$TMP_ROOT/mirror"
WORK_REPO="$TMP_ROOT/repository"
RUN_LOG="$OUT_DIR/routinator.log"
ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv"
ACTUAL_VAPS_JSON="$OUT_DIR/actual-vaps.json"
ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv"
REF_VRPS="$OUT_DIR/reference-vrps.csv"
REF_VAPS="$OUT_DIR/reference-vaps.csv"
SUMMARY_JSON="$OUT_DIR/compare-summary.json"
rm -rf "$TMP_ROOT"
mkdir -p "$TMP_ROOT"
"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON"
python3 - <<'PY' "$TALS_DIR"
from pathlib import Path
import sys
for tal in Path(sys.argv[1]).glob("*.tal"):
lines = tal.read_text(encoding="utf-8").splitlines()
rsync_uris = [line for line in lines if line.startswith("rsync://")]
base64_lines = []
seen_sep = False
for line in lines:
if seen_sep:
if line.strip():
base64_lines.append(line)
elif line.strip() == "":
seen_sep = True
tal.write_text("\n".join(rsync_uris) + "\n\n" + "\n".join(base64_lines) + "\n", encoding="utf-8")
PY
materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --repo-bytes-db "$REPO_BYTES_DB" --mirror-root "$MIRROR_ROOT")
if [[ "$KEEP_DB" -eq 1 ]]; then
materialize_cmd+=(--keep-db)
fi
"${materialize_cmd[@]}"
VALIDATION_TIME="$(python3 - <<'PY' "$META_JSON"
import json,sys
print(json.load(open(sys.argv[1]))["validationTime"])
PY
)"
mapfile -t TAL_PATHS < <(python3 - <<'PY' "$META_JSON"
import json, sys
for item in json.load(open(sys.argv[1], encoding="utf-8"))["talFiles"]:
print(item["path"])
PY
)
COMPARE_TRUST_ANCHOR="unknown"
FAKE_EPOCH="$(python3 - <<'PY' "$VALIDATION_TIME"
from datetime import datetime, timezone
import sys
dt = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc)
print(int(dt.timestamp()))
PY
)"
export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
env \
LD_PRELOAD="$FAKETIME_LIB" \
FAKETIME_FMT=%s \
FAKETIME="$FAKE_EPOCH" \
FAKETIME_DONT_FAKE_MONOTONIC=1 \
"$ROUTINATOR_BIN" \
--repository-dir "$WORK_REPO" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--no-rir-tals \
--extra-tals-dir "$TALS_DIR" \
--enable-aspa \
update --complete >"$RUN_LOG" 2>&1 || true
env \
LD_PRELOAD="$FAKETIME_LIB" \
FAKETIME_FMT=%s \
FAKETIME="$FAKE_EPOCH" \
FAKETIME_DONT_FAKE_MONOTONIC=1 \
"$ROUTINATOR_BIN" \
--repository-dir "$WORK_REPO" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--no-rir-tals \
--extra-tals-dir "$TALS_DIR" \
--enable-aspa \
vrps --noupdate -o "$ACTUAL_VRPS" >>"$RUN_LOG" 2>&1
env \
LD_PRELOAD="$FAKETIME_LIB" \
FAKETIME_FMT=%s \
FAKETIME="$FAKE_EPOCH" \
FAKETIME_DONT_FAKE_MONOTONIC=1 \
"$ROUTINATOR_BIN" \
--repository-dir "$WORK_REPO" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--no-rir-tals \
--extra-tals-dir "$TALS_DIR" \
--enable-aspa \
vrps --noupdate --format json -o "$ACTUAL_VAPS_JSON" >>"$RUN_LOG" 2>&1
python3 "$JSON_TO_VAPS" --input "$ACTUAL_VAPS_JSON" --csv-out "$ACTUAL_VAPS"
normalize_trust_anchor_csv() {
python3 - <<'PY' "$1" "$2"
import csv
import sys
from pathlib import Path
path = Path(sys.argv[1])
trust_anchor = sys.argv[2]
rows = list(csv.reader(path.open(newline="", encoding="utf-8")))
if rows:
for row in rows[1:]:
if row:
row[-1] = trust_anchor
with path.open("w", newline="", encoding="utf-8") as fh:
csv.writer(fh).writerows(rows)
PY
}
normalize_trust_anchor_csv "$ACTUAL_VRPS" "$COMPARE_TRUST_ANCHOR"
normalize_trust_anchor_csv "$ACTUAL_VAPS" "$COMPARE_TRUST_ANCHOR"
"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor "$COMPARE_TRUST_ANCHOR"
python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$SUMMARY_JSON" "$META_JSON"
import csv, json, sys
def rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))[1:]
actual_vrps = {tuple(r) for r in rows(sys.argv[1])}
ref_vrps = {tuple(r) for r in rows(sys.argv[2])}
actual_vaps = {tuple(r) for r in rows(sys.argv[3])}
ref_vaps = {tuple(r) for r in rows(sys.argv[4])}
meta = json.load(open(sys.argv[6], encoding="utf-8"))
summary = {
"compareMode": "trust-anchor-agnostic",
"talCount": len(meta["talFiles"]),
"talPaths": [item["path"] for item in meta["talFiles"]],
"vrps": {
"actual": len(actual_vrps),
"reference": len(ref_vrps),
"match": actual_vrps == ref_vrps,
"only_in_actual": sorted(actual_vrps - ref_vrps)[:20],
"only_in_reference": sorted(ref_vrps - actual_vrps)[:20],
},
"vaps": {
"actual": len(actual_vaps),
"reference": len(ref_vaps),
"match": actual_vaps == ref_vaps,
"only_in_actual": sorted(actual_vaps - ref_vaps)[:20],
"only_in_reference": sorted(ref_vaps - actual_vaps)[:20],
}
}
with open(sys.argv[5], "w") as f:
json.dump(summary, f, indent=2)
PY
if [[ "$KEEP_DB" -ne 1 ]]; then
rm -rf "$TMP_ROOT"
fi
echo "done: $OUT_DIR"

View File

@ -0,0 +1,248 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_rpki_client.sh \
--cir <path> \
--repo-bytes-db <path> \
--out-dir <path> \
--reference-ccr <path> \
[--build-dir <path> | --rpki-client-bin <path>] \
[--keep-db] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CIR=""
REPO_BYTES_DB=""
OUT_DIR=""
REFERENCE_CCR=""
BUILD_DIR=""
RPKI_CLIENT_BIN=""
KEEP_DB=0
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
CIR_MATERIALIZE_BIN="${CIR_MATERIALIZE_BIN:-$ROOT_DIR/target/release/cir_materialize}"
CIR_EXTRACT_INPUTS_BIN="${CIR_EXTRACT_INPUTS_BIN:-$ROOT_DIR/target/release/cir_extract_inputs}"
CCR_TO_COMPARE_VIEWS_BIN="${CCR_TO_COMPARE_VIEWS_BIN:-$ROOT_DIR/target/release/ccr_to_compare_views}"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--repo-bytes-db) REPO_BYTES_DB="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--build-dir) BUILD_DIR="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$REPO_BYTES_DB" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" ]] || {
usage >&2
exit 2
}
if [[ -z "$BUILD_DIR" && -z "$RPKI_CLIENT_BIN" ]]; then
usage >&2
exit 2
fi
if [[ -z "$RPKI_CLIENT_BIN" ]]; then
RPKI_CLIENT_BIN="$BUILD_DIR/src/rpki-client"
fi
if [[ ! -x "$RPKI_CLIENT_BIN" ]]; then
echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2
exit 2
fi
mkdir -p "$OUT_DIR"
if [[ ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views
)
fi
TMP_ROOT="$OUT_DIR/.tmp"
TALS_DIR="$TMP_ROOT/tals"
META_JSON="$TMP_ROOT/meta.json"
MIRROR_ROOT="$TMP_ROOT/mirror"
CACHE_DIR="$TMP_ROOT/cache"
OUT_CCR_DIR="$TMP_ROOT/out"
RUN_LOG="$OUT_DIR/rpki-client.log"
ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv"
ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv"
ACTUAL_VAPS_META="$OUT_DIR/actual-vaps-meta.json"
ACTUAL_VRPS_META="$OUT_DIR/actual-vrps-meta.json"
REF_VRPS="$OUT_DIR/reference-vrps.csv"
REF_VAPS="$OUT_DIR/reference-vaps.csv"
SUMMARY_JSON="$OUT_DIR/compare-summary.json"
rm -rf "$TMP_ROOT"
mkdir -p "$TMP_ROOT"
"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON"
python3 - <<'PY' "$TALS_DIR"
from pathlib import Path
import sys
for tal in Path(sys.argv[1]).glob("*.tal"):
lines = tal.read_text(encoding="utf-8").splitlines()
rsync_uris = [line for line in lines if line.startswith("rsync://")]
base64_lines = []
seen_sep = False
for line in lines:
if seen_sep:
if line.strip():
base64_lines.append(line)
elif line.strip() == "":
seen_sep = True
tal.write_text("\n".join(rsync_uris) + "\n\n" + "\n".join(base64_lines) + "\n", encoding="utf-8")
PY
materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --repo-bytes-db "$REPO_BYTES_DB" --mirror-root "$MIRROR_ROOT")
if [[ "$KEEP_DB" -eq 1 ]]; then
materialize_cmd+=(--keep-db)
fi
"${materialize_cmd[@]}"
VALIDATION_EPOCH="$(python3 - <<'PY' "$META_JSON"
from datetime import datetime, timezone
import json, sys
vt = json.load(open(sys.argv[1]))["validationTime"]
dt = datetime.fromisoformat(vt.replace("Z", "+00:00")).astimezone(timezone.utc)
print(int(dt.timestamp()))
PY
)"
mapfile -t TAL_PATHS < <(python3 - <<'PY' "$META_JSON"
import json, sys
for item in json.load(open(sys.argv[1], encoding="utf-8"))["talFiles"]:
print(item["path"])
PY
)
CLIENT_TAL_ARGS=()
for tal_path in "${TAL_PATHS[@]}"; do
CLIENT_TAL_ARGS+=(-t "$tal_path")
done
COMPARE_TRUST_ANCHOR="unknown"
export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
mkdir -p "$CACHE_DIR" "$OUT_CCR_DIR"
chmod -R 0777 "$TMP_ROOT"
"$RPKI_CLIENT_BIN" \
-R \
-e "$WRAPPER" \
-P "$VALIDATION_EPOCH" \
"${CLIENT_TAL_ARGS[@]}" \
-d "$CACHE_DIR" \
"$OUT_CCR_DIR" >"$RUN_LOG" 2>&1
if [[ -f "$OUT_CCR_DIR/rpki.ccr" ]]; then
"$CCR_TO_COMPARE_VIEWS_BIN" \
--ccr "$OUT_CCR_DIR/rpki.ccr" \
--vrps-out "$ACTUAL_VRPS" \
--vaps-out "$ACTUAL_VAPS" \
--trust-anchor "$COMPARE_TRUST_ANCHOR"
else
python3 - <<'PY' "$OUT_CCR_DIR/json" "$ACTUAL_VRPS" "$ACTUAL_VAPS" "$COMPARE_TRUST_ANCHOR"
import csv
import json
import sys
from pathlib import Path
json_path = Path(sys.argv[1])
vrps_out = Path(sys.argv[2])
vaps_out = Path(sys.argv[3])
compare_ta = sys.argv[4]
if not json_path.is_file():
raise SystemExit(f"rpki-client output has neither rpki.ccr nor json: {json_path}")
data = json.loads(json_path.read_text(encoding="utf-8"))
vrps_out.parent.mkdir(parents=True, exist_ok=True)
with vrps_out.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["ASN", "IP Prefix", "Max Length", "Trust Anchor"])
for roa in data.get("roas", []):
writer.writerow([
f"AS{roa['asn']}",
roa["prefix"],
str(roa["maxLength"]),
compare_ta,
])
with vaps_out.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["Customer ASN", "Providers", "Trust Anchor"])
for aspa in data.get("aspas", []):
providers = ";".join(f"AS{item}" for item in sorted(aspa.get("providers", [])))
writer.writerow([
f"AS{aspa['customer_asid']}",
providers,
compare_ta,
])
PY
fi
python3 - <<'PY' "$ACTUAL_VRPS" "$ACTUAL_VAPS" "$ACTUAL_VRPS_META" "$ACTUAL_VAPS_META"
import csv, json, sys
def count_rows(path):
with open(path, newline="") as f:
rows = list(csv.reader(f))
return max(len(rows) - 1, 0)
json.dump({"count": count_rows(sys.argv[1])}, open(sys.argv[3], "w"), indent=2)
json.dump({"count": count_rows(sys.argv[2])}, open(sys.argv[4], "w"), indent=2)
PY
"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor "$COMPARE_TRUST_ANCHOR"
python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$SUMMARY_JSON" "$META_JSON"
import csv, json, sys
def rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))[1:]
actual_vrps = {tuple(r) for r in rows(sys.argv[1])}
ref_vrps = {tuple(r) for r in rows(sys.argv[2])}
actual_vaps = {tuple(r) for r in rows(sys.argv[3])}
ref_vaps = {tuple(r) for r in rows(sys.argv[4])}
meta = json.load(open(sys.argv[6], encoding="utf-8"))
summary = {
"compareMode": "trust-anchor-agnostic",
"talCount": len(meta["talFiles"]),
"talPaths": [item["path"] for item in meta["talFiles"]],
"vrps": {
"actual": len(actual_vrps),
"reference": len(ref_vrps),
"match": actual_vrps == ref_vrps,
"only_in_actual": sorted(actual_vrps - ref_vrps)[:20],
"only_in_reference": sorted(ref_vrps - actual_vrps)[:20],
},
"vaps": {
"actual": len(actual_vaps),
"reference": len(ref_vaps),
"match": actual_vaps == ref_vaps,
"only_in_actual": sorted(actual_vaps - ref_vaps)[:20],
"only_in_reference": sorted(ref_vaps - actual_vaps)[:20],
}
}
with open(sys.argv[5], "w") as f:
json.dump(summary, f, indent=2)
PY
if [[ "$KEEP_DB" -ne 1 ]]; then
rm -rf "$TMP_ROOT"
fi
echo "done: $OUT_DIR"

View File

@ -0,0 +1,147 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_sequence_ours.sh \
--sequence-root <path> \
[--rpki-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
STEP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_ours.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" ]] || { usage >&2; exit 2; }
SEQUENCE_ROOT="$(python3 - <<'PY' "$SEQUENCE_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
SUMMARY_JSON="$SEQUENCE_ROOT/sequence-summary.json"
SUMMARY_MD="$SEQUENCE_ROOT/sequence-summary.md"
DETAIL_JSON="$SEQUENCE_ROOT/sequence-detail.json"
python3 - <<'PY' "$SEQUENCE_ROOT" "$SUMMARY_JSON" "$SUMMARY_MD" "$DETAIL_JSON" "$STEP_SCRIPT" "$RPKI_BIN" "$REAL_RSYNC_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1])
summary_json = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
detail_json = Path(sys.argv[4])
step_script = Path(sys.argv[5])
rpki_bin = sys.argv[6]
real_rsync_bin = sys.argv[7]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
repo_bytes_db = sequence_root / sequence["repoBytesDbPath"]
steps = sequence["steps"]
results = []
all_match = True
for step in steps:
step_id = step["stepId"]
out_dir = sequence_root / "replay-ours" / step_id
out_dir.parent.mkdir(parents=True, exist_ok=True)
cmd = [
str(step_script),
"--cir",
str(sequence_root / step["cirPath"]),
"--out-dir",
str(out_dir),
"--reference-ccr",
str(sequence_root / step["ccrPath"]),
"--rpki-bin",
rpki_bin,
"--real-rsync-bin",
real_rsync_bin,
]
cmd.extend(["--repo-bytes-db", str(repo_bytes_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"ours sequence replay failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
compare = json.loads((out_dir / "compare-summary.json").read_text(encoding="utf-8"))
timing = json.loads((out_dir / "timing.json").read_text(encoding="utf-8")) if (out_dir / "timing.json").exists() else {}
record = {
"stepId": step_id,
"kind": step["kind"],
"validationTime": step["validationTime"],
"outDir": str(out_dir),
"comparePath": str(out_dir / "compare-summary.json"),
"timingPath": str(out_dir / "timing.json"),
"compareMode": compare.get("compareMode"),
"talCount": compare.get("talCount"),
"talPaths": compare.get("talPaths", []),
"compare": compare,
"timing": timing,
"match": bool(compare["vrps"]["match"]) and bool(compare["vaps"]["match"]),
}
all_match = all_match and record["match"]
results.append(record)
summary = {
"version": 1,
"participant": "ours",
"sequenceRoot": str(sequence_root),
"stepCount": len(results),
"allMatch": all_match,
"steps": results,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
detail_json.write_text(json.dumps(results, indent=2), encoding="utf-8")
lines = [
"# Ours CIR Sequence Replay Summary",
"",
f"- `sequence_root`: `{sequence_root}`",
f"- `step_count`: `{len(results)}`",
f"- `all_match`: `{all_match}`",
"",
"| Step | Kind | TALs | Compare mode | VRP actual/ref | VRP match | VAP actual/ref | VAP match | Duration (ms) |",
"| --- | --- | ---: | --- | --- | --- | --- | --- | ---: |",
]
for item in results:
compare = item["compare"]
timing = item.get("timing") or {}
lines.append(
"| {step} | {kind} | {tal_count} | {compare_mode} | {va}/{vr} | {vm} | {aa}/{ar} | {am} | {dur} |".format(
step=item["stepId"],
kind=item["kind"],
tal_count=item.get("talCount") if item.get("talCount") is not None else "-",
compare_mode=item.get("compareMode") or "-",
va=compare["vrps"]["actual"],
vr=compare["vrps"]["reference"],
vm=compare["vrps"]["match"],
aa=compare["vaps"]["actual"],
ar=compare["vaps"]["reference"],
am=compare["vaps"]["match"],
dur=timing.get("durationMs", "-"),
)
)
summary_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_sequence_routinator.sh \
--sequence-root <path> \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
STEP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_routinator.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" ]] || { usage >&2; exit 2; }
SEQUENCE_ROOT="$(python3 - <<'PY' "$SEQUENCE_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
SUMMARY_JSON="$SEQUENCE_ROOT/sequence-summary-routinator.json"
SUMMARY_MD="$SEQUENCE_ROOT/sequence-summary-routinator.md"
python3 - <<'PY' "$SEQUENCE_ROOT" "$SUMMARY_JSON" "$SUMMARY_MD" "$STEP_SCRIPT" "$ROUTINATOR_ROOT" "$ROUTINATOR_BIN" "$REAL_RSYNC_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1])
summary_json = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
step_script = Path(sys.argv[4])
routinator_root = sys.argv[5]
routinator_bin = sys.argv[6]
real_rsync_bin = sys.argv[7]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
repo_bytes_db = sequence_root / sequence["repoBytesDbPath"]
steps = sequence["steps"]
results = []
all_match = True
for step in steps:
step_id = step["stepId"]
out_dir = sequence_root / "replay-routinator" / step_id
out_dir.parent.mkdir(parents=True, exist_ok=True)
cmd = [
str(step_script),
"--cir",
str(sequence_root / step["cirPath"]),
"--out-dir",
str(out_dir),
"--reference-ccr",
str(sequence_root / step["ccrPath"]),
"--routinator-root",
routinator_root,
"--routinator-bin",
routinator_bin,
"--real-rsync-bin",
real_rsync_bin,
]
cmd.extend(["--repo-bytes-db", str(repo_bytes_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"routinator sequence replay failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
compare = json.loads((out_dir / "compare-summary.json").read_text(encoding="utf-8"))
match = bool(compare["vrps"]["match"]) and bool(compare["vaps"]["match"])
all_match = all_match and match
results.append(
{
"stepId": step_id,
"kind": step["kind"],
"validationTime": step["validationTime"],
"outDir": str(out_dir),
"comparePath": str(out_dir / "compare-summary.json"),
"compareMode": compare.get("compareMode"),
"talCount": compare.get("talCount"),
"talPaths": compare.get("talPaths", []),
"match": match,
"compare": compare,
}
)
summary = {
"version": 1,
"participant": "routinator",
"sequenceRoot": str(sequence_root),
"stepCount": len(results),
"allMatch": all_match,
"steps": results,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = [
"# Routinator CIR Sequence Replay Summary",
"",
f"- `sequence_root`: `{sequence_root}`",
f"- `step_count`: `{len(results)}`",
f"- `all_match`: `{all_match}`",
"",
"| Step | Kind | TALs | Compare mode | VRP actual/ref | VRP match | VAP actual/ref | VAP match |",
"| --- | --- | ---: | --- | --- | --- | --- | --- |",
]
for item in results:
compare = item["compare"]
lines.append(
"| {step} | {kind} | {tal_count} | {compare_mode} | {va}/{vr} | {vm} | {aa}/{ar} | {am} |".format(
step=item["stepId"],
kind=item["kind"],
tal_count=item.get("talCount") if item.get("talCount") is not None else "-",
compare_mode=item.get("compareMode") or "-",
va=compare["vrps"]["actual"],
vr=compare["vrps"]["reference"],
vm=compare["vrps"]["match"],
aa=compare["vaps"]["actual"],
ar=compare["vaps"]["reference"],
am=compare["vaps"]["match"],
)
)
summary_md.write_text("\n".join(lines), encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_sequence_rpki_client.sh \
--sequence-root <path> \
[--build-dir <path> | --rpki-client-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
BUILD_DIR=""
RPKI_CLIENT_BIN=""
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
STEP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_rpki_client.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--build-dir) BUILD_DIR="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" && ( -n "$BUILD_DIR" || -n "$RPKI_CLIENT_BIN" ) ]] || { usage >&2; exit 2; }
SEQUENCE_ROOT="$(python3 - <<'PY' "$SEQUENCE_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
SUMMARY_JSON="$SEQUENCE_ROOT/sequence-summary-rpki-client.json"
SUMMARY_MD="$SEQUENCE_ROOT/sequence-summary-rpki-client.md"
python3 - <<'PY' "$SEQUENCE_ROOT" "$SUMMARY_JSON" "$SUMMARY_MD" "$STEP_SCRIPT" "$BUILD_DIR" "$RPKI_CLIENT_BIN" "$REAL_RSYNC_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1])
summary_json = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
step_script = Path(sys.argv[4])
build_dir = sys.argv[5]
rpki_client_bin = sys.argv[6]
real_rsync_bin = sys.argv[7]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
repo_bytes_db = sequence_root / sequence["repoBytesDbPath"]
steps = sequence["steps"]
results = []
all_match = True
for step in steps:
step_id = step["stepId"]
out_dir = sequence_root / "replay-rpki-client" / step_id
out_dir.parent.mkdir(parents=True, exist_ok=True)
cmd = [
str(step_script),
"--cir",
str(sequence_root / step["cirPath"]),
"--out-dir",
str(out_dir),
"--reference-ccr",
str(sequence_root / step["ccrPath"]),
"--real-rsync-bin",
real_rsync_bin,
]
if rpki_client_bin:
cmd.extend(["--rpki-client-bin", rpki_client_bin])
else:
cmd.extend(["--build-dir", build_dir])
cmd.extend(["--repo-bytes-db", str(repo_bytes_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"rpki-client sequence replay failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
compare = json.loads((out_dir / "compare-summary.json").read_text(encoding="utf-8"))
match = bool(compare["vrps"]["match"]) and bool(compare["vaps"]["match"])
all_match = all_match and match
results.append(
{
"stepId": step_id,
"kind": step["kind"],
"validationTime": step["validationTime"],
"outDir": str(out_dir),
"comparePath": str(out_dir / "compare-summary.json"),
"compareMode": compare.get("compareMode"),
"talCount": compare.get("talCount"),
"talPaths": compare.get("talPaths", []),
"match": match,
"compare": compare,
}
)
summary = {
"version": 1,
"participant": "rpki-client",
"sequenceRoot": str(sequence_root),
"stepCount": len(results),
"allMatch": all_match,
"steps": results,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = [
"# rpki-client CIR Sequence Replay Summary",
"",
f"- `sequence_root`: `{sequence_root}`",
f"- `step_count`: `{len(results)}`",
f"- `all_match`: `{all_match}`",
"",
"| Step | Kind | TALs | Compare mode | VRP actual/ref | VRP match | VAP actual/ref | VAP match |",
"| --- | --- | ---: | --- | --- | --- | --- | --- |",
]
for item in results:
compare = item["compare"]
lines.append(
"| {step} | {kind} | {tal_count} | {compare_mode} | {va}/{vr} | {vm} | {aa}/{ar} | {am} |".format(
step=item["stepId"],
kind=item["kind"],
tal_count=item.get("talCount") if item.get("talCount") is not None else "-",
compare_mode=item.get("compareMode") or "-",
va=compare["vrps"]["actual"],
vr=compare["vrps"]["reference"],
vm=compare["vrps"]["match"],
aa=compare["vaps"]["actual"],
ar=compare["vaps"]["reference"],
am=compare["vaps"]["match"],
)
)
summary_md.write_text("\n".join(lines), encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_sequence_matrix_multi_rir.sh \
--root <path> \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--rpki-bin <path>] \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--rpki-client-build-dir <path>] \
[--drop-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ROOT=""
RIRS="afrinic,apnic,arin,lacnic,ripe"
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
RPKI_CLIENT_BUILD_DIR="${RPKI_CLIENT_BUILD_DIR:-/home/yuyr/dev/rpki-client-9.7/build-m5}"
DROP_BIN="${DROP_BIN:-$ROOT_DIR/target/release/cir_drop_report}"
OURS_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_sequence_ours.sh"
ROUTINATOR_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_sequence_routinator.sh"
RPKIC_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_sequence_rpki_client.sh"
DROP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_drop_sequence.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--root) ROOT="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--rpki-client-build-dir) RPKI_CLIENT_BUILD_DIR="$2"; shift 2 ;;
--drop-bin) DROP_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$ROOT" ]] || { usage >&2; exit 2; }
SUMMARY_JSON="$ROOT/final-summary.json"
SUMMARY_MD="$ROOT/final-summary.md"
IFS=',' read -r -a ITEMS <<< "$RIRS"
results=()
for rir in "${ITEMS[@]}"; do
seq_root="$ROOT/$rir"
"$OURS_SCRIPT" --sequence-root "$seq_root" --rpki-bin "$RPKI_BIN"
"$ROUTINATOR_SCRIPT" --sequence-root "$seq_root" --routinator-root "$ROUTINATOR_ROOT" --routinator-bin "$ROUTINATOR_BIN"
"$RPKIC_SCRIPT" --sequence-root "$seq_root" --build-dir "$RPKI_CLIENT_BUILD_DIR"
"$DROP_SCRIPT" --sequence-root "$seq_root" --drop-bin "$DROP_BIN"
done
python3 - <<'PY' "$ROOT" "$RIRS" "$SUMMARY_JSON" "$SUMMARY_MD"
import json, sys
from pathlib import Path
from collections import Counter
root = Path(sys.argv[1]).resolve()
rirs = [item for item in sys.argv[2].split(',') if item]
summary_json = Path(sys.argv[3])
summary_md = Path(sys.argv[4])
items = []
total_steps = 0
total_dropped_vrps = 0
total_dropped_objects = 0
reason_counter = Counter()
for rir in rirs:
seq_root = root / rir
ours = json.loads((seq_root / "sequence-summary.json").read_text(encoding="utf-8"))
routinator = json.loads((seq_root / "sequence-summary-routinator.json").read_text(encoding="utf-8"))
rpki_client = json.loads((seq_root / "sequence-summary-rpki-client.json").read_text(encoding="utf-8"))
drop = json.loads((seq_root / "drop-summary.json").read_text(encoding="utf-8"))
step_count = len(ours["steps"])
total_steps += step_count
rir_dropped_vrps = 0
rir_dropped_objects = 0
for step in drop["steps"]:
drop_path = Path(step["reportPath"])
detail = json.loads(drop_path.read_text(encoding="utf-8"))
summary = detail.get("summary", {})
rir_dropped_vrps += int(summary.get("droppedVrpCount", 0))
rir_dropped_objects += int(summary.get("droppedObjectCount", 0))
total_dropped_vrps += int(summary.get("droppedVrpCount", 0))
total_dropped_objects += int(summary.get("droppedObjectCount", 0))
for reason, count in summary.get("droppedByReason", {}).items():
reason_counter[reason] += int(count)
items.append({
"rir": rir,
"stepCount": step_count,
"oursAllMatch": ours["allMatch"],
"routinatorAllMatch": routinator["allMatch"],
"rpkiClientAllMatch": rpki_client["allMatch"],
"dropSummary": drop["steps"],
"droppedVrpCount": rir_dropped_vrps,
"droppedObjectCount": rir_dropped_objects,
})
summary = {
"version": 1,
"totalStepCount": total_steps,
"totalDroppedVrpCount": total_dropped_vrps,
"totalDroppedObjectCount": total_dropped_objects,
"topReasons": [{"reason": reason, "count": count} for reason, count in reason_counter.most_common(10)],
"rirs": items,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = ["# Multi-RIR CIR Sequence Matrix Summary", ""]
lines.append(f"- `total_step_count`: `{total_steps}`")
lines.append(f"- `total_dropped_vrps`: `{total_dropped_vrps}`")
lines.append(f"- `total_dropped_objects`: `{total_dropped_objects}`")
lines.append("")
if reason_counter:
lines.append("## Top Drop Reasons")
lines.append("")
for reason, count in reason_counter.most_common(10):
lines.append(f"- `{reason}`: `{count}`")
lines.append("")
for item in items:
lines.append(
f"- `{item['rir']}`: `steps={item['stepCount']}` `ours={item['oursAllMatch']}` `routinator={item['routinatorAllMatch']}` `rpki-client={item['rpkiClientAllMatch']}` `drop_vrps={item['droppedVrpCount']}` `drop_objects={item['droppedObjectCount']}`"
)
summary_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "done: $ROOT"

View File

@ -0,0 +1,502 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/compare/run_perf_compare_quick_remote.sh \
--run-root <path> \
--remote-root <path> \
[--rir-set <mixed2|all5>] \
[--ssh-target <user@host>] \
[--rpki-client-bin <path>] \
[--libtls-path <path>] \
[--rp-run-mode <serial|parallel>] \
[--copy-rpki-client-cache] \
[--probe-rpki-client-cache] \
[--ours-extra-args '<args>'] \
[--dry-run]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
first_existing_executable() {
local fallback="$1"
shift
local candidate
for candidate in "$@"; do
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
done
printf '%s' "$fallback"
}
first_existing_file() {
local fallback="$1"
shift
local candidate
for candidate in "$@"; do
if [[ -f "$candidate" ]]; then
printf '%s' "$candidate"
return
fi
done
printf '%s' "$fallback"
}
RUN_ROOT=""
REMOTE_ROOT=""
SSH_TARGET="${SSH_TARGET:-root@47.251.56.108}"
RPKI_CLIENT_BIN="${RPKI_CLIENT_BIN:-$(first_existing_executable \
"/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client" \
"$ROOT_DIR/../../.cache/rpki-client-9.7-build/bin/rpki-client" \
"$ROOT_DIR/../../.cache/rpki-client-9.7-build/src/rpki-client-9.7/src/rpki-client" \
"$ROOT_DIR/../../.cache/rpki-client-remote9.0/rpki-client" \
"/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client")}"
LIBTLS_PATH="${LIBTLS_PATH:-$(first_existing_file \
"/home/yuyr/dev/rpki-client-9.7/.deps/libtls/root/usr/lib/x86_64-linux-gnu/libtls.so.28.0.0" \
"$ROOT_DIR/../../.cache/rpki-client-9.7-build/runlib/libtls.so.28" \
"$ROOT_DIR/../../.cache/rpki-client-9.7-build/sysroot/usr/lib/x86_64-linux-gnu/libtls.so.28.0.0" \
"$ROOT_DIR/../../.cache/rpki-client-remote9.0/libtls.so.28" \
"/home/yuyr/dev/rpki-client-9.7/.deps/libtls/root/usr/lib/x86_64-linux-gnu/libtls.so.28.0.0")}"
RP_RUN_MODE="${RP_RUN_MODE:-serial}"
RIR_SET="${RIR_SET:-mixed2}"
OURS_EXTRA_ARGS="${OURS_EXTRA_ARGS:-}"
COPY_RPKI_CLIENT_CACHE="${COPY_RPKI_CLIENT_CACHE:-0}"
PROBE_RPKI_CLIENT_CACHE="${PROBE_RPKI_CLIENT_CACHE:-0}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--rir-set) RIR_SET="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--libtls-path) LIBTLS_PATH="$2"; shift 2 ;;
--rp-run-mode) RP_RUN_MODE="$2"; shift 2 ;;
--copy-rpki-client-cache) COPY_RPKI_CLIENT_CACHE=1; shift ;;
--probe-rpki-client-cache) PROBE_RPKI_CLIENT_CACHE=1; shift ;;
--ours-extra-args) OURS_EXTRA_ARGS="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
[[ "$RP_RUN_MODE" == "serial" || "$RP_RUN_MODE" == "parallel" ]] || { echo "invalid --rp-run-mode: $RP_RUN_MODE" >&2; usage; exit 2; }
[[ "$RIR_SET" == "mixed2" || "$RIR_SET" == "all5" ]] || { echo "invalid --rir-set: $RIR_SET" >&2; usage; exit 2; }
[[ "$DRY_RUN" -eq 1 || -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
[[ "$DRY_RUN" -eq 1 || -f "$LIBTLS_PATH" ]] || { echo "libtls not found: $LIBTLS_PATH" >&2; exit 2; }
RUN_ROOT="$(python3 - <<'PY' "$RUN_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
mkdir -p "$RUN_ROOT/steps/step-001/ours" "$RUN_ROOT/steps/step-001/rpki-client" "$RUN_ROOT/steps/step-001/compare"
mkdir -p "$RUN_ROOT/steps/step-002/ours" "$RUN_ROOT/steps/step-002/rpki-client" "$RUN_ROOT/steps/step-002/compare"
tal_path_for_rir() {
case "$1" in
afrinic) printf '%s' "$ROOT_DIR/tests/fixtures/tal/afrinic.tal" ;;
apnic) printf '%s' "$ROOT_DIR/tests/fixtures/tal/apnic-rfc7730-https.tal" ;;
arin) printf '%s' "$ROOT_DIR/tests/fixtures/tal/arin.tal" ;;
lacnic) printf '%s' "$ROOT_DIR/tests/fixtures/tal/lacnic.tal" ;;
ripe) printf '%s' "$ROOT_DIR/tests/fixtures/tal/ripe-ncc.tal" ;;
*) echo "unknown rir: $1" >&2; exit 2 ;;
esac
}
ta_path_for_rir() {
case "$1" in
afrinic) printf '%s' "$ROOT_DIR/tests/fixtures/ta/afrinic-ta.cer" ;;
apnic) printf '%s' "$ROOT_DIR/tests/fixtures/ta/apnic-ta.cer" ;;
arin) printf '%s' "$ROOT_DIR/tests/fixtures/ta/arin-ta.cer" ;;
lacnic) printf '%s' "$ROOT_DIR/tests/fixtures/ta/lacnic-ta.cer" ;;
ripe) printf '%s' "$ROOT_DIR/tests/fixtures/ta/ripe-ncc-ta.cer" ;;
*) echo "unknown rir: $1" >&2; exit 2 ;;
esac
}
case "$RIR_SET" in
mixed2)
RIRS=(apnic arin)
SCOPE_LABEL="APNIC+ARIN mixed release two-step synchronized compare"
;;
all5)
RIRS=(afrinic apnic arin lacnic ripe)
SCOPE_LABEL="all-five-RIR mixed release two-step synchronized compare"
;;
esac
COPY_FILES=()
for rir in "${RIRS[@]}"; do
COPY_FILES+=("$(tal_path_for_rir "$rir")" "$(ta_path_for_rir "$rir")")
done
if [[ "$DRY_RUN" -eq 1 ]]; then
cat <<EOF2
workflow_name=性能对比测试快速版
scope=$SCOPE_LABEL
rir_set=$RIR_SET
rirs=${RIRS[*]}
run_root=$RUN_ROOT
remote_root=$REMOTE_ROOT
ssh_target=$SSH_TARGET
rp_run_mode=$RP_RUN_MODE
ours_extra_args=$OURS_EXTRA_ARGS
EOF2
exit 0
fi
cleanup_remote() {
if [[ "${KEEP_REMOTE:-0}" != "1" ]]; then
ssh "$SSH_TARGET" "rm -rf '$REMOTE_ROOT'" >/dev/null 2>&1 || true
fi
}
trap cleanup_remote EXIT
(
cd "$ROOT_DIR"
cargo build --release --bin rpki --bin ccr_to_compare_views --bin ccr_state_compare --bin cir_state_compare --bin cir_probe_rpki_client_cache
)
ssh "$SSH_TARGET" "set -e; 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; rm -rf '$REMOTE_ROOT'; mkdir -p '$REMOTE_ROOT/bin' '$REMOTE_ROOT/lib' '$REMOTE_ROOT/state/ours' '$REMOTE_ROOT/state/rpki-client' '$REMOTE_ROOT/steps/step-001/ours' '$REMOTE_ROOT/steps/step-001/rpki-client' '$REMOTE_ROOT/steps/step-002/ours' '$REMOTE_ROOT/steps/step-002/rpki-client'"
scp "$ROOT_DIR/target/release/rpki" "${COPY_FILES[@]}" "$SSH_TARGET:$REMOTE_ROOT/"
if [[ "$PROBE_RPKI_CLIENT_CACHE" == "1" ]]; then
scp "$ROOT_DIR/target/release/cir_probe_rpki_client_cache" "$SSH_TARGET:$REMOTE_ROOT/bin/"
fi
scp "$RPKI_CLIENT_BIN" "$SSH_TARGET:$REMOTE_ROOT/bin/rpki-client"
scp "$LIBTLS_PATH" "$SSH_TARGET:$REMOTE_ROOT/lib/libtls.so.28"
printf '%s' "$OURS_EXTRA_ARGS" | ssh "$SSH_TARGET" "cat > '$REMOTE_ROOT/ours-extra-args.txt'"
printf '%s' "$RP_RUN_MODE" | ssh "$SSH_TARGET" "cat > '$REMOTE_ROOT/rp-run-mode.txt'"
printf '%s' "$RIR_SET" | ssh "$SSH_TARGET" "cat > '$REMOTE_ROOT/rir-set.txt'"
run_step() {
local step_id="$1"
local kind="$2"
local local_step="$RUN_ROOT/steps/$step_id"
ssh "$SSH_TARGET" bash -s -- "$REMOTE_ROOT" "$step_id" "$kind" <<'EOS'
set -euo pipefail
REMOTE_ROOT="$1"
STEP_ID="$2"
KIND="$3"
cd "$REMOTE_ROOT"
mkdir -p "steps/$STEP_ID/ours" "steps/$STEP_ID/rpki-client"
touch rpki-client-skiplist
chmod 0644 rpki-client-skiplist
OURS_EXTRA_ARGS="$(cat ours-extra-args.txt)"
RP_RUN_MODE="$(cat rp-run-mode.txt)"
RIR_SET="$(cat rir-set.txt)"
OURS_EXTRA_ARGV=()
if [[ -n "$OURS_EXTRA_ARGS" ]]; then
# shellcheck disable=SC2206
OURS_EXTRA_ARGV=($OURS_EXTRA_ARGS)
fi
case "$RIR_SET" in
mixed2) RIRS=(apnic arin) ;;
all5) RIRS=(afrinic apnic arin lacnic ripe) ;;
*) echo "invalid rir set: $RIR_SET" >&2; exit 2 ;;
esac
tal_file_for_rir() {
case "$1" in
afrinic) printf '%s' "afrinic.tal" ;;
apnic) printf '%s' "apnic-rfc7730-https.tal" ;;
arin) printf '%s' "arin.tal" ;;
lacnic) printf '%s' "lacnic.tal" ;;
ripe) printf '%s' "ripe-ncc.tal" ;;
*) echo "unknown rir: $1" >&2; exit 2 ;;
esac
}
tal_uri_for_rir() {
case "$1" in
afrinic) printf '%s' "https://rpki.afrinic.net/repository/AfriNIC.cer" ;;
apnic) printf '%s' "https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer" ;;
arin) printf '%s' "https://rrdp.arin.net/arin-rpki-ta.cer" ;;
lacnic) printf '%s' "https://rrdp.lacnic.net/ta/rta-lacnic-rpki.cer" ;;
ripe) printf '%s' "https://rpki.ripe.net/ta/ripe-ncc-ta.cer" ;;
*) echo "unknown rir: $1" >&2; exit 2 ;;
esac
}
ta_file_for_rir() {
case "$1" in
afrinic) printf '%s' "afrinic-ta.cer" ;;
apnic) printf '%s' "apnic-ta.cer" ;;
arin) printf '%s' "arin-ta.cer" ;;
lacnic) printf '%s' "lacnic-ta.cer" ;;
ripe) printf '%s' "ripe-ncc-ta.cer" ;;
*) echo "unknown rir: $1" >&2; exit 2 ;;
esac
}
refresh_ta_file_for_rir() {
local rir="$1"
local uri
local file
uri="$(tal_uri_for_rir "$rir")"
file="$(ta_file_for_rir "$rir")"
python3 - <<'PY' "$uri" "$file"
import sys
import urllib.request
uri, path = sys.argv[1:]
request = urllib.request.Request(uri, headers={"User-Agent": "rpki-dev/compare-fast-path"})
with urllib.request.urlopen(request, timeout=30) as response:
data = response.read()
if not data:
raise SystemExit(f"empty TA certificate response: {uri}")
with open(path, "wb") as output:
output.write(data)
PY
}
for rir in "${RIRS[@]}"; do
refresh_ta_file_for_rir "$rir"
done
OURS_TAL_ARGS=()
CLIENT_TAL_ARGS=()
OURS_CIR_TAL_ARGS=()
for rir in "${RIRS[@]}"; do
tal_file="$(tal_file_for_rir "$rir")"
ta_file="$(ta_file_for_rir "$rir")"
tal_uri="$(tal_uri_for_rir "$rir")"
OURS_TAL_ARGS+=(--tal-path "$tal_file" --ta-path "$ta_file")
OURS_CIR_TAL_ARGS+=(--cir-tal-uri "$tal_uri")
CLIENT_TAL_ARGS+=(-t "../../$tal_file")
done
if [[ "$KIND" == "snapshot" ]]; then
rm -rf state/ours/work-db state/ours/raw-store.db state/ours/repo-bytes.db state/rpki-client/cache state/rpki-client/out state/rpki-client/ta state/rpki-client/.ta
fi
mkdir -p state/ours/work-db state/ours/raw-store.db state/ours/repo-bytes.db state/rpki-client/cache state/rpki-client/out state/rpki-client/ta state/rpki-client/.ta
chmod 0777 state/ours/work-db state/ours/raw-store.db state/ours/repo-bytes.db
chmod -R 0777 state/rpki-client
touch state/rpki-client/rpki-client-skiplist
chmod 0644 state/rpki-client/rpki-client-skiplist
START_EPOCH="$(python3 - <<'PY'
import time
print(time.time() + 3.0)
PY
)"
run_ours() {
python3 - <<'PY' "$START_EPOCH"
import sys, time
x = float(sys.argv[1])
d = x - time.time()
if d > 0:
time.sleep(d)
PY
started_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
set +e
env RPKI_PROGRESS_LOG=1 RPKI_PROGRESS_SLOW_SECS=0 ./rpki \
--db state/ours/work-db \
--raw-store-db state/ours/raw-store.db \
--repo-bytes-db state/ours/repo-bytes.db \
"${OURS_TAL_ARGS[@]}" \
"${OURS_EXTRA_ARGV[@]}" \
--ccr-out "steps/$STEP_ID/ours/result.ccr" \
--cir-enable \
--cir-out "steps/$STEP_ID/ours/result.cir" \
"${OURS_CIR_TAL_ARGS[@]}" \
--report-json "steps/$STEP_ID/ours/report.json" \
> "steps/$STEP_ID/ours/run.log" 2>&1
exit_code=$?
set -e
finished_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "steps/$STEP_ID/ours/round-result.json" "$STEP_ID" "$KIND" "$started_ms" "$finished_ms" "$exit_code"
import json, sys
path, step_id, kind, started_ms, finished_ms, exit_code = sys.argv[1:]
json.dump(
{
"stepId": step_id,
"kind": kind,
"durationMs": int(finished_ms) - int(started_ms),
"exitCode": int(exit_code),
},
open(path, "w"),
indent=2,
)
PY
}
run_client() {
cd state/rpki-client
python3 - <<'PY' "$START_EPOCH"
import sys, time
x = float(sys.argv[1])
d = x - time.time()
if d > 0:
time.sleep(d)
PY
started_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
set +e
LD_LIBRARY_PATH="$REMOTE_ROOT/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$REMOTE_ROOT/bin/rpki-client" \
-vv \
-S rpki-client-skiplist \
"${CLIENT_TAL_ARGS[@]}" \
-d cache out \
> "$REMOTE_ROOT/steps/$STEP_ID/rpki-client/run.log" 2>&1
exit_code=$?
set -e
cp out/rpki.ccr "$REMOTE_ROOT/steps/$STEP_ID/rpki-client/result.ccr" 2>/dev/null || true
cp out/rpki.cir "$REMOTE_ROOT/steps/$STEP_ID/rpki-client/result.cir" 2>/dev/null || true
cp out/openbgpd "$REMOTE_ROOT/steps/$STEP_ID/rpki-client/openbgpd" 2>/dev/null || true
finished_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "$REMOTE_ROOT/steps/$STEP_ID/rpki-client/round-result.json" "$STEP_ID" "$KIND" "$started_ms" "$finished_ms" "$exit_code"
import json, sys
path, step_id, kind, started_ms, finished_ms, exit_code = sys.argv[1:]
json.dump(
{
"stepId": step_id,
"kind": kind,
"durationMs": int(finished_ms) - int(started_ms),
"exitCode": int(exit_code),
},
open(path, "w"),
indent=2,
)
PY
}
if [[ "$RP_RUN_MODE" == "parallel" ]]; then
run_ours &
OURS_PID=$!
run_client &
CLIENT_PID=$!
wait "$OURS_PID"
wait "$CLIENT_PID"
else
run_ours
run_client
fi
EOS
for rel in result.ccr result.cir round-result.json run.log stage-timing.json; do
scp -C "$SSH_TARGET:$REMOTE_ROOT/steps/$step_id/ours/$rel" "$local_step/ours/"
done
for rel in result.ccr result.cir round-result.json run.log openbgpd; do
scp -C "$SSH_TARGET:$REMOTE_ROOT/steps/$step_id/rpki-client/$rel" "$local_step/rpki-client/" || true
done
if [[ "$COPY_RPKI_CLIENT_CACHE" == "1" ]]; then
mkdir -p "$local_step/rpki-client/cache"
rsync -a --delete "$SSH_TARGET:$REMOTE_ROOT/state/rpki-client/cache/" "$local_step/rpki-client/cache/"
fi
if [[ -f "$local_step/ours/result.cir" && -f "$local_step/rpki-client/result.cir" ]]; then
"$ROOT_DIR/scripts/periodic/compare_ccr_cir_round.sh" \
--ours-ccr "$local_step/ours/result.ccr" \
--rpki-client-ccr "$local_step/rpki-client/result.ccr" \
--ours-cir "$local_step/ours/result.cir" \
--rpki-client-cir "$local_step/rpki-client/result.cir" \
--out-dir "$local_step/compare" \
--trust-anchor unknown >/dev/null
if [[ "$PROBE_RPKI_CLIENT_CACHE" == "1" ]]; then
ssh "$SSH_TARGET" "set -e; mkdir -p '$REMOTE_ROOT/steps/$step_id/compare/cir'; '$REMOTE_ROOT/bin/cir_probe_rpki_client_cache' --ours-cir '$REMOTE_ROOT/steps/$step_id/ours/result.cir' --rpki-client-cir '$REMOTE_ROOT/steps/$step_id/rpki-client/result.cir' --cache-root '$REMOTE_ROOT/state/rpki-client/cache' --rpki-client-log '$REMOTE_ROOT/steps/$step_id/rpki-client/run.log' --out-json '$REMOTE_ROOT/steps/$step_id/compare/cir/rpki-client-cache-probe.json' --sample-limit 50 >/dev/null"
scp -C "$SSH_TARGET:$REMOTE_ROOT/steps/$step_id/compare/cir/rpki-client-cache-probe.json" "$local_step/compare/cir/"
fi
if [[ "$COPY_RPKI_CLIENT_CACHE" == "1" ]]; then
"$ROOT_DIR/target/release/cir_probe_rpki_client_cache" \
--ours-cir "$local_step/ours/result.cir" \
--rpki-client-cir "$local_step/rpki-client/result.cir" \
--cache-root "$local_step/rpki-client/cache" \
--rpki-client-log "$local_step/rpki-client/run.log" \
--out-json "$local_step/compare/cir/rpki-client-cache-probe.json" \
--sample-limit 50 >/dev/null
fi
else
"$ROOT_DIR/scripts/periodic/compare_ccr_round.sh" \
--ours-ccr "$local_step/ours/result.ccr" \
--rpki-client-ccr "$local_step/rpki-client/result.ccr" \
--out-dir "$local_step/compare" \
--trust-anchor unknown >/dev/null
fi
python3 - <<'PY' "$local_step/ours/round-result.json" "$local_step/rpki-client/round-result.json" "$local_step/ours/stage-timing.json" "$local_step/compare/summary.json" "$local_step/compare/compare-summary.json" "$local_step/step-summary.json" "$OURS_EXTRA_ARGS"
import json, sys
ours = json.load(open(sys.argv[1]))
client = json.load(open(sys.argv[2]))
stage = json.load(open(sys.argv[3]))
compare_path = sys.argv[4] if __import__('pathlib').Path(sys.argv[4]).exists() else sys.argv[5]
compare = json.load(open(compare_path))
ours_extra_args = sys.argv[7]
json.dump(
{
"stepId": ours["stepId"],
"kind": ours["kind"],
"oursExtraArgs": ours_extra_args,
"oursDurationMs": ours["durationMs"],
"rpkiClientDurationMs": client["durationMs"],
"oursExitCode": ours["exitCode"],
"rpkiClientExitCode": client["exitCode"],
"oursTotalMs": stage["total_ms"],
"oursRepoSyncMsTotal": stage["repo_sync_ms_total"],
"oursPublicationPointRepoSyncMsTotal": stage.get("publication_point_repo_sync_ms_total"),
"oursDownloadEventCount": stage.get("download_event_count"),
"oursRrdpDownloadMsTotal": stage.get("rrdp_download_ms_total"),
"oursRsyncDownloadMsTotal": stage.get("rsync_download_ms_total"),
"oursDownloadBytesTotal": stage.get("download_bytes_total"),
"oursVrps": compare["vrps"]["ours"],
"rpkiClientVrps": compare["vrps"]["rpkiClient"],
"oursVaps": compare["vaps"]["ours"],
"rpkiClientVaps": compare["vaps"]["rpkiClient"],
"vrpMatch": compare["vrps"]["match"],
"vapMatch": compare["vaps"]["match"],
"allMatch": compare["allMatch"],
"onlyInOurs": len(compare["vrps"]["onlyInOurs"]),
"onlyInRpkiClient": len(compare["vrps"]["onlyInRpkiClient"]),
},
open(sys.argv[6], "w"),
indent=2,
)
PY
}
run_step step-001 snapshot
run_step step-002 delta
python3 - <<'PY' "$RUN_ROOT/steps/step-001/step-summary.json" "$RUN_ROOT/steps/step-002/step-summary.json" "$RUN_ROOT/summary.json" "$RP_RUN_MODE" "$OURS_EXTRA_ARGS" "$RIR_SET" "$SCOPE_LABEL" "${RIRS[@]}"
import json, sys
steps = [json.load(open(p)) for p in sys.argv[1:3]]
summary = {
"workflowName": "性能对比测试快速版",
"scope": sys.argv[7],
"rpRunMode": sys.argv[4],
"oursExtraArgs": sys.argv[5],
"rirSet": sys.argv[6],
"rirs": sys.argv[8:],
"steps": steps,
}
json.dump(summary, open(sys.argv[3], "w"), indent=2, ensure_ascii=False)
print(json.dumps(summary, indent=2, ensure_ascii=False))
PY

File diff suppressed because it is too large Load Diff

104
scripts/coverage.sh Executable file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
# Requires:
# rustup component add llvm-tools-preview
# cargo install cargo-llvm-cov --locked
# Optional:
# COVERAGE_FORCE_CLEAN=1 Force `cargo llvm-cov clean --workspace` before the run.
# Default behavior is to reuse existing llvm-cov build artifacts.
# RPKI_SKIP_HEAVY_SCRIPT_REPLAY_TESTS=1 Skip replay/matrix integration tests that
# spawn shell pipelines and can trigger separate release builds.
# coverage.sh enables this by default.
# RPKI_SKIP_HEAVY_BLACKBOX_TESTS=1 Skip slower blackbox CLI/script integration tests
# that provide low incremental coverage per wall-clock second.
# coverage.sh enables this by default.
# RPKI_SKIP_HEAVY_CRYPTO_TESTS=1 Skip slower OpenSSL-heavy certificate generation tests
# that provide low incremental coverage per wall-clock second.
# coverage.sh enables this by default.
run_out="$(mktemp)"
text_out="$(mktemp)"
html_out="$(mktemp)"
cleanup() {
rm -f "$run_out" "$text_out" "$html_out"
}
trap cleanup EXIT
IGNORE_REGEX='repository_view_stats\.rs|db_stats\.rs|rrdp_state_dump\.rs|ccr_dump\.rs|ccr_verify\.rs|ccr_to_routinator_csv\.rs|ccr_to_compare_views\.rs|cir_materialize\.rs|cir_extract_inputs\.rs|cir_drop_report\.rs|cir_ta_only_fixture\.rs|cir_dump_reject_list\.rs|rpki_object_parse\.rs|triage_ccr_cir_pair\.rs|rpki_artifact_metrics|rpki_daemon\.rs|sequence_triage_ccr_cir|ccr_state_compare\.rs|cir_state_compare\.rs|cir_probe_rpki_client_cache\.rs|ccr/compare_view\.rs|progress_log\.rs|cli\.rs|validation/run_tree_from_tal\.rs|validation/tree_parallel\.rs|validation/tree_runner|validation/from_tal\.rs|sync/store_projection\.rs|sync/repo\.rs|sync/rrdp|(^|/)storage(/|\.rs$)|cir/materialize\.rs'
# Preserve colored output even though we post-process output by running under a pseudo-TTY.
# We run tests only once, then generate both CLI text + HTML reports without rerunning tests.
set +e
if [ "${COVERAGE_FORCE_CLEAN:-0}" = "1" ]; then
cargo llvm-cov clean --workspace >/dev/null 2>&1
echo "coverage mode: clean build (COVERAGE_FORCE_CLEAN=1)"
else
echo "coverage mode: reuse existing llvm-cov artifacts (default)"
fi
export RPKI_SKIP_HEAVY_SCRIPT_REPLAY_TESTS="${RPKI_SKIP_HEAVY_SCRIPT_REPLAY_TESTS:-1}"
export RPKI_SKIP_HEAVY_BLACKBOX_TESTS="${RPKI_SKIP_HEAVY_BLACKBOX_TESTS:-1}"
export RPKI_SKIP_HEAVY_CRYPTO_TESTS="${RPKI_SKIP_HEAVY_CRYPTO_TESTS:-1}"
# 1) Run tests once to collect coverage data (no report).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov --no-report" "$run_out" >/dev/null 2>&1
run_status="$?"
# 2) CLI summary report + fail-under gate (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --fail-under-lines 90 --ignore-filename-regex '$IGNORE_REGEX'" "$text_out" >/dev/null 2>&1
text_status="$?"
# 3) HTML report (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --html --ignore-filename-regex '$IGNORE_REGEX'" "$html_out" >/dev/null 2>&1
html_status="$?"
set -e
strip_script_noise() {
tr -d '\r' | sed '/^Script \(started\|done\) on /d'
}
strip_ansi_for_parse() {
awk '
{
line = $0
gsub(/\033\[[0-9;]*[A-Za-z]/, "", line) # CSI escapes
gsub(/\033\([A-Za-z]/, "", line) # charset escapes (e.g., ESC(B)
gsub(/\r/, "", line)
print line
}
'
}
cat "$run_out" | strip_script_noise
cat "$text_out" | strip_script_noise
cat "$html_out" | strip_script_noise
cat "$run_out" | strip_ansi_for_parse | awk '
BEGIN {
passed=0; failed=0; ignored=0; measured=0; filtered=0;
}
/^test result: / {
if (match($0, /([0-9]+) passed; ([0-9]+) failed; ([0-9]+) ignored; ([0-9]+) measured; ([0-9]+) filtered out;/, m)) {
passed += m[1]; failed += m[2]; ignored += m[3]; measured += m[4]; filtered += m[5];
}
}
END {
executed = passed + failed;
total = passed + failed + ignored + measured;
printf("\nTEST SUMMARY (all suites): passed=%d failed=%d ignored=%d measured=%d filtered_out=%d executed=%d total=%d\n",
passed, failed, ignored, measured, filtered, executed, total);
}
'
echo
echo "HTML report: target/llvm-cov/html/index.html"
status="$text_status"
if [ "$run_status" -ne 0 ]; then status="$run_status"; fi
if [ "$html_status" -ne 0 ]; then status="$html_status"; fi
exit "$status"

View File

@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"defaultRirs": ["afrinic", "apnic", "arin", "lacnic", "ripe"],
"experiments": [
{
"id": "sync-ours-rsync-only",
"left": { "rpKind": "ours", "mode": "standard", "protocol": "rrdp+rsync", "rsyncScope": "module-root" },
"right": { "rpKind": "ours", "mode": "standard", "protocol": "rsync-only", "rsyncScope": "module-root" }
},
{
"id": "sync-rpki-client-rsync-only",
"left": { "rpKind": "rpki-client", "mode": "standard", "protocol": "rrdp+rsync" },
"right": { "rpKind": "rpki-client", "mode": "standard", "protocol": "rsync-only" }
},
{
"id": "strict-name",
"left": { "rpKind": "ours", "mode": "standard", "protocol": "rrdp+rsync" },
"right": { "rpKind": "ours", "mode": "strict-name", "protocol": "rrdp+rsync" }
},
{
"id": "strict-cms-der",
"left": { "rpKind": "ours", "mode": "standard", "protocol": "rrdp+rsync" },
"right": { "rpKind": "ours", "mode": "strict-cms-der", "protocol": "rrdp+rsync" }
},
{
"id": "strict-signed-attrs",
"left": { "rpKind": "ours", "mode": "standard", "protocol": "rrdp+rsync" },
"right": { "rpKind": "ours", "mode": "strict-signed-attrs", "protocol": "rrdp+rsync" }
},
{
"id": "strict-all",
"left": { "rpKind": "ours", "mode": "standard", "protocol": "rrdp+rsync" },
"right": { "rpKind": "ours", "mode": "strict-all", "protocol": "rrdp+rsync" }
},
{
"id": "rp-implementation-standard",
"left": { "rpKind": "ours", "mode": "standard", "protocol": "rrdp+rsync" },
"right": { "rpKind": "rpki-client", "mode": "standard", "protocol": "rrdp+rsync" }
}
]
}

View File

@ -0,0 +1,431 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import platform
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
RIR_FIXTURES = {
"afrinic": {
"tal": "tal/afrinic.tal",
"ta": "ta/afrinic-ta.cer",
},
"apnic": {
"tal": "tal/apnic-rfc7730-https.tal",
"ta": "ta/apnic-ta.cer",
},
"arin": {
"tal": "tal/arin.tal",
"ta": "ta/arin-ta.cer",
},
"lacnic": {
"tal": "tal/lacnic.tal",
"ta": "ta/lacnic-ta.cer",
},
"ripe": {
"tal": "tal/ripe-ncc.tal",
"ta": "ta/ripe-ncc-ta.cer",
},
}
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def sha256_file(path: Path) -> str:
hasher = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()
def read_tal_uris(path: Path) -> list[str]:
uris: list[str] = []
with path.open("r", encoding="utf-8") as file:
for line in file:
item = line.strip()
if not item or item.startswith("#"):
if uris:
break
continue
if item.startswith(("rsync://", "https://", "http://")):
uris.append(item)
continue
if uris:
break
return uris
def first_uri(uris: list[str], prefixes: tuple[str, ...]) -> str | None:
for uri in uris:
if uri.startswith(prefixes):
return uri
return None
def parse_rirs(raw: str) -> list[str]:
rirs = [item.strip().lower() for item in raw.split(",") if item.strip()]
if not rirs:
raise SystemExit("RIR list must not be empty")
invalid = [item for item in rirs if item not in RIR_FIXTURES]
if invalid:
raise SystemExit(
f"invalid RIR(s): {','.join(invalid)}; allowed: {','.join(RIR_FIXTURES)}"
)
return rirs
def rel_or_abs(path: Path, root: Path | None) -> str:
path = path.resolve()
if root is not None:
try:
return path.relative_to(root.resolve()).as_posix()
except ValueError:
pass
return path.as_posix()
def git_commit(repo_root: Path) -> str | None:
try:
return subprocess.check_output(
["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
text=True,
stderr=subprocess.DEVNULL,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def build_fixture_proof(
fixture_dir: Path,
rirs: list[str],
repo_root: Path | None,
ta_online_fetch_observed: bool,
) -> dict[str, Any]:
trust_anchors = []
for rir in rirs:
mapping = RIR_FIXTURES[rir]
tal_path = fixture_dir / mapping["tal"]
ta_path = fixture_dir / mapping["ta"]
if not tal_path.is_file():
raise SystemExit(f"missing TAL fixture for {rir}: {tal_path}")
if not ta_path.is_file():
raise SystemExit(f"missing TA fixture for {rir}: {ta_path}")
tal_uris = read_tal_uris(tal_path)
trust_anchors.append(
{
"rir": rir,
"talPath": rel_or_abs(tal_path, repo_root),
"taPath": rel_or_abs(ta_path, repo_root),
"talUri": first_uri(tal_uris, ("https://", "http://")),
"taRsyncUri": first_uri(tal_uris, ("rsync://",)),
"talSha256": sha256_file(tal_path),
"taCertificateSha256": sha256_file(ta_path),
"talBytes": tal_path.stat().st_size,
"taCertificateBytes": ta_path.stat().st_size,
"taFixturePinned": not ta_online_fetch_observed,
"taOnlineFetchObserved": ta_online_fetch_observed,
}
)
return {
"schemaVersion": 1,
"generatedBy": "feature035-experiment-driver",
"generatedAtUtc": utc_now(),
"fixtureDir": rel_or_abs(fixture_dir, repo_root),
"all5": set(rirs) == set(RIR_FIXTURES),
"rirs": rirs,
"trustAnchors": trust_anchors,
}
def parse_csv(raw: str) -> list[str]:
if not raw:
return []
return [item.strip() for item in raw.split(",") if item.strip()]
def optional_path(raw: str | None, repo_root: Path | None) -> str | None:
if raw is None:
return None
return rel_or_abs(Path(raw), repo_root)
def build_run_meta(args: argparse.Namespace) -> dict[str, Any]:
rirs = parse_rirs(args.rirs)
repo_root = Path(args.repo_root).resolve() if args.repo_root else None
argv = json.loads(args.argv_json) if args.argv_json else []
env_whitelist = json.loads(args.env_json) if args.env_json else {}
fixture_proof_summary = (
json.loads(args.fixture_proof_summary_json)
if args.fixture_proof_summary_json
else None
)
fixture_proof = None
if args.fixture_proof:
fixture_proof_path = Path(args.fixture_proof)
if fixture_proof_path.is_file():
fixture_proof = json.loads(fixture_proof_path.read_text(encoding="utf-8"))
if not isinstance(argv, list):
raise SystemExit("--argv-json must decode to a JSON array")
if not isinstance(env_whitelist, dict):
raise SystemExit("--env-json must decode to a JSON object")
if fixture_proof_summary is not None and not isinstance(fixture_proof_summary, dict):
raise SystemExit("--fixture-proof-summary-json must decode to a JSON object")
return {
"schemaVersion": 1,
"generatedBy": "feature035-experiment-driver",
"generatedAtUtc": utc_now(),
"experimentId": args.experiment_id,
"side": args.side,
"sideLabel": args.side_label,
"step": args.step,
"runId": args.run_id,
"liveRun": not args.replay_used,
"replayUsed": args.replay_used,
"rp": {
"kind": args.rp_kind,
"binary": args.rp_binary,
"version": args.rp_version,
"gitCommit": args.rp_git_commit,
"mode": args.rp_mode,
"protocolMode": args.protocol_mode,
"strictPolicies": parse_csv(args.strict_policies),
},
"scope": {
"rirs": rirs,
"all5": set(rirs) == set(RIR_FIXTURES),
},
"command": {
"argv": argv,
"cwd": args.cwd,
"envWhitelist": env_whitelist,
},
"state": {
"resetBeforeRun": args.reset_before_run,
"stateRoot": args.state_root,
"db": args.db,
"repoBytesDb": args.repo_bytes_db,
"rawStoreDb": args.raw_store_db,
"rsyncMirrorRoot": args.rsync_mirror_root,
"cacheRoot": args.cache_root,
},
"artifacts": {
"ccr": optional_path(args.ccr, repo_root),
"cir": optional_path(args.cir, repo_root),
"runMeta": optional_path(str(args.out), repo_root),
"fixtureProof": optional_path(args.fixture_proof, repo_root),
"reportJson": optional_path(args.report_json, repo_root),
"stageTimingJson": optional_path(args.stage_timing_json, repo_root),
"stdoutLog": optional_path(args.stdout_log, repo_root),
"stderrLog": optional_path(args.stderr_log, repo_root),
"processTime": optional_path(args.process_time, repo_root),
"vrpsCsv": optional_path(args.vrps_csv, repo_root),
"vapsCsv": optional_path(args.vaps_csv, repo_root),
},
"fixtureProof": fixture_proof,
"fixtureProofSummary": fixture_proof_summary,
"metrics": {
"exitCode": args.exit_code,
"wallMs": args.wall_ms,
"maxRssKb": args.max_rss_kb,
"vrps": args.vrps,
"vaps": args.vaps,
"publicationPoints": args.publication_points,
"warnings": args.warnings,
"cirObjectCount": args.cir_object_count,
"cirRejectCount": args.cir_reject_count,
"cirTrustAnchorCount": args.cir_trust_anchor_count,
"ccrStateDigest": args.ccr_state_digest,
},
"environment": {
"host": args.host or platform.node(),
"platform": args.platform or platform.platform(),
},
}
def command_fixture_proof(args: argparse.Namespace) -> None:
repo_root = Path(args.repo_root).resolve() if args.repo_root else None
proof = build_fixture_proof(
fixture_dir=Path(args.fixture_dir),
rirs=parse_rirs(args.rirs),
repo_root=repo_root,
ta_online_fetch_observed=args.ta_online_fetch_observed,
)
write_json(Path(args.out), proof)
def command_run_meta(args: argparse.Namespace) -> None:
write_json(Path(args.out), build_run_meta(args))
def command_dry_run_bundle(args: argparse.Namespace) -> None:
out_dir = Path(args.out_dir)
repo_root = Path(args.repo_root).resolve() if args.repo_root else Path.cwd().resolve()
fixture_proof = out_dir / "fixture-proof.json"
command_fixture_proof(
argparse.Namespace(
fixture_dir=args.fixture_dir,
rirs=args.rirs,
repo_root=str(repo_root),
out=str(fixture_proof),
ta_online_fetch_observed=False,
)
)
for side, side_label in (("left", "A"), ("right", "B")):
run_dir = out_dir / side_label / "snapshot"
meta_args = argparse.Namespace(
out=run_dir / "run-meta.json",
repo_root=str(repo_root),
experiment_id=args.experiment_id,
side=side,
side_label=side_label,
step="snapshot",
run_id=f"{side_label}-snapshot-dry-run",
replay_used=False,
rp_kind="ours" if side_label == "A" else "rpki-client",
rp_binary=f"bin/{'rpki' if side_label == 'A' else 'rpki-client'}",
rp_version="dry-run",
rp_git_commit=git_commit(repo_root),
rp_mode="standard",
protocol_mode="rrdp+rsync",
strict_policies="",
rirs=args.rirs,
argv_json=json.dumps(["dry-run"]),
env_json=json.dumps({"RPKI_PROGRESS_LOG": "1"}),
cwd=str(out_dir),
reset_before_run=True,
state_root=str(out_dir / side_label / "state"),
db=str(out_dir / side_label / "state" / "work-db"),
repo_bytes_db=str(out_dir / side_label / "state" / "repo-bytes.db"),
raw_store_db=str(out_dir / side_label / "state" / "raw-store.db"),
rsync_mirror_root=str(out_dir / side_label / "state" / "rsync-mirror"),
cache_root=str(out_dir / side_label / "state" / "cache"),
ccr=str(run_dir / "result.ccr"),
cir=str(run_dir / "result.cir"),
fixture_proof=str(fixture_proof),
report_json=str(run_dir / "report.json"),
stage_timing_json=str(run_dir / "stage-timing.json"),
stdout_log=str(run_dir / "stdout.log"),
stderr_log=str(run_dir / "stderr.log"),
process_time=str(run_dir / "process-time.txt"),
vrps_csv=str(run_dir / "vrps.csv"),
vaps_csv=str(run_dir / "vaps.csv"),
exit_code=0,
wall_ms=0,
max_rss_kb=0,
vrps=0,
vaps=0,
publication_points=0,
warnings=0,
cir_object_count=0,
cir_reject_count=0,
cir_trust_anchor_count=len(parse_rirs(args.rirs)),
ccr_state_digest=None,
fixture_proof_summary_json=json.dumps(
{
"taFixturePinned": True,
"taOnlineFetchObserved": False,
"trustAnchorCount": len(parse_rirs(args.rirs)),
}
),
)
command_run_meta(meta_args)
def add_run_meta_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--out", required=True)
parser.add_argument("--repo-root")
parser.add_argument("--experiment-id", required=True)
parser.add_argument("--side", choices=["left", "right"], required=True)
parser.add_argument("--side-label", choices=["A", "B"], required=True)
parser.add_argument("--step", choices=["snapshot", "delta"], required=True)
parser.add_argument("--run-id", required=True)
parser.add_argument("--replay-used", action="store_true")
parser.add_argument("--rp-kind", required=True)
parser.add_argument("--rp-binary", required=True)
parser.add_argument("--rp-version")
parser.add_argument("--rp-git-commit")
parser.add_argument("--rp-mode", default="standard")
parser.add_argument("--protocol-mode", default="rrdp+rsync")
parser.add_argument("--strict-policies", default="")
parser.add_argument("--rirs", default="afrinic,apnic,arin,lacnic,ripe")
parser.add_argument("--argv-json")
parser.add_argument("--env-json")
parser.add_argument("--cwd", default=os.getcwd())
parser.add_argument("--reset-before-run", action="store_true")
parser.add_argument("--state-root")
parser.add_argument("--db")
parser.add_argument("--repo-bytes-db")
parser.add_argument("--raw-store-db")
parser.add_argument("--rsync-mirror-root")
parser.add_argument("--cache-root")
parser.add_argument("--ccr")
parser.add_argument("--cir")
parser.add_argument("--fixture-proof")
parser.add_argument("--fixture-proof-summary-json")
parser.add_argument("--report-json")
parser.add_argument("--stage-timing-json")
parser.add_argument("--stdout-log")
parser.add_argument("--stderr-log")
parser.add_argument("--process-time")
parser.add_argument("--vrps-csv")
parser.add_argument("--vaps-csv")
parser.add_argument("--exit-code", type=int)
parser.add_argument("--wall-ms", type=int)
parser.add_argument("--max-rss-kb", type=int)
parser.add_argument("--vrps", type=int)
parser.add_argument("--vaps", type=int)
parser.add_argument("--publication-points", type=int)
parser.add_argument("--warnings", type=int)
parser.add_argument("--cir-object-count", type=int)
parser.add_argument("--cir-reject-count", type=int)
parser.add_argument("--cir-trust-anchor-count", type=int)
parser.add_argument("--ccr-state-digest")
parser.add_argument("--host")
parser.add_argument("--platform")
def main() -> None:
parser = argparse.ArgumentParser(description="Feature #035 CCR/CIR experiment bundle helpers")
subparsers = parser.add_subparsers(dest="command", required=True)
fixture = subparsers.add_parser("fixture-proof")
fixture.add_argument("--fixture-dir", default="tests/fixtures")
fixture.add_argument("--rirs", default="afrinic,apnic,arin,lacnic,ripe")
fixture.add_argument("--repo-root")
fixture.add_argument("--out", required=True)
fixture.add_argument("--ta-online-fetch-observed", action="store_true")
fixture.set_defaults(func=command_fixture_proof)
run_meta = subparsers.add_parser("run-meta")
add_run_meta_args(run_meta)
run_meta.set_defaults(func=command_run_meta)
dry_run = subparsers.add_parser("dry-run-bundle")
dry_run.add_argument("--out-dir", required=True)
dry_run.add_argument("--repo-root", default=".")
dry_run.add_argument("--fixture-dir", default="tests/fixtures")
dry_run.add_argument("--rirs", default="afrinic,apnic,arin,lacnic,ripe")
dry_run.add_argument("--experiment-id", default="m2-dry-run")
dry_run.set_defaults(func=command_dry_run_bundle)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"rirs": {
"afrinic": { "tal": "tal/afrinic.tal", "ta": "ta/afrinic-ta.cer" },
"apnic": { "tal": "tal/apnic-rfc7730-https.tal", "ta": "ta/apnic-ta.cer" },
"arin": { "tal": "tal/arin.tal", "ta": "ta/arin-ta.cer" },
"lacnic": { "tal": "tal/lacnic.tal", "ta": "ta/lacnic-ta.cer" },
"ripe": { "tal": "tal/ripe-ncc.tal", "ta": "ta/ripe-ncc-ta.cer" }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,939 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import hashlib
import json
import os
import shlex
import subprocess
import sys
import time
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]
FEATURE035_DIR = REPO_ROOT / "scripts" / "experiments" / "feature035"
FIXTURE_MANIFEST_PATH = FEATURE035_DIR / "fixture-manifest.json"
PORTABLE_ROOT = DEV_ROOT / "rpki-client-portable"
CACHED_CIR_RPKI_CLIENT = DEV_ROOT / ".cache" / "rpki-client-9.8-cir" / "rpki-client"
CACHED_CIR_LIBTLS = DEV_ROOT / ".cache" / "rpki-client-9.8-cir" / "libtls.so.28"
DEFAULT_RIRS = ["afrinic", "apnic", "arin", "lacnic", "ripe"]
def run_local(argv: list[str], *, cwd: Path | None = None, capture: bool = False, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(argv, cwd=str(cwd) if cwd else None, text=True, capture_output=capture, check=False)
if check and result.returncode != 0:
raise SystemExit(
f"command failed ({result.returncode}): {' '.join(shlex.quote(x) for x in argv)}\n"
f"stdout:\n{result.stdout or ''}\nstderr:\n{result.stderr or ''}"
)
return result
def ssh_script(target: str, script: str, *, capture: bool = False, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(["ssh", target, "bash", "-s"], input=script, text=True, capture_output=capture, check=False)
if check and result.returncode != 0:
raise SystemExit(f"remote script failed ({result.returncode}) on {target}\n{result.stdout}\n{result.stderr}")
return result
def rsync_to_remote(target: str, source: Path, destination: str | Path) -> None:
run_local(["rsync", "-a", str(source), f"{target}:{destination}"])
def rsync_dir_to_remote(target: str, source: Path, destination: str | Path) -> None:
run_local(["rsync", "-a", f"{source}/", f"{target}:{destination}/"])
def rsync_from_remote(target: str, source: str | Path, destination: Path) -> None:
destination.mkdir(parents=True, exist_ok=True)
run_local(["rsync", "-a", f"{target}:{source}/", f"{destination}/"])
def rsync_run_artifacts_from_remote(target: str, source: str | Path, destination: Path) -> None:
destination.mkdir(parents=True, exist_ok=True)
rsync_base = ["rsync", "-az", "--ignore-missing-args", "--partial", "--partial-dir=.rsync-partial"]
for name in [
"result.ccr",
"result.cir",
"report.json",
"vrps.csv",
"vaps.csv",
"process-time.txt",
"remote-run-meta.json",
"exit-code.txt",
"started-at.txt",
"finished-at.txt",
"stdout.log",
"stderr.log",
]:
run_local([*rsync_base, f"{target}:{source}/{name}", f"{destination}/"])
def rsync_remote_analysis_from_remote(target: str, remote_exp_root: str | Path, local_exp_root: Path) -> None:
local_exp_root.mkdir(parents=True, exist_ok=True)
rsync_base = ["rsync", "-az", "--ignore-missing-args", "--partial", "--partial-dir=.rsync-partial"]
for name in [
"left-sequence.jsonl",
"right-sequence.jsonl",
"run-progress.json",
"sequence-triage-time.txt",
"sequence-triage/sequence-triage.json",
]:
destination = local_exp_root / Path(name).parent
destination.mkdir(parents=True, exist_ok=True)
run_local([*rsync_base, f"{target}:{remote_exp_root}/{name}", f"{destination}/"])
def same_remote_location(
left_target: str,
left_root: str | Path,
right_target: str,
right_root: str | Path,
) -> bool:
return left_target == right_target and str(left_root) == str(right_root)
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, sort_keys=True, ensure_ascii=False)
handle.write("\n")
def append_jsonl(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(value, sort_keys=True, ensure_ascii=False))
handle.write("\n")
def utc_stamp() -> str:
return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
def rfc3339_now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def fixture_manifest() -> dict[str, Any]:
return load_json(FIXTURE_MANIFEST_PATH)
def fixture_name(rir: str, kind: str) -> str:
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 sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
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(":")
if len(parts) == 3:
hours, minutes, seconds = parts
elif len(parts) == 2:
hours = "0"
minutes, seconds = parts
else:
hours = "0"
minutes = "0"
seconds = parts[0]
return int(round((int(days) * 86400 + int(hours) * 3600 + int(minutes) * 60 + float(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:
elapsed = line.rsplit(":", 1)[1] if "):" not in line else line.rsplit("):", 1)[1]
data["wallMs"] = parse_elapsed_to_ms(elapsed)
elif "Maximum resident set size" in line:
try:
data["maxRssKb"] = int(line.rsplit(":", 1)[1].strip())
except ValueError:
pass
return data
def rpki_client_bin_path() -> Path:
primary = PORTABLE_ROOT / "src" / "rpki-client"
for candidate in (primary, CACHED_CIR_RPKI_CLIENT):
if not candidate.is_file():
continue
smoke = run_local([str(candidate), "-T", "invalid"], capture=True, check=False)
if "--ta-fixture requires <tal>:<path>" in (smoke.stderr + smoke.stdout):
return candidate
raise SystemExit("rpki-client binary lacks CIR/TA fixture support; checkout feature/cir-output-for-rp-compare or restore .cache/rpki-client-9.8-cir/rpki-client")
def detect_libtls_path(rpki_client_bin: Path) -> Path:
if CACHED_CIR_LIBTLS.is_file():
return CACHED_CIR_LIBTLS
ldd = run_local(["ldd", str(rpki_client_bin)], capture=True)
for line in ldd.stdout.splitlines():
if "libtls.so.28" not in line or "=>" not in line:
continue
candidate = Path(line.split("=>", 1)[1].strip().split(" ", 1)[0])
if candidate.is_file():
return candidate
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 build_tool_binaries() -> None:
run_local([
"cargo", "build", "--release",
"--bin", "rpki",
"--bin", "sequence_triage_ccr_cir",
"--bin", "cir_dump_reject_list",
], cwd=REPO_ROOT)
_ = rpki_client_bin_path()
def validate_remote_disk(ssh_target: str) -> None:
script = r'''
set -euo pipefail
df -h /data / || true
python3 - <<'PY'
import shutil
for path in ['/data', '/']:
try:
usage = shutil.disk_usage(path)
except FileNotFoundError:
continue
used = usage.used / usage.total if usage.total else 0
print(f'{path} used={used:.2%}')
if used >= 0.90:
raise SystemExit(f'{path} disk usage >= 90%; cleanup required before all5 sequence experiment')
PY
'''
ssh_script(ssh_target, script)
def prepare_remote(ssh_target: str, remote_root: Path, needs_rpki_client: bool) -> None:
validate_remote_disk(ssh_target)
preflight = (
"set -euo pipefail; "
"systemctl disable --now rpki-client.timer >/dev/null 2>&1 || true; "
"systemctl stop rpki-client.service >/dev/null 2>&1 || true; "
"pkill -x rpki-client >/dev/null 2>&1 || true; "
"pkill -x routinator >/dev/null 2>&1 || true; "
f"mkdir -p {shlex.quote(str(remote_root / 'bin'))} {shlex.quote(str(remote_root / 'lib'))} "
f"{shlex.quote(str(remote_root / 'fixtures' / 'tal'))} {shlex.quote(str(remote_root / 'fixtures' / 'ta'))} "
f"{shlex.quote(str(remote_root / 'experiments'))}; "
f"df -h /data / > {shlex.quote(str(remote_root / 'df-before.txt'))} 2>&1 || true; "
f"free -h > {shlex.quote(str(remote_root / 'free-before.txt'))} 2>&1 || true"
)
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")
rsync_to_remote(ssh_target, REPO_ROOT / "target" / "release" / "rpki", remote_root / "bin" / "rpki")
rsync_to_remote(ssh_target, REPO_ROOT / "target" / "release" / "sequence_triage_ccr_cir", remote_root / "bin" / "sequence_triage_ccr_cir")
rsync_to_remote(ssh_target, REPO_ROOT / "target" / "release" / "cir_dump_reject_list", remote_root / "bin" / "cir_dump_reject_list")
if needs_rpki_client:
rpki_client_bin = rpki_client_bin_path()
rsync_to_remote(ssh_target, rpki_client_bin, remote_root / "bin" / "rpki-client")
rsync_to_remote(ssh_target, detect_libtls_path(rpki_client_bin), remote_root / "lib" / "libtls.so.28")
def prepare_remote_once(
prepared: dict[tuple[str, str], bool],
ssh_target: str,
remote_root: Path,
needs_rpki_client: bool,
) -> None:
key = (ssh_target, str(remote_root))
already_has_rpki_client = prepared.get(key)
if already_has_rpki_client is not None and (already_has_rpki_client or not needs_rpki_client):
return
prepare_remote(ssh_target, remote_root, needs_rpki_client)
prepared[key] = bool(already_has_rpki_client or needs_rpki_client)
def side_config(name: str) -> dict[str, Any]:
if name == "ours-standard":
return {"rpKind": "ours", "mode": "standard", "protocol": "rrdp+rsync", "rsyncScope": "module-root"}
if name == "ours-strict-all":
return {
"rpKind": "ours",
"mode": "strict-all",
"protocol": "rrdp+rsync",
"rsyncScope": "module-root",
"strictPolicies": "name,cms-der,signed-attrs",
}
if name == "rpki-client-standard":
return {"rpKind": "rpki-client", "mode": "standard", "protocol": "rrdp+rsync"}
raise SystemExit(f"unknown side config: {name}")
def parse_rirs(raw: str) -> list[str]:
rirs = [item.strip() for item in raw.split(",") if item.strip()]
if not rirs:
raise SystemExit("--rirs must contain at least one RIR")
seen: set[str] = set()
invalid: list[str] = []
duplicate: list[str] = []
for rir in rirs:
if rir not in DEFAULT_RIRS:
invalid.append(rir)
if rir in seen:
duplicate.append(rir)
seen.add(rir)
if invalid:
raise SystemExit(f"unsupported RIR(s): {','.join(invalid)}; valid values: {','.join(DEFAULT_RIRS)}")
if duplicate:
raise SystemExit(f"duplicate RIR(s): {','.join(duplicate)}")
return rirs
def build_remote_command(remote_root: Path, side_name: str, side: dict[str, Any], side_label: str, seq: int, rirs: list[str]) -> tuple[Path, str]:
run_dir = remote_root / "experiments" / "sequence" / side_label / f"run_{seq:04d}"
state_dir = remote_root / "experiments" / "sequence" / side_label / "state" / side["rpKind"]
sync_mode = "snapshot" if seq == 1 else "delta"
ensure = [f"mkdir -p {shlex.quote(str(run_dir))}", f"chmod 0777 {shlex.quote(str(run_dir))}"]
if side["rpKind"] == "ours":
if seq == 1:
ensure.append(f"rm -rf {shlex.quote(str(state_dir))}")
ensure.extend([
f"mkdir -p {shlex.quote(str(state_dir / 'work-db'))} {shlex.quote(str(state_dir / 'rsync-mirror'))}",
f"chmod -R 0777 {shlex.quote(str(state_dir.parent))}",
])
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"),
"--rsync-scope", side.get("rsyncScope", "module-root"),
]
if side.get("strictPolicies"):
argv.extend(["--strict", str(side["strictPolicies"])])
for rir in rirs:
argv.extend(["--tal-path", str(remote_root / "fixtures" / "tal" / fixture_name(rir, "tal"))])
argv.extend(["--ta-path", str(remote_root / "fixtures" / "ta" / fixture_name(rir, "ta"))])
argv.extend(["--report-json", str(run_dir / "report.json"), "--report-json-compact"])
argv.extend(["--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")])
prefix = "env RPKI_PROGRESS_LOG=1 RPKI_PROGRESS_SLOW_SECS=10 /usr/bin/time"
else:
if seq == 1:
ensure.append(f"rm -rf {shlex.quote(str(state_dir))}")
ensure.extend([
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(state_dir.parent))}",
])
for rir in rirs:
ensure.append(
f"cp -f {shlex.quote(str(remote_root / 'fixtures' / 'ta' / fixture_name(rir, 'ta')))} "
f"{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")]
for rir in rirs:
argv.extend(["-t", str(remote_root / "fixtures" / "tal" / fixture_name(rir, "tal"))])
argv.extend(["-T", f"{fixture_desc(rir)}:{state_dir / 'cache' / 'fixtures' / fixture_name(rir, 'ta')}"])
argv.extend(["-d", str(state_dir / "cache"), str(run_dir)])
prefix = f"env LD_LIBRARY_PATH={shlex.quote(str(remote_root / 'lib'))} /usr/bin/time"
command = (
"set -euo pipefail; "
+ "; ".join(ensure)
+ "; date -u +%Y-%m-%dT%H:%M:%SZ > " + shlex.quote(str(run_dir / "started-at.txt"))
+ "; set +e; "
+ prefix + " -v -o " + shlex.quote(str(run_dir / "process-time.txt"))
+ " -- " + shlex.join(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"))
+ "; date -u +%Y-%m-%dT%H:%M:%SZ > " + shlex.quote(str(run_dir / "finished-at.txt"))
+ "; true"
)
if side["rpKind"] == "rpki-client":
command += (
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"
)
command += (
f"; python3 - <<'REMOTE_META' {shlex.quote(str(run_dir))} {shlex.quote(side_name)} {shlex.quote(side_label)} {seq} {shlex.quote(sync_mode)}\n"
"import json, pathlib, sys\n"
"run_dir=pathlib.Path(sys.argv[1]); side_name=sys.argv[2]; side_label=sys.argv[3]; seq=int(sys.argv[4]); sync_mode=sys.argv[5]\n"
"def read(p):\n return p.read_text().strip() if p.exists() else None\n"
"def counts_from_report():\n"
" p=run_dir/'report.json'\n"
" if not p.exists():\n"
" return {}\n"
" try:\n"
" report=json.load(open(p))\n"
" except Exception:\n"
" return {}\n"
" meta=report.get('metadata') if isinstance(report, dict) else None\n"
" if isinstance(meta, dict):\n"
" return {'vrps': int(meta.get('vrps') or 0), 'vaps': int(meta.get('vaps') or meta.get('aspas') or 0), 'publicationPoints': int(meta.get('repositories') or 0), 'warnings': 0}\n"
" pps=report.get('publication_points', []) if isinstance(report, dict) else []\n"
" tree=report.get('tree', {}) if isinstance(report, dict) else {}\n"
" pp_warnings=sum(len(pp.get('warnings', [])) for pp in pps if isinstance(pp, dict)) if isinstance(pps, list) else 0\n"
" return {'vrps': len(report.get('vrps', [])), 'vaps': len(report.get('aspas', [])), 'publicationPoints': len(pps) if isinstance(pps, list) else 0, 'warnings': len(tree.get('warnings', [])) + pp_warnings if isinstance(tree, dict) else pp_warnings}\n"
"meta={'sideName':side_name,'sideLabel':side_label,'seq':seq,'syncMode':sync_mode,'startedAt':read(run_dir/'started-at.txt'),'finishedAt':read(run_dir/'finished-at.txt'),'exitCode':int(read(run_dir/'exit-code.txt') or '1'),'counts':counts_from_report()}\n"
"json.dump(meta, open(run_dir/'remote-run-meta.json','w'), indent=2, sort_keys=True); print()\n"
"REMOTE_META"
)
return run_dir, command
def run_remote_sample(ssh_target: str, remote_root: Path, side_name: str, side: dict[str, Any], side_label: str, seq: int, rirs: list[str]) -> Path:
run_dir, command = build_remote_command(remote_root, side_name, side, side_label, seq, rirs)
ssh_script(ssh_target, command)
return run_dir
def append_remote_sequence_item(
ssh_target: str,
remote_root: Path,
side_name: str,
side_label: str,
seq: int,
run_dir: Path,
schedule_mode: str,
) -> dict[str, Any]:
remote_exp_root = remote_root / "experiments" / "sequence"
seq_path = remote_exp_root / ("left-sequence.jsonl" if side_label == "A" else "right-sequence.jsonl")
side_value = "left" if side_label == "A" else "right"
script = f"""
set -euo pipefail
python3 - <<'REMOTE_SEQUENCE_ITEM' {shlex.quote(str(remote_exp_root))} {shlex.quote(str(seq_path))} {shlex.quote(str(run_dir))} {shlex.quote(str(remote_root / 'bin' / 'cir_dump_reject_list'))} {shlex.quote(side_name)} {shlex.quote(side_label)} {seq} {shlex.quote(side_value)} {shlex.quote(schedule_mode)}
import hashlib, json, os, pathlib, subprocess, sys
exp_root=pathlib.Path(sys.argv[1])
seq_path=pathlib.Path(sys.argv[2])
run_dir=pathlib.Path(sys.argv[3])
cir_dump=pathlib.Path(sys.argv[4])
side_name=sys.argv[5]
side_label=sys.argv[6]
seq=int(sys.argv[7])
side_value=sys.argv[8]
schedule_mode=sys.argv[9]
def read(p):
return p.read_text().strip() if p.exists() else None
def sha256_file(p):
h=hashlib.sha256()
with open(p, 'rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
def parse_elapsed_to_ms(raw):
raw=raw.strip()
if not raw:
return 0
if '-' in raw:
days, raw=raw.split('-', 1)
else:
days='0'
parts=raw.split(':')
if len(parts) == 3:
hours, minutes, seconds=parts
elif len(parts) == 2:
hours='0'; minutes, seconds=parts
else:
hours='0'; minutes='0'; seconds=parts[0]
return int(round((int(days)*86400 + int(hours)*3600 + int(minutes)*60 + float(seconds))*1000))
def parse_time_file(p):
data={{}}
if not p.exists():
return data
for line in p.read_text(errors='replace').splitlines():
if 'Elapsed (wall clock) time' in line:
elapsed=line.rsplit('):', 1)[1] if '):' in line else line.rsplit(':', 1)[1]
data['wallMs']=parse_elapsed_to_ms(elapsed)
elif 'Maximum resident set size' in line:
try:
data['maxRssKb']=int(line.rsplit(':', 1)[1].strip())
except ValueError:
pass
return data
def cir_counts(cir):
values={{}}
if not cir.exists():
return {{}}
result=subprocess.run([str(cir_dump), '--cir', str(cir), '--limit', '0'], text=True, capture_output=True, check=True)
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), 'cirTrustAnchorCount': values.get('trust_anchor_count', 0), 'cirRejectCount': values.get('reject_count', 0)}}
meta=json.load(open(run_dir/'remote-run-meta.json'))
time_info=parse_time_file(run_dir/'process-time.txt')
counts=dict(meta.get('counts') or {{}})
ccr=run_dir/'result.ccr'
cir=run_dir/'result.cir'
if meta.get('exitCode') != 0:
raise SystemExit(f"remote run failed: exitCode={{meta.get('exitCode')}} runDir={{run_dir}}")
missing=[str(path) for path in (ccr, cir) if not path.exists()]
if missing:
raise SystemExit(f"remote run missing required artifact(s): {{missing}}")
counts.update(cir_counts(cir))
item={{
'schemaVersion': 1,
'rpId': side_name,
'side': side_value,
'seq': seq,
'runId': f'{{side_label}}-{{seq:04d}}',
'syncMode': 'snapshot' if seq == 1 else 'delta',
'status': 'success' if meta.get('exitCode') == 0 else 'failed',
'startTime': meta.get('startedAt'),
'finishTime': meta.get('finishedAt'),
'validationTime': None,
'ccrPath': os.path.relpath(ccr, exp_root),
'cirPath': os.path.relpath(cir, exp_root),
'ccrSha256': sha256_file(ccr) if ccr.exists() else None,
'cirSha256': sha256_file(cir) if cir.exists() else None,
'wallMs': time_info.get('wallMs'),
'maxRssKb': time_info.get('maxRssKb'),
'vrps': counts.get('vrps'),
'vaps': counts.get('vaps'),
'publicationPoints': counts.get('publicationPoints'),
'cirObjectCount': counts.get('cirObjectCount'),
'cirRejectCount': counts.get('cirRejectCount'),
'cirTrustAnchorCount': counts.get('cirTrustAnchorCount'),
'scheduleMode': schedule_mode,
}}
seq_path.parent.mkdir(parents=True, exist_ok=True)
with open(seq_path, 'a', encoding='utf-8') as handle:
handle.write(json.dumps(item, sort_keys=True, ensure_ascii=False) + '\\n')
print(json.dumps(item, sort_keys=True, ensure_ascii=False))
REMOTE_SEQUENCE_ITEM
"""
result = ssh_script(ssh_target, script, capture=True)
lines = [line for line in result.stdout.splitlines() if line.strip()]
if not lines:
raise SystemExit("remote sequence item append produced no output")
return json.loads(lines[-1])
def cleanup_remote_run_nonessential(ssh_target: str, run_dir: Path) -> None:
keep = {
"result.ccr",
"result.cir",
"process-time.txt",
"remote-run-meta.json",
"exit-code.txt",
"started-at.txt",
"finished-at.txt",
"stdout.log",
"stderr.log",
}
keep_json = json.dumps(sorted(keep), ensure_ascii=False)
script = f"""
set -euo pipefail
python3 - <<'REMOTE_CLEAN' {shlex.quote(str(run_dir))} {shlex.quote(keep_json)}
import json, pathlib, sys
run_dir=pathlib.Path(sys.argv[1])
keep=set(json.loads(sys.argv[2]))
removed=0
if run_dir.exists():
for path in run_dir.iterdir():
if path.name in keep or path.is_dir():
continue
try:
removed += path.stat().st_size
path.unlink()
except FileNotFoundError:
pass
print(f'cleaned_nonessential_bytes={{removed}}')
REMOTE_CLEAN
"""
ssh_script(ssh_target, script)
def cir_counts(cir_path: Path) -> dict[str, int]:
result = run_local([str(REPO_ROOT / "target" / "release" / "cir_dump_reject_list"), "--cir", str(cir_path), "--limit", "0"], capture=True)
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),
"cirTrustAnchorCount": values.get("trust_anchor_count", 0),
"cirRejectCount": values.get("reject_count", 0),
}
def report_counts(path: Path, rp_kind: str) -> dict[str, int]:
if not path.is_file():
return {}
report = load_json(path)
if rp_kind == "rpki-client":
meta = report.get("metadata", {})
return {
"vrps": int(meta.get("vrps", 0)),
"vaps": int(meta.get("vaps", 0) or meta.get("aspas", 0) or 0),
"publicationPoints": int(meta.get("repositories", 0)),
"warnings": 0,
}
pps = report.get("publication_points", [])
tree = report.get("tree", {})
return {
"vrps": len(report.get("vrps", [])),
"vaps": len(report.get("aspas", [])),
"publicationPoints": len(pps),
"warnings": len(tree.get("warnings", [])) + sum(len(pp.get("warnings", [])) for pp in pps if isinstance(pp, dict)),
}
def build_sequence_item(local_root: Path, side_name: str, side_label: str, side: dict[str, Any], seq: int, run_dir: Path) -> dict[str, Any]:
ccr = run_dir / "result.ccr"
cir = run_dir / "result.cir"
meta = load_json(run_dir / "remote-run-meta.json")
time_info = parse_time_file(run_dir / "process-time.txt")
counts = dict(meta.get("counts") or {})
if not counts:
counts = report_counts(run_dir / "report.json", side["rpKind"])
counts.update(cir_counts(cir))
return {
"schemaVersion": 1,
"rpId": side_name,
"side": "left" if side_label == "A" else "right",
"seq": seq,
"runId": f"{side_label}-{seq:04d}",
"syncMode": "snapshot" if seq == 1 else "delta",
"status": "success" if meta.get("exitCode") == 0 else "failed",
"startTime": meta.get("startedAt"),
"finishTime": meta.get("finishedAt"),
"validationTime": None,
"ccrPath": ccr.relative_to(local_root).as_posix(),
"cirPath": cir.relative_to(local_root).as_posix(),
"ccrSha256": sha256_file(ccr),
"cirSha256": sha256_file(cir),
"wallMs": time_info.get("wallMs"),
"maxRssKb": time_info.get("maxRssKb"),
"vrps": counts.get("vrps"),
"vaps": counts.get("vaps"),
"publicationPoints": counts.get("publicationPoints"),
"cirObjectCount": counts.get("cirObjectCount"),
"cirRejectCount": counts.get("cirRejectCount"),
"cirTrustAnchorCount": counts.get("cirTrustAnchorCount"),
}
def run_sequence_triage(local_exp_root: Path, args: argparse.Namespace) -> None:
compare_dir = local_exp_root / "sequence-triage"
run_local([
str(REPO_ROOT / "target" / "release" / "sequence_triage_ccr_cir"),
"--left-sequence", str(local_exp_root / "left-sequence.jsonl"),
"--right-sequence", str(local_exp_root / "right-sequence.jsonl"),
"--out-dir", str(compare_dir),
"--align-window-runs", str(args.align_window_runs),
"--align-window-secs", str(args.align_window_secs),
"--sample-limit", str(args.sample_limit),
"--timeline-sample-limit", str(args.timeline_sample_limit),
])
def run_sequence_triage_remote(ssh_target: str, remote_root: Path, args: argparse.Namespace) -> None:
remote_exp_root = remote_root / "experiments" / "sequence"
compare_dir = remote_exp_root / "sequence-triage"
time_path = remote_exp_root / "sequence-triage-time.txt"
command = " ".join(
shlex.quote(item)
for item in [
str(remote_root / "bin" / "sequence_triage_ccr_cir"),
"--left-sequence", str(remote_exp_root / "left-sequence.jsonl"),
"--right-sequence", str(remote_exp_root / "right-sequence.jsonl"),
"--out-dir", str(compare_dir),
"--align-window-runs", str(args.align_window_runs),
"--align-window-secs", str(args.align_window_secs),
"--sample-limit", str(args.sample_limit),
"--timeline-sample-limit", str(args.timeline_sample_limit),
]
)
ssh_script(
ssh_target,
"set -euo pipefail; "
f"rm -rf {shlex.quote(str(compare_dir))} {shlex.quote(str(time_path))}; "
f"/usr/bin/time -v -o {shlex.quote(str(time_path))} -- {command}",
)
def sync_side_to_analysis_remote(
source_ssh_target: str,
source_remote_root: Path,
analysis_ssh_target: str,
analysis_remote_root: Path,
side_label: str,
) -> None:
source_exp_root = source_remote_root / "experiments" / "sequence"
analysis_exp_root = analysis_remote_root / "experiments" / "sequence"
sequence_name = "left-sequence.jsonl" if side_label == "A" else "right-sequence.jsonl"
if same_remote_location(source_ssh_target, source_exp_root, analysis_ssh_target, analysis_exp_root):
return
side_dir = source_exp_root / side_label
script = (
"set -euo pipefail; "
f"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new {shlex.quote(analysis_ssh_target)} "
f"{shlex.quote('mkdir -p ' + shlex.quote(str(analysis_exp_root / side_label)))}; "
f"rsync -az --delete {shlex.quote(str(side_dir))}/ {shlex.quote(analysis_ssh_target)}:{shlex.quote(str(analysis_exp_root / side_label))}/; "
f"rsync -az {shlex.quote(str(source_exp_root / sequence_name))} "
f"{shlex.quote(analysis_ssh_target)}:{shlex.quote(str(analysis_exp_root / sequence_name))}"
)
ssh_script(source_ssh_target, script)
def run_side_sequence(
args: argparse.Namespace,
ssh_target: str,
remote_root: Path,
local_exp_root: Path,
side_label: str,
side_name: str,
side: dict[str, Any],
seq_path: Path,
rirs: list[str],
) -> list[dict[str, Any]]:
side_progress: list[dict[str, Any]] = []
for seq in range(1, args.samples_per_side + 1):
side_progress.append(
run_one_side_sample(args, ssh_target, remote_root, local_exp_root, side_label, side_name, side, seq_path, seq, rirs)
)
return side_progress
def run_one_side_sample(
args: argparse.Namespace,
ssh_target: str,
remote_root: Path,
local_exp_root: Path,
side_label: str,
side_name: str,
side: dict[str, Any],
seq_path: Path,
seq: int,
rirs: list[str],
) -> dict[str, Any]:
rir_label = ",".join(rirs)
print(
f"[run] {side_label} {side_name} seq={seq} rirs={rir_label} schedule={args.schedule_mode}",
flush=True,
)
remote_run_dir = run_remote_sample(ssh_target, remote_root, side_name, side, side_label, seq, rirs)
if args.remote_triage:
item = append_remote_sequence_item(
ssh_target,
remote_root,
side_name,
side_label,
seq,
remote_run_dir,
args.schedule_mode,
)
if args.cleanup_run_nonessential:
cleanup_remote_run_nonessential(ssh_target, remote_run_dir)
else:
local_run_dir = local_exp_root / side_label / f"run_{seq:04d}"
rsync_run_artifacts_from_remote(ssh_target, remote_run_dir, local_run_dir)
item = build_sequence_item(local_exp_root, side_name, side_label, side, seq, local_run_dir)
item["scheduleMode"] = args.schedule_mode
append_jsonl(seq_path, item)
print(
f"[done] {side_label} seq={seq} wallMs={item.get('wallMs')} vrps={item.get('vrps')} vaps={item.get('vaps')} objects={item.get('cirObjectCount')} rejects={item.get('cirRejectCount')}",
flush=True,
)
return item
def run_experiment(args: argparse.Namespace) -> None:
if not args.skip_build:
build_tool_binaries()
rirs = parse_rirs(args.rirs)
left = side_config(args.left)
right = side_config(args.right)
run_root = Path(args.run_root).resolve()
remote_root = Path(args.remote_root)
left_ssh_target = args.left_ssh_target or args.ssh_target
right_ssh_target = args.right_ssh_target or args.ssh_target
analysis_ssh_target = args.analysis_ssh_target or left_ssh_target
left_remote_root = Path(args.left_remote_root or args.remote_root)
right_remote_root = Path(args.right_remote_root or args.remote_root)
analysis_remote_root = Path(args.analysis_remote_root or args.remote_root)
run_root.mkdir(parents=True, exist_ok=True)
write_json(run_root / "experiment-config.json", {
"schemaVersion": 1,
"generatedAtUtc": utc_stamp(),
"left": args.left,
"right": args.right,
"samplesPerSide": args.samples_per_side,
"rirs": rirs,
"scheduleMode": args.schedule_mode,
"remoteRoot": str(remote_root),
"leftSshTarget": left_ssh_target,
"rightSshTarget": right_ssh_target,
"analysisSshTarget": analysis_ssh_target,
"leftRemoteRoot": str(left_remote_root),
"rightRemoteRoot": str(right_remote_root),
"analysisRemoteRoot": str(analysis_remote_root),
"sshTarget": args.ssh_target,
})
if args.dry_run:
print(json.dumps(load_json(run_root / "experiment-config.json"), indent=2, ensure_ascii=False))
return
if args.triage_only:
run_sequence_triage(run_root / "experiments" / "sequence", args)
print(json.dumps({
"runRoot": str(run_root),
"triage": str(run_root / "experiments" / "sequence" / "sequence-triage" / "sequence-triage.json"),
}, indent=2))
return
prepared_remotes: dict[tuple[str, str], bool] = {}
prepare_remote_once(prepared_remotes, left_ssh_target, left_remote_root, needs_rpki_client=(left["rpKind"] == "rpki-client"))
prepare_remote_once(prepared_remotes, right_ssh_target, right_remote_root, needs_rpki_client=(right["rpKind"] == "rpki-client"))
prepare_remote_once(prepared_remotes, analysis_ssh_target, analysis_remote_root, needs_rpki_client=False)
local_exp_root = run_root / "experiments" / "sequence"
left_seq_path = local_exp_root / "left-sequence.jsonl"
right_seq_path = local_exp_root / "right-sequence.jsonl"
left_seq_path.unlink(missing_ok=True)
right_seq_path.unlink(missing_ok=True)
progress: list[dict[str, Any]] = []
if args.schedule_mode == "interleaved":
for seq in range(1, args.samples_per_side + 1):
for side_label, ssh_target, side_remote_root, side_name, side, seq_path in [
("A", left_ssh_target, left_remote_root, args.left, left, left_seq_path),
("B", right_ssh_target, right_remote_root, args.right, right, right_seq_path),
]:
progress.append(
run_one_side_sample(
args,
ssh_target,
side_remote_root,
local_exp_root,
side_label,
side_name,
side,
seq_path,
seq,
rirs,
)
)
else:
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(run_side_sequence, args, left_ssh_target, left_remote_root, local_exp_root, "A", args.left, left, left_seq_path, rirs),
executor.submit(run_side_sequence, args, right_ssh_target, right_remote_root, local_exp_root, "B", args.right, right, right_seq_path, rirs),
]
for future in as_completed(futures):
progress.extend(future.result())
progress.sort(key=lambda item: (str(item.get("side")), int(item.get("seq") or 0)))
write_json(local_exp_root / "run-progress.json", progress)
if args.remote_triage:
sync_side_to_analysis_remote(left_ssh_target, left_remote_root, analysis_ssh_target, analysis_remote_root, "A")
sync_side_to_analysis_remote(right_ssh_target, right_remote_root, analysis_ssh_target, analysis_remote_root, "B")
remote_exp_root = analysis_remote_root / "experiments" / "sequence"
remote_progress = json.dumps(progress, sort_keys=True, ensure_ascii=False)
ssh_script(
analysis_ssh_target,
"set -euo pipefail; "
f"cat > {shlex.quote(str(remote_exp_root / 'run-progress.json'))} <<'REMOTE_PROGRESS_JSON'\n"
f"{remote_progress}\n"
"REMOTE_PROGRESS_JSON\n",
)
run_sequence_triage_remote(analysis_ssh_target, analysis_remote_root, args)
if args.fetch_remote_analysis:
rsync_remote_analysis_from_remote(analysis_ssh_target, remote_exp_root, local_exp_root)
else:
run_sequence_triage(local_exp_root, args)
ssh_script(analysis_ssh_target, f"df -h /data / > {shlex.quote(str(analysis_remote_root / 'df-after.txt'))} 2>&1 || true; free -h > {shlex.quote(str(analysis_remote_root / 'free-after.txt'))} 2>&1 || true")
compare_dir = local_exp_root / "sequence-triage"
remote_compare_dir = analysis_remote_root / "experiments" / "sequence" / "sequence-triage"
print(json.dumps({
"runRoot": str(run_root),
"remoteRoot": str(remote_root),
"leftRemoteRoot": str(left_remote_root),
"rightRemoteRoot": str(right_remote_root),
"analysisRemoteRoot": str(analysis_remote_root),
"triage": str(compare_dir / "sequence-triage.json") if not args.remote_triage or args.fetch_remote_analysis else None,
"remoteTriage": str(remote_compare_dir / "sequence-triage.json") if args.remote_triage else None,
}, indent=2))
def main() -> None:
parser = argparse.ArgumentParser(description="Feature #043 all5 sequence triage 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("--left-ssh-target")
parser.add_argument("--right-ssh-target")
parser.add_argument("--analysis-ssh-target")
parser.add_argument("--left-remote-root")
parser.add_argument("--right-remote-root")
parser.add_argument("--analysis-remote-root")
parser.add_argument("--left", default="ours-standard")
parser.add_argument("--right", default="rpki-client-standard")
parser.add_argument("--samples-per-side", type=int, default=3)
parser.add_argument("--rirs", default=",".join(DEFAULT_RIRS))
parser.add_argument("--schedule-mode", choices=["interleaved", "parallel"], default="interleaved")
parser.add_argument("--align-window-runs", type=int, default=2)
parser.add_argument("--align-window-secs", type=int, default=1800)
parser.add_argument("--sample-limit", type=int, default=200)
parser.add_argument("--timeline-sample-limit", type=int, default=0)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--skip-build", action="store_true", help="reuse existing release binaries")
parser.add_argument("--triage-only", action="store_true", help="only rerun local sequence triage for an existing run root")
parser.add_argument("--remote-triage", action="store_true", help="keep CIR/CCR on remote, write sequence JSONL remotely, and run triage on remote")
parser.add_argument("--fetch-remote-analysis", action="store_true", help="when --remote-triage is set, fetch only small sequence/triage JSON outputs; never fetch CIR/CCR")
parser.add_argument("--cleanup-run-nonessential", action="store_true", help="after each successful remote sequence item, remove report/log/CSV files and keep only CIR/CCR/timing/meta")
args = parser.parse_args()
if args.samples_per_side < 2:
raise SystemExit("--samples-per-side must be >= 2")
run_experiment(args)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
build_local_repo_replay_package.sh --out <path> [--tar-gz]
Build a standalone local repository tree replay package for Routinator and
rpki-client. The package does not include repository data and does not include
materialize tooling.
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SRC_DIR="$ROOT_DIR/scripts/local_repo_replay"
OUT=""
TAR_GZ=0
while [[ $# -gt 0 ]]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
--tar-gz) TAR_GZ=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$OUT" ]] || { usage >&2; exit 2; }
if [[ "$TAR_GZ" -eq 1 ]]; then
PACKAGE_DIR="$(mktemp -d)"
TARGET_DIR="$PACKAGE_DIR/local-repo-replay-package"
else
TARGET_DIR="$OUT"
rm -rf "$TARGET_DIR"
fi
mkdir -p "$TARGET_DIR/scripts" "$TARGET_DIR/docs" "$TARGET_DIR/examples"
install -m 0755 "$SRC_DIR/run_routinator_from_local_tree.sh" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/run_rpki_client_from_local_tree.sh" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/run_dual_local_tree_replay.sh" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/prepare_tals.py" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/normalize_rp_outputs.py" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/compare_normalized_sets.py" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/summarize_replay.py" "$TARGET_DIR/scripts/"
install -m 0755 "$ROOT_DIR/scripts/cir/cir-rsync-wrapper" "$TARGET_DIR/scripts/"
install -m 0755 "$ROOT_DIR/scripts/cir/cir-local-link-sync.py" "$TARGET_DIR/scripts/"
install -m 0644 "$SRC_DIR/templates/README.md" "$TARGET_DIR/README.md"
install -m 0644 "$SRC_DIR/templates/docs/input_tree_requirements.md" "$TARGET_DIR/docs/"
install -m 0644 "$SRC_DIR/templates/docs/offline_replay_limits.md" "$TARGET_DIR/docs/"
install -m 0644 "$SRC_DIR/templates/docs/output_files.md" "$TARGET_DIR/docs/"
install -m 0755 "$SRC_DIR/templates/examples/routinator_example.sh" "$TARGET_DIR/examples/"
install -m 0755 "$SRC_DIR/templates/examples/rpki_client_example.sh" "$TARGET_DIR/examples/"
install -m 0755 "$SRC_DIR/templates/examples/dual_compare_example.sh" "$TARGET_DIR/examples/"
cat > "$TARGET_DIR/env.example" <<'EOF'
# 本地目录树 replay 示例配置。目录树由使用者提前准备,不包含在 package 中。
TAL_DIR=/data/replay/tals
MIRROR_ROOT=/data/replay/mirror
ROUTINATOR_BIN=/opt/routinator/target/release/routinator
RPKI_CLIENT_BIN=/opt/rpki-client/src/rpki-client
RPKI_CLIENT_CACHE_DIR=/data/replay/work/rpki-client-cache
VALIDATION_TIME=2026-05-23T00:00:00Z
EOF
if grep -R -E 'cir_materialize|repo-bytes\\.db|\\.cir' "$TARGET_DIR/scripts" >/dev/null; then
echo "package contains forbidden materialize/repo-bytes implementation references" >&2
exit 1
fi
if [[ "$TAR_GZ" -eq 1 ]]; then
mkdir -p "$(dirname "$OUT")"
tar -C "$PACKAGE_DIR" -czf "$OUT" local-repo-replay-package
rm -rf "$PACKAGE_DIR"
echo "$OUT"
else
echo "$TARGET_DIR"
fi

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def read_set(path: Path) -> set[str]:
if not path.is_file():
return set()
return {line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()}
def compare(left: set[str], right: set[str]) -> dict[str, object]:
union = left | right
inter = left & right
return {
"left": len(left),
"right": len(right),
"intersection": len(inter),
"onlyLeft": len(left - right),
"onlyRight": len(right - left),
"jaccard": round(len(inter) / len(union), 8) if union else 1.0,
"onlyLeftSamples": sorted(left - right)[:20],
"onlyRightSamples": sorted(right - left)[:20],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--left", type=Path, required=True)
parser.add_argument("--right", type=Path, required=True)
parser.add_argument("--left-name", default="left")
parser.add_argument("--right-name", default="right")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
result = {
"leftName": args.left_name,
"rightName": args.right_name,
"vrps": compare(read_set(args.left / "vrps.normalized.txt"), read_set(args.right / "vrps.normalized.txt")),
"vaps": compare(read_set(args.left / "vaps.normalized.txt"), read_set(args.right / "vaps.normalized.txt")),
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(args.out)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
from typing import Any
def normalize_asn(value: Any) -> str:
text = str(value).strip().upper()
if text.startswith("AS"):
text = text[2:]
return f"AS{int(text)}"
def write_set(path: Path, rows: set[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(sorted(rows)) + ("\n" if rows else ""), encoding="utf-8")
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def normalize_vrps_from_json(path: Path) -> set[str]:
data = load_json(path)
rows: set[str] = set()
objects: list[dict[str, Any]] = []
if isinstance(data, dict):
for key in ("roas", "routeOrigins", "valid_roas"):
value = data.get(key)
if isinstance(value, list):
objects.extend(item for item in value if isinstance(item, dict))
elif isinstance(data, list):
objects = [item for item in data if isinstance(item, dict)]
for item in objects:
asn = item.get("asn") or item.get("asID") or item.get("origin")
prefix = item.get("prefix")
max_length = item.get("maxLength") or item.get("max_length") or item.get("maxlen") or item.get("maxLengthPrefix")
if asn is None or prefix is None or max_length is None:
continue
rows.add(f"{normalize_asn(asn)}|{prefix}|{int(max_length)}")
return rows
def normalize_vaps_from_json(path: Path) -> set[str]:
data = load_json(path)
rows: set[str] = set()
objects: list[dict[str, Any]] = []
if isinstance(data, dict):
for key in ("aspas", "aspaAssertions", "vaps"):
value = data.get(key)
if isinstance(value, list):
objects.extend(item for item in value if isinstance(item, dict))
elif isinstance(data, list):
objects = [item for item in data if isinstance(item, dict)]
for item in objects:
customer = (
item.get("customer")
or item.get("customer_asid")
or item.get("customerASID")
or item.get("customerAsid")
or item.get("customerAsn")
)
providers = item.get("providers") or item.get("provider_asns") or item.get("providerASNs") or []
if customer is None:
continue
provider_asns = [normalize_asn(provider) for provider in providers]
rows.add(f"{normalize_asn(customer)}|{','.join(sorted(set(provider_asns), key=lambda value: int(value[2:])))}")
return rows
def normalize_vrps_from_csv(path: Path) -> set[str]:
rows: set[str] = set()
with path.open(newline="", encoding="utf-8", errors="replace") as handle:
for row in csv.DictReader(handle):
asn = row.get("ASN") or row.get("asn") or row.get("AS")
prefix = row.get("IP Prefix") or row.get("prefix") or row.get("Prefix")
max_length = row.get("Max Length") or row.get("maxLength") or row.get("max_length")
if asn and prefix and max_length:
rows.add(f"{normalize_asn(asn)}|{prefix}|{int(max_length)}")
return rows
def normalize_routinator(input_path: Path) -> tuple[set[str], set[str]]:
return normalize_vrps_from_json(input_path), normalize_vaps_from_json(input_path)
def normalize_rpki_client(input_path: Path) -> tuple[set[str], set[str]]:
json_path = input_path / "json" if input_path.is_dir() else input_path
csv_path = input_path / "csv" if input_path.is_dir() else input_path
vrps: set[str] = set()
vaps: set[str] = set()
if json_path.is_file():
vrps |= normalize_vrps_from_json(json_path)
vaps |= normalize_vaps_from_json(json_path)
if csv_path.is_file():
vrps |= normalize_vrps_from_csv(csv_path)
return vrps, vaps
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--kind", choices=["routinator", "rpki-client"], required=True)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--vrps-out", type=Path, required=True)
parser.add_argument("--vaps-out", type=Path, required=True)
args = parser.parse_args()
if args.kind == "routinator":
vrps, vaps = normalize_routinator(args.input)
else:
vrps, vaps = normalize_rpki_client(args.input)
write_set(args.vrps_out, vrps)
write_set(args.vaps_out, vaps)
print(json.dumps({"vrps": len(vrps), "vaps": len(vaps)}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def split_tal(text: str) -> tuple[list[str], list[str]]:
lines = text.splitlines()
for index, line in enumerate(lines):
if line.strip() == "":
return lines[:index], lines[index + 1 :]
return lines, []
def rsync_only_tal(source: Path) -> tuple[str, dict[str, object]]:
text = source.read_text(encoding="utf-8", errors="replace")
uri_lines, key_lines = split_tal(text)
uris = [
line.strip()
for line in uri_lines
if line.strip() and not line.lstrip().startswith("#")
]
rsync_uris = [
uri for uri in uris
if uri.lower().startswith("rsync://")
]
if not rsync_uris:
return text if text.endswith("\n") else text + "\n", {
"file": source.name,
"mode": "unchanged_no_rsync_uri",
"uris": len(uris),
"rsyncUris": 0,
}
out = "\n".join(rsync_uris) + "\n\n" + "\n".join(key_lines).strip() + "\n"
return out, {
"file": source.name,
"mode": "rsync_only",
"uris": len(uris),
"rsyncUris": len(rsync_uris),
}
def collect_tals(tal_dir: Path | None, tals: list[Path]) -> list[Path]:
paths: list[Path] = []
if tal_dir is not None:
paths.extend(sorted(tal_dir.glob("*.tal")))
paths.extend(tals)
unique: dict[str, Path] = {}
for path in paths:
unique[path.name] = path
return [unique[name] for name in sorted(unique)]
def main() -> int:
parser = argparse.ArgumentParser(description="Prepare TALs for local rsync-tree replay.")
parser.add_argument("--tal-dir", type=Path)
parser.add_argument("--tal", type=Path, action="append", default=[])
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--summary", type=Path)
args = parser.parse_args()
sources = collect_tals(args.tal_dir, args.tal)
if not sources:
raise SystemExit("no TAL files provided")
args.out_dir.mkdir(parents=True, exist_ok=True)
rows = []
for source in sources:
if not source.is_file():
raise SystemExit(f"TAL file not found: {source}")
content, row = rsync_only_tal(source)
(args.out_dir / source.name).write_text(content, encoding="utf-8")
rows.append(row)
if args.summary is not None:
args.summary.parent.mkdir(parents=True, exist_ok=True)
args.summary.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
run_dual_local_tree_replay.sh \
--routinator-bin <path> --routinator-mirror-root <dir> \
--rpki-client-bin <path> --rpki-client-mirror-root <dir> \
--tal-dir <dir> --out-dir <dir> [--validation-time <RFC3339>]
EOF
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROUTINATOR_BIN=""
ROUTINATOR_MIRROR_ROOT=""
RPKI_CLIENT_BIN=""
RPKI_CLIENT_MIRROR_ROOT=""
RPKI_CLIENT_CACHE_DIR=""
TAL_DIR=""
OUT_DIR=""
VALIDATION_TIME=""
while [[ $# -gt 0 ]]; do
case "$1" in
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--routinator-mirror-root) ROUTINATOR_MIRROR_ROOT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--rpki-client-mirror-root) RPKI_CLIENT_MIRROR_ROOT="$2"; shift 2 ;;
--rpki-client-cache-dir) RPKI_CLIENT_CACHE_DIR="$2"; shift 2 ;;
--tal-dir) TAL_DIR="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--validation-time) VALIDATION_TIME="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$RPKI_CLIENT_MIRROR_ROOT" ]]; then
RPKI_CLIENT_MIRROR_ROOT="$ROUTINATOR_MIRROR_ROOT"
fi
[[ -n "$ROUTINATOR_BIN" && -n "$ROUTINATOR_MIRROR_ROOT" && -n "$RPKI_CLIENT_BIN" && -n "$RPKI_CLIENT_MIRROR_ROOT" && -n "$TAL_DIR" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
TIME_ARGS=()
if [[ -n "$VALIDATION_TIME" ]]; then
TIME_ARGS=(--validation-time "$VALIDATION_TIME")
fi
CACHE_ARGS=()
if [[ -n "$RPKI_CLIENT_CACHE_DIR" ]]; then
CACHE_ARGS=(--cache-dir "$RPKI_CLIENT_CACHE_DIR")
fi
"$SCRIPT_DIR/run_routinator_from_local_tree.sh" \
--routinator-bin "$ROUTINATOR_BIN" \
--mirror-root "$ROUTINATOR_MIRROR_ROOT" \
--tal-dir "$TAL_DIR" \
--out-dir "$OUT_DIR/routinator" \
--enable-aspa \
"${TIME_ARGS[@]}"
"$SCRIPT_DIR/run_rpki_client_from_local_tree.sh" \
--rpki-client-bin "$RPKI_CLIENT_BIN" \
--mirror-root "$RPKI_CLIENT_MIRROR_ROOT" \
--tal-dir "$TAL_DIR" \
--out-dir "$OUT_DIR/rpki-client" \
"${CACHE_ARGS[@]}" \
"${TIME_ARGS[@]}"
python3 "$SCRIPT_DIR/compare_normalized_sets.py" \
--left "$OUT_DIR/routinator" \
--right "$OUT_DIR/rpki-client" \
--left-name routinator \
--right-name rpki-client \
--out "$OUT_DIR/compare-summary.json"
echo "done: $OUT_DIR"

View File

@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
run_routinator_from_local_tree.sh \
--routinator-bin <path> \
--mirror-root <local-rsync-mirror-root> \
--tal-dir <dir> | --tal <file>... \
--out-dir <path> \
[--validation-time <RFC3339>] \
[--real-rsync-bin <path>] \
[--enable-aspa]
The input mirror is prepared by the caller. This script does not generate it.
Pass --validation-time only if FAKETIME_LIB points to a working libfaketime
library; otherwise Routinator will validate at wall-clock time.
Example:
export FAKETIME_LIB=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1
EOF
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROUTINATOR_BIN=""
MIRROR_ROOT=""
OUT_DIR=""
VALIDATION_TIME=""
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
ENABLE_ASPA=0
TAL_DIR=""
TALS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--mirror-root) MIRROR_ROOT="$2"; shift 2 ;;
--tal-dir) TAL_DIR="$2"; shift 2 ;;
--tal) TALS+=("$2"); shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--validation-time) VALIDATION_TIME="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
--enable-aspa) ENABLE_ASPA=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$ROUTINATOR_BIN" && -n "$MIRROR_ROOT" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
[[ -x "$ROUTINATOR_BIN" ]] || { echo "routinator binary not executable: $ROUTINATOR_BIN" >&2; exit 2; }
[[ -d "$MIRROR_ROOT" ]] || { echo "mirror root not found: $MIRROR_ROOT" >&2; exit 2; }
if [[ -z "$TAL_DIR" && "${#TALS[@]}" -eq 0 ]]; then
echo "provide --tal-dir or at least one --tal" >&2
exit 2
fi
mkdir -p "$OUT_DIR"
WORK_DIR="$OUT_DIR/work"
REPO_DIR="$WORK_DIR/repository"
EXTRA_TALS="$WORK_DIR/tals"
CONFIG_FILE="$WORK_DIR/routinator.conf"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$EXTRA_TALS"
PREPARE_TAL_ARGS=(--out-dir "$EXTRA_TALS" --summary "$OUT_DIR/prepared-tals.json")
if [[ -n "$TAL_DIR" ]]; then
PREPARE_TAL_ARGS+=(--tal-dir "$TAL_DIR")
fi
for tal in "${TALS[@]}"; do
PREPARE_TAL_ARGS+=(--tal "$tal")
done
python3 "$SCRIPT_DIR/prepare_tals.py" "${PREPARE_TAL_ARGS[@]}"
cat > "$CONFIG_FILE" <<EOF
repository-dir = "$REPO_DIR"
no-rir-tals = true
extra-tals-dir = "$EXTRA_TALS"
disable-rrdp = true
rrdp-fallback = "never"
rsync-command = "$SCRIPT_DIR/cir-rsync-wrapper"
EOF
export CIR_MIRROR_ROOT="$(cd "$MIRROR_ROOT" && pwd)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
export LOCAL_REPO_REPLAY_VALIDATION_TIME="$VALIDATION_TIME"
export LOCAL_REPO_REPLAY_OUT_DIR="$OUT_DIR"
ARGS=(
"$ROUTINATOR_BIN"
--config "$CONFIG_FILE"
--repository-dir "$REPO_DIR"
--disable-rrdp
--rrdp-fallback never
--rsync-command "$SCRIPT_DIR/cir-rsync-wrapper"
--no-rir-tals
--extra-tals-dir "$EXTRA_TALS"
)
if [[ "$ENABLE_ASPA" -eq 1 ]]; then
echo 'enable-aspa = true' >> "$CONFIG_FILE"
ARGS+=(--enable-aspa)
fi
if [[ -n "$VALIDATION_TIME" && -z "${FAKETIME_LIB:-}" ]]; then
echo "warning: --validation-time is ignored for Routinator because FAKETIME_LIB is not set" >&2
fi
VRP_ARGS=(vrps -o "$OUT_DIR/vrps.csv")
JSON_ARGS=(vrps -f jsonext -o "$OUT_DIR/routinator.json")
/usr/bin/time -v -o "$OUT_DIR/process-time.txt" bash -c '
set -euo pipefail
if [[ -n "$LOCAL_REPO_REPLAY_VALIDATION_TIME" && -n "${FAKETIME_LIB:-}" ]]; then
faketime_value="$(python3 - <<'"'"'PY'"'"' "$LOCAL_REPO_REPLAY_VALIDATION_TIME"
from datetime import datetime, timezone
import sys
dt = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc)
print("@" + dt.strftime("%Y-%m-%d %H:%M:%S"))
PY
)"
export LD_PRELOAD="$FAKETIME_LIB"
export TZ=UTC
unset FAKETIME_FMT
export FAKETIME="$faketime_value"
export FAKETIME_DONT_FAKE_MONOTONIC=1
fi
"$@" update --complete >"$LOCAL_REPO_REPLAY_OUT_DIR/update.log" 2>&1 || true
"$@" vrps -o "$LOCAL_REPO_REPLAY_OUT_DIR/vrps.csv" >"$LOCAL_REPO_REPLAY_OUT_DIR/vrps.log" 2>&1
"$@" vrps -f jsonext -o "$LOCAL_REPO_REPLAY_OUT_DIR/routinator.json" >"$LOCAL_REPO_REPLAY_OUT_DIR/json.log" 2>&1
' local_repo_replay "${ARGS[@]}"
python3 "$SCRIPT_DIR/normalize_rp_outputs.py" --kind routinator --input "$OUT_DIR/routinator.json" --vrps-out "$OUT_DIR/vrps.normalized.txt" --vaps-out "$OUT_DIR/vaps.normalized.txt"
python3 "$SCRIPT_DIR/summarize_replay.py" --rp routinator --out-dir "$OUT_DIR" --summary "$OUT_DIR/summary.json"
echo "done: $OUT_DIR"

View File

@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
run_rpki_client_from_local_tree.sh \
--rpki-client-bin <path> \
--mirror-root <local-rsync-mirror-root> \
--tal-dir <dir> | --tal <file>... \
--out-dir <path> \
[--cache-dir <work-cache-dir>] \
[--validation-time <RFC3339>] \
[--real-rsync-bin <path>] \
[--parser-workers <n>]
The input mirror is prepared by the caller. This script disables RRDP, points
rpki-client's rsync command at a local URI mapper, and fetches only from the
local filesystem tree.
EOF
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RPKI_CLIENT_BIN=""
CACHE_DIR=""
MIRROR_ROOT=""
OUT_DIR=""
TAL_DIR=""
VALIDATION_TIME=""
PARSER_WORKERS="${PARSER_WORKERS:-4}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
TALS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--mirror-root) MIRROR_ROOT="$2"; shift 2 ;;
--cache-dir) CACHE_DIR="$2"; shift 2 ;;
--tal-dir) TAL_DIR="$2"; shift 2 ;;
--tal) TALS+=("$2"); shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--validation-time) VALIDATION_TIME="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
--parser-workers) PARSER_WORKERS="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$RPKI_CLIENT_BIN" && -n "$MIRROR_ROOT" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
[[ -d "$MIRROR_ROOT" ]] || { echo "mirror root not found: $MIRROR_ROOT" >&2; exit 2; }
if [[ -z "$TAL_DIR" && "${#TALS[@]}" -eq 0 ]]; then
echo "provide --tal-dir or at least one --tal" >&2
exit 2
fi
mkdir -p "$OUT_DIR"
if [[ -z "$CACHE_DIR" ]]; then
CACHE_DIR="$OUT_DIR/cache"
fi
mkdir -p "$CACHE_DIR" "$OUT_DIR/out"
chmod a+rwx "$OUT_DIR" "$CACHE_DIR" "$OUT_DIR/out"
WORK_DIR="$OUT_DIR/work"
PREPARED_TALS="$WORK_DIR/tals"
rm -rf "$WORK_DIR"
mkdir -p "$PREPARED_TALS"
PREPARE_TAL_ARGS=(--out-dir "$PREPARED_TALS" --summary "$OUT_DIR/prepared-tals.json")
if [[ -n "$TAL_DIR" ]]; then
PREPARE_TAL_ARGS+=(--tal-dir "$TAL_DIR")
fi
for tal in "${TALS[@]}"; do
PREPARE_TAL_ARGS+=(--tal "$tal")
done
python3 "$SCRIPT_DIR/prepare_tals.py" "${PREPARE_TAL_ARGS[@]}"
CLIENT_TAL_ARGS=()
while IFS= read -r tal; do
CLIENT_TAL_ARGS+=(-t "$tal")
done < <(find "$PREPARED_TALS" -maxdepth 1 -type f -name '*.tal' | sort)
TIME_ARGS=()
if [[ -n "$VALIDATION_TIME" ]]; then
epoch="$(python3 - <<'PY' "$VALIDATION_TIME"
from datetime import datetime, timezone
import sys
print(int(datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc).timestamp()))
PY
)"
TIME_ARGS=(-P "$epoch")
fi
export CIR_MIRROR_ROOT="$(cd "$MIRROR_ROOT" && pwd)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
/usr/bin/time -v -o "$OUT_DIR/process-time.txt" \
"$RPKI_CLIENT_BIN" \
-R -e "$SCRIPT_DIR/cir-rsync-wrapper" -j -c -p "$PARSER_WORKERS" \
"${TIME_ARGS[@]}" \
"${CLIENT_TAL_ARGS[@]}" \
-d "$CACHE_DIR" \
"$OUT_DIR/out" >"$OUT_DIR/stdout.log" 2>"$OUT_DIR/stderr.log"
python3 "$SCRIPT_DIR/normalize_rp_outputs.py" --kind rpki-client --input "$OUT_DIR/out" --vrps-out "$OUT_DIR/vrps.normalized.txt" --vaps-out "$OUT_DIR/vaps.normalized.txt"
python3 "$SCRIPT_DIR/summarize_replay.py" --rp rpki-client --out-dir "$OUT_DIR" --summary "$OUT_DIR/summary.json"
echo "done: $OUT_DIR"

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
def count_lines(path: Path) -> int:
if not path.is_file():
return 0
return sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip())
def parse_time(path: Path) -> dict[str, object]:
if not path.is_file():
return {}
text = path.read_text(encoding="utf-8", errors="replace")
elapsed = re.search(r"Elapsed \(wall clock\) time .*: (.+)", text)
rss = re.search(r"Maximum resident set size \(kbytes\): (\d+)", text)
return {
"elapsed": elapsed.group(1).strip() if elapsed else "",
"maxRssKb": int(rss.group(1)) if rss else 0,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--rp", required=True)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--summary", type=Path, required=True)
args = parser.parse_args()
summary = {
"rp": args.rp,
"vrps": count_lines(args.out_dir / "vrps.normalized.txt"),
"vaps": count_lines(args.out_dir / "vaps.normalized.txt"),
"time": parse_time(args.out_dir / "process-time.txt"),
"artifacts": sorted(path.name for path in args.out_dir.iterdir() if path.is_file()),
}
args.summary.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(args.summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,118 @@
# Local Repository Tree Replay Package
This package replays already prepared local RPKI repository trees with
Routinator and rpki-client.
It is intentionally independent from CIR:
- it does not read `.cir`;
- it does not read `repo-bytes.db`;
- it does not call `cir_materialize`;
- it does not generate a local repository tree.
The caller must prepare the local repository/cache tree before running these
scripts.
## Contents
```text
local-repo-replay-package/
scripts/
run_routinator_from_local_tree.sh
run_rpki_client_from_local_tree.sh
run_dual_local_tree_replay.sh
prepare_tals.py
cir-rsync-wrapper
cir-local-link-sync.py
normalize_rp_outputs.py
compare_normalized_sets.py
summarize_replay.py
docs/
input_tree_requirements.md
offline_replay_limits.md
output_files.md
examples/
routinator_example.sh
rpki_client_example.sh
dual_compare_example.sh
env.example
```
## Routinator replay
```bash
./scripts/run_routinator_from_local_tree.sh \
--routinator-bin /opt/routinator/target/release/routinator \
--mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/routinator \
--enable-aspa
```
The script uses `--disable-rrdp`, `--rsync-command ./scripts/cir-rsync-wrapper`,
and the local mirror root to satisfy rsync fetches from the local filesystem.
The wrapper name is historical; in this package it is only a generic
`rsync://` to local-path mapper.
If `--validation-time` is needed for Routinator, set `FAKETIME_LIB` to a working
libfaketime shared library. Otherwise Routinator validates at wall-clock time.
On Ubuntu, install and use faketime like this:
```bash
sudo apt-get install -y libfaketime
export FAKETIME_LIB=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1
./scripts/run_routinator_from_local_tree.sh \
--routinator-bin /opt/routinator/target/release/routinator \
--mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/routinator \
--validation-time 2026-05-14T06:48:00Z \
--enable-aspa
```
Without `FAKETIME_LIB`, old local trees can produce empty or smaller output
because Routinator validates manifests and CRLs against current wall-clock time.
## rpki-client replay
```bash
./scripts/run_rpki_client_from_local_tree.sh \
--rpki-client-bin /opt/rpki-client/src/rpki-client \
--mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/rpki-client \
--parser-workers 4
```
The script uses `rpki-client -R -e ./scripts/cir-rsync-wrapper` so RRDP is
disabled and rsync fetches are served from the local mirror. `--cache-dir` is an
optional working cache directory used by rpki-client during this local replay.
## Dual replay
```bash
./scripts/run_dual_local_tree_replay.sh \
--routinator-bin /opt/routinator/target/release/routinator \
--routinator-mirror-root /data/replay/mirror \
--rpki-client-bin /opt/rpki-client/src/rpki-client \
--rpki-client-mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/dual
```
If `--validation-time` is passed to dual replay, remember to export
`FAKETIME_LIB` first so Routinator and rpki-client use the same logical
validation time.
## Outputs
Each run writes normalized output:
- `vrps.normalized.txt`
- `vaps.normalized.txt`
- `summary.json`
- raw RP output and logs
- `process-time.txt`
See `docs/output_files.md`.

View File

@ -0,0 +1,39 @@
# Input Tree Requirements
The input tree is not part of this package. The caller must prepare it before
running replay.
## Mirror root
The mirror root must map rsync URIs to local paths:
```text
rsync://rpki.example.net/repo/a/b/c.roa
=> <mirror-root>/rpki.example.net/repo/a/b/c.roa
```
The tree must contain all objects needed by the selected TALs: TA certificates,
manifests, CRLs, ROAs, ASPAs, router certs, and child CA certificates.
Both Routinator and rpki-client scripts consume this same mirror root through a
local rsync command wrapper.
## rpki-client working cache
For rpki-client replay, `--cache-dir` is only rpki-client's working cache
directory for this local run. It is not the input dataset. The authoritative
input is `--mirror-root`.
## TALs
Provide either `--tal-dir <dir>` or repeated `--tal <file>`.
The scripts prepare a replay-local TAL copy that prefers `rsync://` TA
certificate URIs. This prevents a TAL with an HTTPS URI listed first from
escaping to the network during local replay. The TAL set should match the local
tree. Mixing a tree from one run with different TALs may produce meaningless
differences.
## No generation
This package does not generate the tree and does not repair missing objects.

View File

@ -0,0 +1,38 @@
# Offline Replay Limits
## Routinator
The Routinator script disables RRDP and uses an rsync command wrapper to map
rsync URLs to local paths. It still runs Routinator's normal validation logic.
If the local mirror does not contain required objects, validation can fail or
produce fewer outputs.
## rpki-client
The rpki-client script uses `-R` to disable RRDP and `-e` to point rsync at the
local mapper. rpki-client still builds its normal working cache, but every
rsync source is rewritten to the local mirror.
If the mirror was incomplete or produced by a different TAL set, replay results
may differ from the original run.
## Validation time
rpki-client supports `-P <posix-seconds>`. Routinator does not expose the same
simple command-line evaluation-time option in the tested version; if `FAKETIME_LIB`
is configured, the script can run Routinator under faketime. Without
`FAKETIME_LIB`, `--validation-time` is intentionally ignored for Routinator and
current wall-clock validation can reject stale manifests or CRLs.
Ubuntu example:
```bash
sudo apt-get install -y libfaketime
export FAKETIME_LIB=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1
```
The script sets `TZ=UTC` and converts RFC3339 validation time to libfaketime
absolute UTC format, for example `2026-05-14T06:48:00Z` becomes
`@2026-05-14 06:48:00`. Setting `TZ=UTC` is required because libfaketime parses
absolute timestamps in the process timezone.

View File

@ -0,0 +1,16 @@
# Output Files
Each replay output directory can contain:
- `vrps.normalized.txt`: one normalized VRP per line.
- `vaps.normalized.txt`: one normalized VAP/ASPA per line.
- `summary.json`: counts and resource summary.
- `process-time.txt`: `/usr/bin/time -v` output.
- RP-specific raw output:
- Routinator: `routinator.json`, `vrps.csv`.
- rpki-client: `out/json`, `out/csv`, and other native files.
- logs:
- `stdout.log` / `stderr.log` for rpki-client.
- `update.log`, `vrps.log`, `json.log` for Routinator.
Dual replay additionally writes `compare-summary.json`.

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/run_dual_local_tree_replay.sh \
--routinator-bin "${ROUTINATOR_BIN:-/opt/routinator/target/release/routinator}" \
--routinator-mirror-root "${ROUTINATOR_MIRROR_ROOT:-/data/replay/mirror}" \
--rpki-client-bin "${RPKI_CLIENT_BIN:-/opt/rpki-client/src/rpki-client}" \
--rpki-client-mirror-root "${RPKI_CLIENT_MIRROR_ROOT:-/data/replay/mirror}" \
--rpki-client-cache-dir "${RPKI_CLIENT_CACHE_DIR:-/data/replay/work/rpki-client-cache}" \
--tal-dir "${TAL_DIR:-/data/replay/tals}" \
--out-dir "${OUT_DIR:-/data/replay/out/dual}"

View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/run_routinator_from_local_tree.sh \
--routinator-bin "${ROUTINATOR_BIN:-/opt/routinator/target/release/routinator}" \
--mirror-root "${MIRROR_ROOT:-/data/replay/mirror}" \
--tal-dir "${TAL_DIR:-/data/replay/tals}" \
--out-dir "${OUT_DIR:-/data/replay/out/routinator}" \
--enable-aspa

View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/run_rpki_client_from_local_tree.sh \
--rpki-client-bin "${RPKI_CLIENT_BIN:-/opt/rpki-client/src/rpki-client}" \
--mirror-root "${MIRROR_ROOT:-/data/replay/mirror}" \
--cache-dir "${RPKI_CLIENT_CACHE_DIR:-/data/replay/work/rpki-client-cache}" \
--tal-dir "${TAL_DIR:-/data/replay/tals}" \
--out-dir "${OUT_DIR:-/data/replay/out/rpki-client}" \
--parser-workers "${PARSER_WORKERS:-4}"

View File

@ -0,0 +1,195 @@
#!/usr/bin/env bash
set -euo pipefail
# M2: Run per-sample decode+profile benchmark (Ours vs Routinator) on selected_der fixtures.
#
# Outputs:
# - specs/develop/20260224/data/m2_manifest_decode_profile_compare.csv
# - specs/develop/20260224/data/m2_raw.log
#
# Note: This script assumes Routinator benchmark repo exists at:
# /home/yuyr/dev/rust_playground/routinator/benchmark
#
# It also assumes fixtures exist under:
# rpki/tests/benchmark/selected_der/*.mft
# routinator/benchmark/fixtures/selected_der/*.mft
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RPKI_DIR="$ROOT_DIR"
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
DATE_TAG="${DATE_TAG:-20260224}"
OUT_DIR="$RPKI_DIR/../specs/develop/${DATE_TAG}/data"
OUT_CSV="${OUT_CSV:-$OUT_DIR/m2_manifest_decode_profile_compare.csv}"
OUT_RAW="${OUT_RAW:-$OUT_DIR/m2_raw.log}"
REPEATS="${REPEATS:-3}"
# Iterations / warmups (kept moderate for interactive iteration).
ITER_SMALL="${ITER_SMALL:-20000}"
ITER_MEDIUM="${ITER_MEDIUM:-20000}"
ITER_LARGE="${ITER_LARGE:-20000}"
ITER_XLARGE="${ITER_XLARGE:-2000}"
WARM_SMALL="${WARM_SMALL:-2000}"
WARM_MEDIUM="${WARM_MEDIUM:-2000}"
WARM_LARGE="${WARM_LARGE:-2000}"
WARM_XLARGE="${WARM_XLARGE:-200}"
SAMPLES=(
small-01
small-02
medium-01
medium-02
large-01
large-02
xlarge-01
xlarge-02
)
mkdir -p "$OUT_DIR"
: > "$OUT_RAW"
echo "sample,bucket,manifest_file_count,ours_avg_ns_per_op,ours_ops_per_s,rout_avg_ns_per_op,rout_ops_per_s,ratio_ours_over_rout,iterations,repeats,warmup" > "$OUT_CSV"
echo "[1/3] Build ours benchmark (release)..." | tee -a "$OUT_RAW"
(cd "$OURS_BENCH_DIR" && cargo build --release -q)
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
echo "[2/3] Build routinator benchmark (release)..." | tee -a "$OUT_RAW"
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
taskset_prefix=""
if command -v taskset >/dev/null 2>&1; then
if [[ -n "${TASKSET_CPU:-}" ]]; then
taskset_prefix="taskset -c ${TASKSET_CPU}"
fi
fi
bucket_for() {
local s="$1"
case "$s" in
small-*) echo "small" ;;
medium-*) echo "medium" ;;
large-*) echo "large" ;;
xlarge-*) echo "xlarge" ;;
*) echo "unknown" ;;
esac
}
iters_for() {
local b="$1"
case "$b" in
small) echo "$ITER_SMALL" ;;
medium) echo "$ITER_MEDIUM" ;;
large) echo "$ITER_LARGE" ;;
xlarge) echo "$ITER_XLARGE" ;;
*) echo "$ITER_MEDIUM" ;;
esac
}
warm_for() {
local b="$1"
case "$b" in
small) echo "$WARM_SMALL" ;;
medium) echo "$WARM_MEDIUM" ;;
large) echo "$WARM_LARGE" ;;
xlarge) echo "$WARM_XLARGE" ;;
*) echo "$WARM_MEDIUM" ;;
esac
}
run_ours() {
local sample="$1"
local iters="$2"
local warm="$3"
local ours_fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
if [[ ! -f "$ours_fixture" ]]; then
echo "ours fixture not found: $ours_fixture" >&2
exit 1
fi
echo "### ours $sample" >> "$OUT_RAW"
local out
out=$($taskset_prefix "$OURS_BIN" --manifest "$ours_fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats "$REPEATS")
echo "$out" >> "$OUT_RAW"
local line
line=$(echo "$out" | rg "^\\| ${sample} \\|" | tail -n 1)
if [[ -z "${line:-}" ]]; then
echo "failed to parse ours output for $sample" >&2
exit 1
fi
# Expected final row: | sample | avg ns/op | ops/s | file count |
local avg ops cnt
avg=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$3); print $3}')
ops=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$4); print $4}')
cnt=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$5); print $5}')
echo "$avg,$ops,$cnt"
}
run_rout() {
local sample="$1"
local iters="$2"
local warm="$3"
local rout_fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
if [[ ! -f "$rout_fixture" ]]; then
echo "routinator fixture not found: $rout_fixture" >&2
exit 1
fi
echo "### routinator $sample" >> "$OUT_RAW"
local out
out=$(
$taskset_prefix "$ROUT_BIN" \
--target decode_only \
--manifest "$rout_fixture" \
--issuer "$ROUT_BENCH_DIR/fixtures/ta.cer" \
--iterations "$iters" \
--repeats "$REPEATS" \
--warmup-iterations "$warm" \
--strict false
)
echo "$out" >> "$OUT_RAW"
local avg_line cnt_line
avg_line=$(echo "$out" | rg "^ avg:")
cnt_line=$(echo "$out" | rg "^ manifest_file_count:")
local avg_ns ops_s cnt
avg_ns=$(echo "$avg_line" | awk '{print $2}')
ops_s=$(echo "$avg_line" | awk '{gsub(/[()]/,"",$4); print $4}')
cnt=$(echo "$cnt_line" | awk '{print $2}')
echo "$avg_ns,$ops_s,$cnt"
}
echo "[3/3] Run per-sample benchmarks..." | tee -a "$OUT_RAW"
for s in "${SAMPLES[@]}"; do
b=$(bucket_for "$s")
it=$(iters_for "$b")
warm=$(warm_for "$b")
IFS=, read -r ours_avg ours_ops ours_cnt < <(run_ours "$s" "$it" "$warm")
IFS=, read -r rout_avg rout_ops rout_cnt < <(run_rout "$s" "$it" "$warm")
if [[ "$ours_cnt" != "$rout_cnt" ]]; then
echo "WARNING: file count differs for $s (ours=$ours_cnt rout=$rout_cnt)" | tee -a "$OUT_RAW"
fi
ratio=$(python3 - <<PY
o=float("$ours_avg")
r=float("$rout_avg")
print(f"{(o/r):.4f}" if r != 0 else "inf")
PY
)
echo "$s,$b,$ours_cnt,$ours_avg,$ours_ops,$rout_avg,$rout_ops,$ratio,$it,$REPEATS,$warm" >> "$OUT_CSV"
echo >> "$OUT_RAW"
done
echo "Done."
echo "- CSV: $OUT_CSV"
echo "- Raw: $OUT_RAW"

View File

@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
# M3: Generate flamegraphs + top hotspots for Manifest decode+profile (Ours vs Routinator).
#
# Outputs under:
# specs/develop/20260224/flamegraph/
# specs/develop/20260224/hotspots/
# specs/develop/20260224/perf/
#
# Notes:
# - On WSL2, /usr/bin/perf is often a wrapper that fails. This script uses a real perf binary
# from /usr/lib/linux-tools/*/perf (if present).
# - Ours profiling uses perf + flamegraph --perfdata to avoid rebuilding the whole crate graph
# with RocksDB.
ROOT_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RPKI_DIR="$ROOT_REPO/rpki"
DATE_TAG="${DATE_TAG:-20260224}"
OUT_BASE="$ROOT_REPO/specs/develop/${DATE_TAG}"
OUT_FLAME="$OUT_BASE/flamegraph"
OUT_HOT="$OUT_BASE/hotspots"
OUT_PERF="$OUT_BASE/perf"
RUN_TAG="${RUN_TAG:-p2}"
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
ROUT_ISSUER="$ROUT_BENCH_DIR/fixtures/ta.cer"
PROFILE_HZ="${PROFILE_HZ:-99}"
mkdir -p "$OUT_FLAME" "$OUT_HOT" "$OUT_PERF"
PERF_WRAPPER_OUT="$(perf --version 2>&1 || true)"
PERF_REAL=""
if echo "${PERF_WRAPPER_OUT}" | grep -q "WARNING: perf not found for kernel"; then
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
else
PERF_REAL="$(command -v perf || true)"
fi
if [[ -z "${PERF_REAL}" ]]; then
echo "ERROR: usable perf binary not found (wrapper detected and no /usr/lib/linux-tools/*/perf)." >&2
exit 2
fi
SHIM_DIR="$RPKI_DIR/target/bench/tools"
mkdir -p "$SHIM_DIR"
cat > "$SHIM_DIR/perf" <<EOF
#!/usr/bin/env bash
exec "${PERF_REAL}" "\$@"
EOF
chmod +x "$SHIM_DIR/perf"
export PATH="$SHIM_DIR:$PATH"
echo "Using perf: $PERF_REAL"
echo "[1/3] Build ours benchmark with frame pointers..."
(cd "$OURS_BENCH_DIR" && RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release -q)
echo "[2/3] Build routinator benchmark (release)..."
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
taskset_prefix=""
if command -v taskset >/dev/null 2>&1; then
taskset_prefix="taskset -c 0"
fi
profile_ours() {
local sample="$1"
local iters="$2"
local warm="$3"
local fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
if [[ ! -f "$fixture" ]]; then
echo "ERROR: ours fixture not found: $fixture" >&2
exit 1
fi
local perfdata="$OUT_PERF/ours_${sample}_${RUN_TAG}.perf.data"
local svg="$OUT_FLAME/ours_${sample}_${RUN_TAG}.svg"
local tsv="$OUT_HOT/ours_${sample}_${RUN_TAG}.tsv"
echo "== ours $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
$taskset_prefix perf record -o "$perfdata" -F "$PROFILE_HZ" -g -- \
"$OURS_BIN" --manifest "$fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats 1 >/dev/null
flamegraph --perfdata "$perfdata" --output "$svg" --title "ours ${sample} ManifestObject::decode_der" --deterministic >/dev/null
perf report -i "$perfdata" --stdio --no-children --sort symbol --percent-limit 0.5 \
| awk '/^[[:space:]]*[0-9.]+%/ {pct=$1; sub(/%/,"",pct); $1=""; sub(/^[[:space:]]+/,""); print pct "\t" $0}' \
> "$tsv"
}
profile_routinator() {
local sample="$1"
local iters="$2"
local warm="$3"
local fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
if [[ ! -f "$fixture" ]]; then
echo "ERROR: routinator fixture not found: $fixture" >&2
exit 1
fi
local svg="$OUT_FLAME/routinator_${sample}_${RUN_TAG}.svg"
local tsv="$OUT_HOT/routinator_${sample}_${RUN_TAG}.tsv"
echo "== routinator $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
$taskset_prefix "$ROUT_BIN" \
--target decode_only \
--manifest "$fixture" \
--issuer "$ROUT_ISSUER" \
--iterations "$iters" \
--repeats 1 \
--warmup-iterations "$warm" \
--strict false \
--profile-hz "$PROFILE_HZ" \
--flamegraph "$svg" \
--hotspots "$tsv" \
>/dev/null
}
echo "[3/3] Profile samples..."
# Choose iterations so each capture runs ~10-20s serially.
profile_ours small-01 200000 0
profile_routinator small-01 200000 0
profile_ours large-02 50000 0
profile_routinator large-02 50000 0
profile_ours xlarge-02 5000 0
profile_routinator xlarge-02 5000 0
echo "Done."
echo "- Flamegraphs: $OUT_FLAME/"
echo "- Hotspots: $OUT_HOT/"
echo "- Perf data: $OUT_PERF/"

View File

@ -0,0 +1,97 @@
# Manual RRDP sync (APNIC-focused)
This directory contains **manual, command-line** scripts to reproduce the workflow described in:
- `specs/develop/20260226/apnic_rrdp_delta_analysis_after_manifest_revalidation_fix_20260227T022606Z.md`
They are meant for **hands-on validation / acceptance runs**, not for CI.
## Prerequisites
- Rust toolchain (`cargo`)
- `rsync` available on PATH (for rsync fallback/objects)
- Network access (RRDP over HTTPS)
## What the scripts do
### `full_sync.sh`
- Creates a fresh RocksDB directory
- Runs a **full serial** validation from a TAL URL (default: APNIC RFC7730 TAL)
- Writes:
- run log
- audit report JSON
- run meta JSON (includes durations + download_stats)
- short summary Markdown (includes durations + download_stats)
- RocksDB key statistics (`db_stats --exact`)
- RRDP legacy session/serial dump (`rrdp_state_dump --view legacy-state`)
### `delta_sync.sh`
- Copies an existing “baseline snapshot DB” to a new DB directory (so the baseline is not modified)
- Runs another validation against the copied DB (RRDP will prefer **delta** when available)
- Produces the same artifacts as `full_sync.sh`
- Additionally generates a Markdown **delta analysis** report by comparing:
- base vs delta report JSON
- base vs delta `rrdp_state_dump --view legacy-state` TSV
- and includes a **duration comparison** (base vs delta) if the base meta JSON is available
- delta meta JSON includes download_stats copied from delta report JSON
## Audit report fields (report.json)
The `rpki` binary writes an audit report JSON with:
- `format_version: 2`
- `downloads`: per-download RRDP/rsync events (URI, timestamps, duration, ok/fail, error, bytes, objects stats)
- `download_stats`: aggregate counters (by kind)
These are useful for diagnosing why a run is slow (e.g. RRDP snapshot vs delta vs rsync fallback).
The standalone `rrdp_state_dump` tool also supports `source`, `members`, `owners`, and `all` views.
The manual sync scripts intentionally call `--view legacy-state` so delta analysis keeps using a stable session/serial TSV format.
## Meta fields (meta.json)
The scripts generate `*_meta.json` next to `*_report.json` and include:
- `durations_secs`: wall-clock duration breakdown for the script steps
- `download_stats`: copied from `report_json.download_stats`
## Usage
Run from `rpki/`:
```bash
./scripts/manual_sync/full_sync.sh
```
After you have a baseline run, run delta against it:
```bash
./scripts/manual_sync/delta_sync.sh target/live/manual_sync/apnic_full_db_YYYYMMDDTHHMMSSZ \
target/live/manual_sync/apnic_full_report_YYYYMMDDTHHMMSSZ.json
```
If the baseline was produced by `full_sync.sh`, the delta script will auto-discover the base meta JSON
next to the base report (by replacing `_report.json` with `_meta.json`) and include base durations in
the delta analysis report.
## Configuration (env vars)
Both scripts accept overrides via env vars:
- `TAL_URL` (default: APNIC TAL URL)
- `HTTP_TIMEOUT_SECS` (default: 1800)
- `RSYNC_TIMEOUT_SECS` (default: 1800)
- `RSYNC_MIRROR_ROOT` (default: disabled; when set, passes `--rsync-mirror-root` to `rpki`)
- `VALIDATION_TIME` (RFC3339; default: now UTC)
- `OUT_DIR` (default: `rpki/target/live/manual_sync`)
- `RUN_NAME` (default: auto timestamped)
Example:
```bash
TAL_URL="https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal" \
HTTP_TIMEOUT_SECS=1800 RSYNC_TIMEOUT_SECS=1800 \
./scripts/manual_sync/full_sync.sh
```

542
scripts/manual_sync/delta_sync.sh Executable file
View File

@ -0,0 +1,542 @@
#!/usr/bin/env bash
set -euo pipefail
# Delta sync + validation starting from a baseline snapshot DB.
#
# This script:
# 1) Copies BASE_DB_DIR -> DELTA_DB_DIR (so baseline is not modified)
# 2) Runs rpki validation again (RRDP will prefer delta if available)
# 3) Writes artifacts + a markdown delta analysis report
#
# Usage:
# ./scripts/manual_sync/delta_sync.sh <base_db_dir> <base_report_json>
#
# Outputs under OUT_DIR (default: target/live/manual_sync):
# - *_delta_db_* copied RocksDB directory
# - *_delta_report_*.json audit report
# - *_delta_run_*.log stdout/stderr log (includes summary)
# - *_delta_db_stats_*.txt db_stats --exact output
# - *_delta_rrdp_state_*.tsv rrdp_state_dump --view legacy-state output
# - *_delta_analysis_*.md base vs delta comparison report
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BASE_DB_DIR="${1:-}"
BASE_REPORT_JSON="${2:-}"
if [[ -z "${BASE_DB_DIR}" || -z "${BASE_REPORT_JSON}" ]]; then
echo "Usage: $0 <base_db_dir> <base_report_json>" >&2
exit 2
fi
if [[ ! -d "${BASE_DB_DIR}" ]]; then
echo "ERROR: base_db_dir is not a directory: ${BASE_DB_DIR}" >&2
exit 2
fi
if [[ ! -f "${BASE_REPORT_JSON}" ]]; then
echo "ERROR: base_report_json does not exist: ${BASE_REPORT_JSON}" >&2
exit 2
fi
TAL_URL="${TAL_URL:-https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal}"
HTTP_TIMEOUT_SECS="${HTTP_TIMEOUT_SECS:-1800}"
RSYNC_TIMEOUT_SECS="${RSYNC_TIMEOUT_SECS:-1800}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/manual_sync}"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_delta_${TS}}"
DELTA_DB_DIR="${DELTA_DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
DELTA_REPORT_JSON="${DELTA_REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
DELTA_RUN_LOG="${DELTA_RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
BASE_DB_STATS_TXT="${BASE_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_base_db_stats.txt}"
DELTA_DB_STATS_TXT="${DELTA_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_delta_db_stats.txt}"
BASE_RRDP_STATE_TSV="${BASE_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_base_rrdp_state.tsv}"
DELTA_RRDP_STATE_TSV="${DELTA_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_delta_rrdp_state.tsv}"
DELTA_ANALYSIS_MD="${DELTA_ANALYSIS_MD:-$OUT_DIR/${RUN_NAME}_delta_analysis.md}"
DELTA_META_JSON="${DELTA_META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
# Best-effort base meta discovery (produced by `full_sync.sh`).
BASE_META_JSON="${BASE_META_JSON:-}"
if [[ -z "${BASE_META_JSON}" ]]; then
guess="${BASE_REPORT_JSON%_report.json}_meta.json"
if [[ -f "${guess}" ]]; then
BASE_META_JSON="${guess}"
fi
fi
echo "== rpki manual delta sync ==" >&2
echo "tal_url=$TAL_URL" >&2
echo "base_db=$BASE_DB_DIR" >&2
echo "base_report=$BASE_REPORT_JSON" >&2
echo "delta_db=$DELTA_DB_DIR" >&2
echo "delta_report=$DELTA_REPORT_JSON" >&2
echo "== copying base DB (baseline is not modified) ==" >&2
cp -a "$BASE_DB_DIR" "$DELTA_DB_DIR"
script_start_s="$(date +%s)"
run_start_s="$(date +%s)"
cmd=(cargo run --release --bin rpki -- \
--db "$DELTA_DB_DIR" \
--tal-url "$TAL_URL" \
--http-timeout-secs "$HTTP_TIMEOUT_SECS" \
--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS" \
--report-json "$DELTA_REPORT_JSON")
if [[ -n "${RSYNC_MIRROR_ROOT}" ]]; then
cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
fi
if [[ -n "${VALIDATION_TIME}" ]]; then
cmd+=(--validation-time "$VALIDATION_TIME")
fi
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$DELTA_RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
echo "== db_stats (exact) ==" >&2
db_stats_start_s="$(date +%s)"
cargo run --release --bin db_stats -- --db "$BASE_DB_DIR" --exact 2>&1 | tee "$BASE_DB_STATS_TXT" >/dev/null
cargo run --release --bin db_stats -- --db "$DELTA_DB_DIR" --exact 2>&1 | tee "$DELTA_DB_STATS_TXT" >/dev/null
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump (legacy-state) ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" --view legacy-state >"$BASE_RRDP_STATE_TSV"
cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" --view legacy-state >"$DELTA_RRDP_STATE_TSV"
state_end_s="$(date +%s)"
state_duration_s="$((state_end_s - state_start_s))"
script_end_s="$(date +%s)"
total_duration_s="$((script_end_s - script_start_s))"
echo "== delta analysis report ==" >&2
TAL_URL="$TAL_URL" \
BASE_DB_DIR="$BASE_DB_DIR" \
DELTA_DB_DIR="$DELTA_DB_DIR" \
DELTA_RUN_LOG="$DELTA_RUN_LOG" \
VALIDATION_TIME_ARG="$VALIDATION_TIME" \
HTTP_TIMEOUT_SECS="$HTTP_TIMEOUT_SECS" \
RSYNC_TIMEOUT_SECS="$RSYNC_TIMEOUT_SECS" \
RUN_DURATION_S="$run_duration_s" \
DB_STATS_DURATION_S="$db_stats_duration_s" \
STATE_DURATION_S="$state_duration_s" \
TOTAL_DURATION_S="$total_duration_s" \
python3 - "$BASE_REPORT_JSON" "$DELTA_REPORT_JSON" "$BASE_RRDP_STATE_TSV" "$DELTA_RRDP_STATE_TSV" \
"$BASE_DB_STATS_TXT" "$DELTA_DB_STATS_TXT" "$BASE_META_JSON" "$DELTA_META_JSON" "$DELTA_ANALYSIS_MD" <<'PY'
import json
import os
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
base_report_path = Path(sys.argv[1])
delta_report_path = Path(sys.argv[2])
base_state_path = Path(sys.argv[3])
delta_state_path = Path(sys.argv[4])
base_db_stats_path = Path(sys.argv[5])
delta_db_stats_path = Path(sys.argv[6])
base_meta_path_s = sys.argv[7]
delta_meta_path = Path(sys.argv[8])
out_md_path = Path(sys.argv[9])
def load_json(p: Path):
s = p.read_text(encoding="utf-8")
try:
return json.loads(s)
except json.JSONDecodeError:
# Backwards-compat / robustness: tolerate accidental literal trailing "\\n".
s2 = s.strip()
if s2.endswith("\\n"):
s2 = s2[:-2].rstrip()
return json.loads(s2)
def load_optional_json(path_s: str):
if not path_s:
return None
p = Path(path_s)
if not p.exists():
return None
return load_json(p)
def parse_rrdp_state_tsv(p: Path):
# format from `rrdp_state_dump --view legacy-state`:
# [legacy-state]
# notify_uri serial session_id
# <notify_uri> <serial> <session_id>
out = {}
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("["):
continue
if line == "notify_uri serial session_id":
continue
parts = line.split(" ")
if len(parts) != 3:
raise SystemExit(f"invalid rrdp_state_dump line in {p}: {line!r}")
uri, serial, session = parts
out[uri] = (int(serial), session)
return out
def parse_db_stats(p: Path):
# lines: key=value
out = {}
for line in p.read_text(encoding="utf-8").splitlines():
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip()
if v.isdigit():
out[k] = int(v)
else:
out[k] = v
return out
def warnings_total(rep: dict) -> int:
return len(rep["tree"]["warnings"]) + sum(len(pp["warnings"]) for pp in rep["publication_points"])
def report_summary(rep: dict) -> dict:
return {
"validation_time": rep["meta"]["validation_time_rfc3339_utc"],
"publication_points_processed": rep["tree"]["instances_processed"],
"publication_points_failed": rep["tree"]["instances_failed"],
"rrdp_repos_unique": len({pp.get("rrdp_notification_uri") for pp in rep["publication_points"] if pp.get("rrdp_notification_uri")}),
"vrps": len(rep["vrps"]),
"aspas": len(rep["aspas"]),
"audit_publication_points": len(rep["publication_points"]),
"warnings_total": warnings_total(rep),
}
def count_repo_sync_failed(rep: dict) -> int:
# Best-effort heuristic (we don't currently expose a structured counter in the audit report).
# Keep the match conservative to avoid false positives.
def is_repo_sync_failed(msg: str) -> bool:
m = msg.lower()
return "repo sync failed" in m or "rrdp fetch failed" in m or "rsync fetch failed" in m
n = 0
for w in rep["tree"]["warnings"]:
if is_repo_sync_failed(w.get("message", "")):
n += 1
for pp in rep["publication_points"]:
for w in pp.get("warnings", []):
if is_repo_sync_failed(w.get("message", "")):
n += 1
return n
def pp_manifest_sha(pp: dict) -> str:
# In our audit format, the first object is the manifest (synthetic entry) with sha256 of manifest_bytes.
for o in pp["objects"]:
if o["kind"] == "manifest":
return o["sha256_hex"]
return ""
def pp_objects_by_uri(rep: dict):
m = {}
for pp in rep["publication_points"]:
for o in pp["objects"]:
m[o["rsync_uri"]] = (o["sha256_hex"], o["kind"])
return m
def vrp_set(rep: dict):
return {(v["asn"], v["prefix"], v["max_length"]) for v in rep["vrps"]}
def rfc_refs_str(w: dict) -> str:
refs = w.get("rfc_refs") or []
return ", ".join(refs) if refs else ""
base = load_json(base_report_path)
delta = load_json(delta_report_path)
base_sum = report_summary(base)
delta_sum = report_summary(delta)
base_db = parse_db_stats(base_db_stats_path)
delta_db = parse_db_stats(delta_db_stats_path)
base_state = parse_rrdp_state_tsv(base_state_path)
delta_state = parse_rrdp_state_tsv(delta_state_path)
base_meta = load_optional_json(base_meta_path_s)
download_stats = delta.get("download_stats") or {}
delta_meta = {
"recorded_at_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"tal_url": os.environ["TAL_URL"],
"base_db_dir": os.environ["BASE_DB_DIR"],
"delta_db_dir": os.environ["DELTA_DB_DIR"],
"base_report_json": str(base_report_path),
"delta_report_json": str(delta_report_path),
"delta_run_log": os.environ["DELTA_RUN_LOG"],
"validation_time_arg": os.environ.get("VALIDATION_TIME_ARG",""),
"http_timeout_secs": int(os.environ["HTTP_TIMEOUT_SECS"]),
"rsync_timeout_secs": int(os.environ["RSYNC_TIMEOUT_SECS"]),
"durations_secs": {
"rpki_run": int(os.environ["RUN_DURATION_S"]),
"db_stats_exact": int(os.environ["DB_STATS_DURATION_S"]),
"rrdp_state_dump": int(os.environ["STATE_DURATION_S"]),
"total_script": int(os.environ["TOTAL_DURATION_S"]),
},
"download_stats": download_stats,
}
delta_meta_path.write_text(json.dumps(delta_meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# RRDP state changes
serial_changed = 0
session_changed = 0
serial_deltas = []
for uri, (old_serial, old_sess) in base_state.items():
if uri not in delta_state:
continue
new_serial, new_sess = delta_state[uri]
if new_serial != old_serial:
serial_changed += 1
serial_deltas.append((uri, old_serial, new_serial, new_serial - old_serial))
if new_sess != old_sess:
session_changed += 1
serial_deltas.sort(key=lambda x: x[3], reverse=True)
# Publication point diffs
base_pp = {pp["manifest_rsync_uri"]: pp for pp in base["publication_points"]}
delta_pp = {pp["manifest_rsync_uri"]: pp for pp in delta["publication_points"]}
base_keys = set(base_pp.keys())
delta_keys = set(delta_pp.keys())
new_pp = sorted(delta_keys - base_keys)
missing_pp = sorted(base_keys - delta_keys)
updated_pp = 0
unchanged_pp = 0
for k in sorted(base_keys & delta_keys):
if pp_manifest_sha(base_pp[k]) != pp_manifest_sha(delta_pp[k]):
updated_pp += 1
else:
unchanged_pp += 1
# Cache usage + repo sync failure hints
def source_counts(rep: dict) -> Counter:
c = Counter()
for pp in rep["publication_points"]:
c[pp.get("source","")] += 1
return c
base_sources = source_counts(base)
delta_sources = source_counts(delta)
base_repo_sync_failed = count_repo_sync_failed(base)
delta_repo_sync_failed = count_repo_sync_failed(delta)
def cache_reason_counts(rep: dict) -> Counter:
c = Counter()
for pp in rep.get("publication_points", []):
if pp.get("source") != "vcir_current_instance":
continue
# Use warning messages as "reason". If missing, emit a fallback bucket.
ws = pp.get("warnings", [])
if not ws:
c["(no warnings recorded)"] += 1
continue
for w in ws:
msg = w.get("message", "").strip() or "(empty warning message)"
c[msg] += 1
return c
base_cache_reasons = cache_reason_counts(base)
delta_cache_reasons = cache_reason_counts(delta)
# Object change stats (by rsync URI, sha256)
base_obj = pp_objects_by_uri(base)
delta_obj = pp_objects_by_uri(delta)
kind_stats = {k: {"added": 0, "changed": 0, "removed": 0} for k in ["manifest","crl","certificate","roa","aspa","other"]}
all_uris = set(base_obj.keys()) | set(delta_obj.keys())
for uri in all_uris:
b = base_obj.get(uri)
d = delta_obj.get(uri)
if b is None and d is not None:
kind_stats[d[1]]["added"] += 1
elif b is not None and d is None:
kind_stats[b[1]]["removed"] += 1
else:
if b[0] != d[0]:
kind_stats[d[1]]["changed"] += 1
# VRP diff
base_v = vrp_set(base)
delta_v = vrp_set(delta)
added_v = delta_v - base_v
removed_v = base_v - delta_v
def fmt_db_stats(db: dict) -> str:
ordered = [
"mode",
"repository_view",
"raw_by_hash",
"vcir",
"rrdp_source",
"rrdp_source_member",
"rrdp_uri_owner",
"rrdp_state",
"raw_objects",
"rrdp_object_index",
"group_current_repository_view",
"group_current_validation_state",
"group_current_rrdp_state",
"group_legacy_compatibility",
"total",
"sst_files",
]
out = []
seen = set()
for k in ordered:
if k in db:
out.append(f"- `{k}={db[k]}`")
seen.add(k)
for k in sorted(set(db) - seen):
out.append(f"- `{k}={db[k]}`")
return "\n".join(out) if out else "_(missing db_stats keys)_"
lines = []
lines.append("# APNIC RRDP 增量同步验收manual_sync\n\n")
lines.append(f"时间戳:`{now}`UTC\n\n")
lines.append("## 复现信息\n\n")
lines.append(f"- base_report`{base_report_path}`\n")
lines.append(f"- delta_report`{delta_report_path}`\n")
lines.append(f"- base_db_stats`{base_db_stats_path}`\n")
lines.append(f"- delta_db_stats`{delta_db_stats_path}`\n")
lines.append(f"- base_rrdp_state`{base_state_path}`\n")
lines.append(f"- delta_rrdp_state`{delta_state_path}`\n\n")
lines.append("## 运行结果概览\n\n")
lines.append("| metric | base | delta |\n")
lines.append("|---|---:|---:|\n")
for k in [
"validation_time",
"publication_points_processed",
"publication_points_failed",
"rrdp_repos_unique",
"vrps",
"aspas",
"audit_publication_points",
"warnings_total",
]:
lines.append(f"| {k} | {base_sum[k]} | {delta_sum[k]} |\n")
lines.append("\n")
def dur(meta: dict | None, key: str):
if not meta:
return None
return (meta.get("durations_secs") or {}).get(key)
base_rpki_run = dur(base_meta, "rpki_run")
delta_rpki_run = delta_meta["durations_secs"]["rpki_run"]
base_total = dur(base_meta, "total_script")
delta_total = delta_meta["durations_secs"]["total_script"]
lines.append("## 持续时间seconds\n\n")
lines.append("| step | base | delta |\n")
lines.append("|---|---:|---:|\n")
lines.append(f"| rpki_run | {base_rpki_run if base_rpki_run is not None else 'unknown'} | {delta_rpki_run} |\n")
lines.append(f"| total_script | {base_total if base_total is not None else 'unknown'} | {delta_total} |\n")
lines.append("\n")
if base_meta is None and base_meta_path_s:
lines.append(f"> 注:未能读取 base meta`{base_meta_path_s}`(文件不存在或不可读)。建议用 `full_sync.sh` 生成 baseline 以获得 base 时长对比。\n\n")
lines.append("RocksDB KV`db_stats --exact`\n\n")
lines.append("### 基线base\n\n")
lines.append(fmt_db_stats(base_db) + "\n\n")
lines.append("### 增量delta\n\n")
lines.append(fmt_db_stats(delta_db) + "\n\n")
lines.append("## RRDP 增量是否发生(基于 `rrdp_state` 变化)\n\n")
lines.append(f"- repo_total(base)={len(base_state)}\n")
lines.append(f"- repo_total(delta)={len(delta_state)}\n")
lines.append(f"- serial_changed={serial_changed}\n")
lines.append(f"- session_changed={session_changed}\n\n")
if serial_deltas:
lines.append("serial 增长最大的 10 个 RRDP repoold -> new\n\n")
for uri, old, new, diff in serial_deltas[:10]:
lines.append(f"- `{uri}``{old} -> {new}`+{diff}\n")
lines.append("\n")
lines.append("## 发布点Publication Point变化统计\n\n")
lines.append("以 `manifest_rsync_uri` 作为发布点 key对比 base vs delta\n\n")
lines.append(f"- base PP`{len(base_keys)}`\n")
lines.append(f"- delta PP`{len(delta_keys)}`\n")
lines.append(f"- `new_pp={len(new_pp)}`\n")
lines.append(f"- `missing_pp={len(missing_pp)}`\n")
lines.append(f"- `updated_pp={updated_pp}`\n")
lines.append(f"- `unchanged_pp={unchanged_pp}`\n\n")
lines.append("> 注:`new_pp/missing_pp/updated_pp` 会混入“遍历范围变化”的影响(例如 validation_time 不同、或 base 中存在更多失败 PP。\n\n")
lines.append("## fail fetch / VCIR 当前实例缓存复用情况\n\n")
lines.append(f"- repo sync failed启发式warning contains 'repo sync failed'/'rrdp fetch failed'/'rsync fetch failed'\n")
lines.append(f" - base`{base_repo_sync_failed}`\n")
lines.append(f" - delta`{delta_repo_sync_failed}`\n\n")
lines.append("- source 计数(按 `PublicationPointAudit.source`\n\n")
lines.append(f" - base`{dict(base_sources)}`\n")
lines.append(f" - delta`{dict(delta_sources)}`\n\n")
def render_cache_reasons(title: str, c: Counter) -> str:
if not c:
return f"{title}`0`(未使用 VCIR 当前实例缓存复用)\n\n"
lines = []
total = sum(c.values())
lines.append(f"{title}`{total}`\n\n")
lines.append("Top reasons按 warning message 聚合,可能一条 PP 有多条 warning\n\n")
for msg, n in c.most_common(10):
lines.append(f"- `{n}` × {msg}\n")
lines.append("\n")
return "".join(lines)
lines.append(render_cache_reasons("- base `source=vcir_current_instance`", base_cache_reasons))
lines.append(render_cache_reasons("- delta `source=vcir_current_instance`", delta_cache_reasons))
lines.append("## 文件变更统计(按对象类型)\n\n")
lines.append("按 `ObjectAuditEntry.sha256_hex` 对比(同一 rsync URI 前后 hash 变化记为 `~changed`\n\n")
lines.append("| kind | added | changed | removed |\n")
lines.append("|---|---:|---:|---:|\n")
for kind in ["manifest","crl","certificate","roa","aspa","other"]:
st = kind_stats[kind]
lines.append(f"| {kind} | {st['added']} | {st['changed']} | {st['removed']} |\n")
lines.append("\n")
lines.append("## VRP 影响(去重后集合 diff\n\n")
lines.append("以 `(asn, prefix, max_length)` 为 key\n\n")
lines.append(f"- base unique VRP`{len(base_v)}`\n")
lines.append(f"- delta unique VRP`{len(delta_v)}`\n")
lines.append(f"- `added={len(added_v)}`\n")
lines.append(f"- `removed={len(removed_v)}`\n")
lines.append(f"- `net={len(added_v) - len(removed_v)}`\n\n")
out_md_path.write_text("".join(lines), encoding="utf-8")
print(out_md_path)
PY
echo "== done ==" >&2
echo "artifacts:" >&2
echo "- delta db: $DELTA_DB_DIR" >&2
echo "- delta report: $DELTA_REPORT_JSON" >&2
echo "- delta run log: $DELTA_RUN_LOG" >&2
echo "- delta meta json: $DELTA_META_JSON" >&2
echo "- analysis md: $DELTA_ANALYSIS_MD" >&2
echo "- base state tsv: $BASE_RRDP_STATE_TSV" >&2
echo "- delta state tsv: $DELTA_RRDP_STATE_TSV" >&2

189
scripts/manual_sync/full_sync.sh Executable file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
# Full sync + validation from a TAL URL (default: APNIC).
#
# Produces artifacts under OUT_DIR (default: target/live/manual_sync):
# - *_db_* RocksDB directory
# - *_report_*.json audit report
# - *_run_*.log stdout/stderr log (includes summary)
# - *_db_stats_*.txt db_stats --exact output
# - *_rrdp_state_*.tsv rrdp_state_dump --view legacy-state output
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
TAL_URL="${TAL_URL:-https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal}"
HTTP_TIMEOUT_SECS="${HTTP_TIMEOUT_SECS:-1800}"
RSYNC_TIMEOUT_SECS="${RSYNC_TIMEOUT_SECS:-1800}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/manual_sync}"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_full_${TS}}"
DB_DIR="${DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
REPORT_JSON="${REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
RUN_LOG="${RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
DB_STATS_TXT="${DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_db_stats.txt}"
RRDP_STATE_TSV="${RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_rrdp_state.tsv}"
RUN_META_JSON="${RUN_META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_summary.md}"
echo "== rpki manual full sync ==" >&2
echo "tal_url=$TAL_URL" >&2
echo "db=$DB_DIR" >&2
echo "report_json=$REPORT_JSON" >&2
echo "out_dir=$OUT_DIR" >&2
cmd=(cargo run --release --bin rpki -- \
--db "$DB_DIR" \
--tal-url "$TAL_URL" \
--http-timeout-secs "$HTTP_TIMEOUT_SECS" \
--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS" \
--report-json "$REPORT_JSON")
if [[ -n "${RSYNC_MIRROR_ROOT}" ]]; then
cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
fi
if [[ -n "${VALIDATION_TIME}" ]]; then
cmd+=(--validation-time "$VALIDATION_TIME")
fi
script_start_s="$(date +%s)"
run_start_s="$(date +%s)"
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
echo "== db_stats (exact) ==" >&2
db_stats_start_s="$(date +%s)"
cargo run --release --bin db_stats -- --db "$DB_DIR" --exact 2>&1 | tee "$DB_STATS_TXT" >/dev/null
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump (legacy-state) ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$DB_DIR" --view legacy-state >"$RRDP_STATE_TSV"
state_end_s="$(date +%s)"
state_duration_s="$((state_end_s - state_start_s))"
script_end_s="$(date +%s)"
total_duration_s="$((script_end_s - script_start_s))"
echo "== write run meta + summary ==" >&2
TAL_URL="$TAL_URL" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
HTTP_TIMEOUT_SECS="$HTTP_TIMEOUT_SECS" \
RSYNC_TIMEOUT_SECS="$RSYNC_TIMEOUT_SECS" \
VALIDATION_TIME_ARG="$VALIDATION_TIME" \
RUN_DURATION_S="$run_duration_s" \
DB_STATS_DURATION_S="$db_stats_duration_s" \
STATE_DURATION_S="$state_duration_s" \
TOTAL_DURATION_S="$total_duration_s" \
python3 - "$REPORT_JSON" "$RUN_META_JSON" "$SUMMARY_MD" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
report_path = Path(sys.argv[1])
meta_path = Path(sys.argv[2])
summary_path = Path(sys.argv[3])
rep = json.loads(report_path.read_text(encoding="utf-8"))
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
download_stats = rep.get("download_stats") or {}
meta = {
"recorded_at_utc": now,
"tal_url": os.environ["TAL_URL"],
"db_dir": os.environ["DB_DIR"],
"report_json": os.environ["REPORT_JSON"],
"run_log": os.environ["RUN_LOG"],
"validation_time_rfc3339_utc": rep["meta"]["validation_time_rfc3339_utc"],
"http_timeout_secs": int(os.environ["HTTP_TIMEOUT_SECS"]),
"rsync_timeout_secs": int(os.environ["RSYNC_TIMEOUT_SECS"]),
"validation_time_arg": os.environ.get("VALIDATION_TIME_ARG",""),
"durations_secs": {
"rpki_run": int(os.environ["RUN_DURATION_S"]),
"db_stats_exact": int(os.environ["DB_STATS_DURATION_S"]),
"rrdp_state_dump": int(os.environ["STATE_DURATION_S"]),
"total_script": int(os.environ["TOTAL_DURATION_S"]),
},
"counts": {
"publication_points_processed": rep["tree"]["instances_processed"],
"publication_points_failed": rep["tree"]["instances_failed"],
"vrps": len(rep["vrps"]),
"aspas": len(rep["aspas"]),
"audit_publication_points": len(rep["publication_points"]),
},
"download_stats": download_stats,
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
lines = []
lines.append("# Manual full sync summary\\n\\n")
lines.append(f"- recorded_at_utc: `{now}`\\n")
lines.append(f"- tal_url: `{meta['tal_url']}`\\n")
lines.append(f"- db: `{meta['db_dir']}`\\n")
lines.append(f"- report_json: `{meta['report_json']}`\\n")
lines.append(f"- validation_time: `{meta['validation_time_rfc3339_utc']}`\\n\\n")
lines.append("## Results\\n\\n")
lines.append("| metric | value |\\n")
lines.append("|---|---:|\\n")
for k in ["publication_points_processed","publication_points_failed","vrps","aspas","audit_publication_points"]:
lines.append(f"| {k} | {meta['counts'][k]} |\\n")
lines.append("\\n")
lines.append("## Durations (seconds)\\n\\n")
lines.append("| step | seconds |\\n")
lines.append("|---|---:|\\n")
for k,v in meta["durations_secs"].items():
lines.append(f"| {k} | {v} |\\n")
lines.append("\\n")
lines.append("## Download Stats\\n\\n")
lines.append("- raw events: `report_json.downloads`\\n")
lines.append("- aggregated: `report_json.download_stats` (copied into meta.json)\\n\\n")
def fmt_u(v):
if v is None:
return ""
return str(v)
by_kind = download_stats.get("by_kind") or {}
lines.append("| kind | ok | fail | duration_ms_total | bytes_total | objects_count_total | objects_bytes_total |\\n")
lines.append("|---|---:|---:|---:|---:|---:|---:|\\n")
for kind in sorted(by_kind.keys()):
st = by_kind[kind] or {}
lines.append(
f"| {kind} | {st.get('ok_total',0)} | {st.get('fail_total',0)} | {st.get('duration_ms_total',0)} | {fmt_u(st.get('bytes_total'))} | {fmt_u(st.get('objects_count_total'))} | {fmt_u(st.get('objects_bytes_total'))} |\\n"
)
lines.append("\\n")
summary_path.write_text("".join(lines), encoding="utf-8")
print(summary_path)
PY
echo "== done ==" >&2
echo "artifacts:" >&2
echo "- db: $DB_DIR" >&2
echo "- report: $REPORT_JSON" >&2
echo "- run log: $RUN_LOG" >&2
echo "- db stats: $DB_STATS_TXT" >&2
echo "- rrdp state: $RRDP_STATE_TSV" >&2
echo "- meta json: $RUN_META_JSON" >&2
echo "- summary md: $SUMMARY_MD" >&2

View File

@ -0,0 +1,268 @@
# Payload Replay Scripts
本目录提供基于本地 payload archive 的手工 replay 入口。
## `multi_rir_case_info.py`
用于从 multi-RIR bundle 中解析指定 `rir` 的输入路径、对照 CSV、fixture、以及 Routinator replay timing 基线。
示例:
```bash
python3 scripts/payload_replay/multi_rir_case_info.py \
--bundle-root ../../rpki/target/live/20260316-112341-multi-final3 \
--rir afrinic
```
也支持输出 shell 环境变量:
```bash
python3 scripts/payload_replay/multi_rir_case_info.py \
--bundle-root ../../rpki/target/live/20260316-112341-multi-final3 \
--rir afrinic \
--format env
```
## `run_multi_rir_replay_case.sh`
统一的 multi-RIR 入口。给定 `rir` 和模式后,它会自动选择该 RIR 的:
- snapshot/base replay 输入
- delta replay 输入
- 对照 CSV
- TAL / TA fixture
- trust anchor 名称
用法:
```bash
./scripts/payload_replay/run_multi_rir_replay_case.sh <rir> [describe|snapshot|delta|both]
```
示例:
```bash
./scripts/payload_replay/run_multi_rir_replay_case.sh afrinic describe
./scripts/payload_replay/run_multi_rir_replay_case.sh lacnic snapshot
./scripts/payload_replay/run_multi_rir_replay_case.sh arin delta
./scripts/payload_replay/run_multi_rir_replay_case.sh ripe both
```
脚本会自动:
- 从 multi-RIR bundle 中选择指定 RIR 的 snapshot/base 与 delta 输入
- 读取该 RIR 的 Routinator `base-replay` / `delta-replay` timing 基线
- 优先使用 `base-locks.json.validationTime``locks-delta.json.validationTime` 作为 replay `--validation-time`;若缺失才回退到 `timings/base-replay.json``timings/delta-replay.json``startedAt`
- 在 `target/live/multi_rir_replay_runs/<rir>/` 下生成:
- snapshot replay 产物
- delta replay 产物
- per-RIR 合并 case report含 correctness + timing compare
默认 bundle 根目录为:
- `../../rpki/target/live/20260316-112341-multi-final3`
也可以通过 `BUNDLE_ROOT` 覆盖。
## `run_apnic_snapshot_replay_profile.sh`
基于 multi-RIR bundle 中的 APNIC snapshot 输入,使用当前 replay 主流程执行一次带 `--analyze``--profile-cpu` 的离线 profile。
```bash
./scripts/payload_replay/run_apnic_snapshot_replay_profile.sh
```
作用:
- 使用 `APNIC` 的 snapshot/base replay 输入
- 自动开启:
- `--analyze`
- `--profile-cpu`
- 自动记录:
- replay wall-clock 时长
- Routinator baseline (`base-replay`)
- analyze 目录路径
- 生成:
- `report.json`
- `run.log`
- `meta.json`
- `summary.md`
- 以及 `target/live/analyze/<timestamp>/` 下的:
- `timing.json`
- `flamegraph.svg`
- `pprof.pb.gz`
支持:
- `DRY_RUN=1`:只打印命令,不真正执行
- `MAX_DEPTH` / `MAX_INSTANCES`:用于限定 replay 范围
- `PROFILE_RUN_ROOT`:覆盖 wrapper 产物输出目录
## `run_apnic_replay.sh`
默认使用:
- `tests/fixtures/tal/apnic-rfc7730-https.tal`
- `tests/fixtures/ta/apnic-ta.cer`
- `target/live/payload_replay/payload-archive`
- `target/live/payload_replay/locks.json`
运行:
```bash
./scripts/payload_replay/run_apnic_replay.sh
```
产物默认输出到:
- `target/live/payload_replay_runs/`
包含:
- replay DB 目录
- `report.json`
- `run.log`
- `meta.json`
- `summary.md`
## 环境变量
可覆盖:
- `TAL_PATH`
- `TA_PATH`
- `PAYLOAD_REPLAY_ARCHIVE`
- `PAYLOAD_REPLAY_LOCKS`
- `VALIDATION_TIME`
- `MAX_DEPTH`
- `MAX_INSTANCES`
- `OUT_DIR`
- `RUN_NAME`
- `DB_DIR`
- `REPORT_JSON`
- `RUN_LOG`
- `META_JSON`
- `SUMMARY_MD`
## 说明
- 该脚本依赖 `rpki` CLI 已支持:
- `--payload-replay-archive`
- `--payload-replay-locks`
- replay 模式必须搭配离线 TAL/TA 输入,不会去访问真实 RRDP / rsync 网络源。
## `report_to_routinator_csv.py`
`rpki` 生成的 `report.json` 转成 Routinator 风格的 VRP CSV
```bash
python3 scripts/payload_replay/report_to_routinator_csv.py \
--report target/live/payload_replay_runs/<run>_report.json \
--out target/live/payload_replay_runs/<run>_vrps.csv \
--trust-anchor apnic
```
输出列为:
- `ASN`
- `IP Prefix`
- `Max Length`
- `Trust Anchor`
## `compare_with_routinator_record.sh`
把 ours 生成的 VRP CSV 与 Routinator 的 `record.csv` 做对比:
```bash
./scripts/payload_replay/compare_with_routinator_record.sh \
target/live/payload_replay_runs/<run>_vrps.csv \
target/live/payload_replay/record.csv
```
会产出:
- compare summary Markdown
- `only_in_ours.csv`
- `only_in_record.csv`
## `run_apnic_replay.sh` 现有额外产物
脚本现在除了 `report/meta/summary`,还会额外生成:
- `vrps.csv`
- 若 `ROUTINATOR_RECORD_CSV` 存在,则生成:
- compare summary
- `only_in_ours.csv`
- `only_in_record.csv`
## `run_apnic_delta_replay.sh`
使用 APNIC delta demo 数据集运行 base + delta replay
```bash
./scripts/payload_replay/run_apnic_delta_replay.sh
```
默认输入:
- `target/live/apnic_delta_demo/20260315-170223-autoplay/base-payload-archive`
- `target/live/apnic_delta_demo/20260315-170223-autoplay/base-locks.json`
- `target/live/apnic_delta_demo/20260315-170223-autoplay/payload-delta-archive`
- `target/live/apnic_delta_demo/20260315-170223-autoplay/locks-delta.json`
- `tests/fixtures/tal/apnic-rfc7730-https.tal`
- `tests/fixtures/ta/apnic-ta.cer`
输出目录默认:`target/live/payload_delta_replay_runs/`
## `run_apnic_delta_replay.sh` compare outputs
脚本现在在 delta replay 结束后还会额外生成:
- `vrps.csv`
- compare summary Markdown
- `only_in_ours.csv`
- `only_in_record.csv`
默认 compare 输入是:
- `target/live/apnic_delta_demo/20260315-170223-autoplay/record-delta.csv`
也可以通过环境变量覆盖:
- `TRUST_ANCHOR`
- `ROUTINATOR_RECORD_CSV`
- `VRPS_CSV`
- `COMPARE_SUMMARY_MD`
- `ONLY_IN_OURS_CSV`
- `ONLY_IN_RECORD_CSV`
## `write_multi_rir_case_report.py`
把某个 RIR 的 snapshot replay 与 delta replay 的 `meta.json`、compare summary 以及 Routinator timing 基线合并成一个 per-RIR Markdown/JSON 报告。
该脚本通常由 `run_multi_rir_replay_case.sh <rir> both` 自动调用。
## `run_multi_rir_replay_suite.sh`
顺序执行 5 个 RIR或环境变量 `RIRS` 指定的子集)的 `both` 模式,并最终生成 multi-RIR 汇总报告。
```bash
./scripts/payload_replay/run_multi_rir_replay_suite.sh
```
可覆盖环境变量:
- `BUNDLE_ROOT`
- `SUITE_OUT_DIR`
- `RIRS`
最终输出:
- `<suite_out_dir>/<rir>/<rir>_case_report.md`
- `<suite_out_dir>/multi_rir_summary.md`
- `<suite_out_dir>/multi_rir_summary.json`
## `write_multi_rir_summary.py`
汇总 5 个 RIR 的 per-RIR case report生成 correctness + timing 总表与几何平均比值。

View File

@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 || $# -gt 5 ]]; then
echo "Usage: $0 <ours.csv> <record.csv> [summary.md] [only_in_ours.csv] [only_in_record.csv]" >&2
exit 2
fi
OURS_CSV="$1"
RECORD_CSV="$2"
SUMMARY_MD="${3:-}"
ONLY_IN_OURS_CSV="${4:-}"
ONLY_IN_RECORD_CSV="${5:-}"
if [[ -z "$SUMMARY_MD" ]]; then
SUMMARY_MD="$(dirname "$OURS_CSV")/$(basename "$OURS_CSV" .csv)_vs_routinator_summary.md"
fi
if [[ -z "$ONLY_IN_OURS_CSV" ]]; then
ONLY_IN_OURS_CSV="$(dirname "$OURS_CSV")/$(basename "$OURS_CSV" .csv)_only_in_ours.csv"
fi
if [[ -z "$ONLY_IN_RECORD_CSV" ]]; then
ONLY_IN_RECORD_CSV="$(dirname "$OURS_CSV")/$(basename "$OURS_CSV" .csv)_only_in_record.csv"
fi
python3 - "$OURS_CSV" "$RECORD_CSV" "$SUMMARY_MD" "$ONLY_IN_OURS_CSV" "$ONLY_IN_RECORD_CSV" <<'PY'
import csv
import ipaddress
import sys
from pathlib import Path
ours_csv = Path(sys.argv[1])
record_csv = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
only_in_ours_csv = Path(sys.argv[4])
only_in_record_csv = Path(sys.argv[5])
def normalize_row(row: dict):
asn = row["ASN"].strip().upper()
prefix = row["IP Prefix"].strip()
max_len = str(int(row["Max Length"]))
ta = row["Trust Anchor"].strip()
network = ipaddress.ip_network(prefix, strict=False)
return {
"ASN": asn,
"IP Prefix": str(network),
"Max Length": max_len,
"Trust Anchor": ta,
}
def read_rows(path: Path):
with path.open(encoding="utf-8", newline="") as f:
rows = [normalize_row(r) for r in csv.DictReader(f)]
return rows
def row_key(row: dict):
network = ipaddress.ip_network(row["IP Prefix"], strict=False)
return (
row["ASN"],
network.version,
int(network.network_address),
network.prefixlen,
int(row["Max Length"]),
row["Trust Anchor"],
)
def write_rows(path: Path, rows):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["ASN", "IP Prefix", "Max Length", "Trust Anchor"])
writer.writeheader()
for row in rows:
writer.writerow(row)
ours = read_rows(ours_csv)
record = read_rows(record_csv)
ours_map = {row_key(r): r for r in ours}
record_map = {row_key(r): r for r in record}
only_in_ours = [ours_map[k] for k in sorted(set(ours_map) - set(record_map))]
only_in_record = [record_map[k] for k in sorted(set(record_map) - set(ours_map))]
intersection = len(set(ours_map) & set(record_map))
write_rows(only_in_ours_csv, only_in_ours)
write_rows(only_in_record_csv, only_in_record)
summary_md.parent.mkdir(parents=True, exist_ok=True)
summary = []
summary.append("# Replay vs Routinator VRP Compare\n\n")
summary.append(f"- ours_csv: `{ours_csv}`\n")
summary.append(f"- record_csv: `{record_csv}`\n")
summary.append(f"- only_in_ours_csv: `{only_in_ours_csv}`\n")
summary.append(f"- only_in_record_csv: `{only_in_record_csv}`\n\n")
summary.append("| metric | value |\n")
summary.append("|---|---:|\n")
summary.append(f"| ours_total | {len(ours_map)} |\n")
summary.append(f"| record_total | {len(record_map)} |\n")
summary.append(f"| intersection | {intersection} |\n")
summary.append(f"| only_in_ours | {len(only_in_ours)} |\n")
summary.append(f"| only_in_record | {len(only_in_record)} |\n")
summary_md.write_text("".join(summary), encoding="utf-8")
print(summary_md)
PY
echo "== compare complete ==" >&2
echo "- summary: $SUMMARY_MD" >&2
echo "- only_in_ours: $ONLY_IN_OURS_CSV" >&2
echo "- only_in_record: $ONLY_IN_RECORD_CSV" >&2

View File

@ -0,0 +1,172 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import shlex
import sys
from pathlib import Path
RIR_CONFIG = {
"afrinic": {
"tal": "tests/fixtures/tal/afrinic.tal",
"ta": "tests/fixtures/ta/afrinic-ta.cer",
"trust_anchor": "afrinic",
},
"apnic": {
"tal": "tests/fixtures/tal/apnic-rfc7730-https.tal",
"ta": "tests/fixtures/ta/apnic-ta.cer",
"trust_anchor": "apnic",
},
"arin": {
"tal": "tests/fixtures/tal/arin.tal",
"ta": "tests/fixtures/ta/arin-ta.cer",
"trust_anchor": "arin",
},
"lacnic": {
"tal": "tests/fixtures/tal/lacnic.tal",
"ta": "tests/fixtures/ta/lacnic-ta.cer",
"trust_anchor": "lacnic",
},
"ripe": {
"tal": "tests/fixtures/tal/ripe-ncc.tal",
"ta": "tests/fixtures/ta/ripe-ncc-ta.cer",
"trust_anchor": "ripe",
},
}
def default_repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def default_bundle_root(repo_root: Path) -> Path:
return (repo_root / "../../rpki/target/live/20260316-112341-multi-final3").resolve()
def require_path(path: Path, kind: str) -> Path:
if kind == "dir" and not path.is_dir():
raise SystemExit(f"missing directory: {path}")
if kind == "file" and not path.is_file():
raise SystemExit(f"missing file: {path}")
return path
def load_timing_summary(bundle_root: Path) -> dict:
timing_path = require_path(bundle_root / "timing-summary.json", "file")
return json.loads(timing_path.read_text(encoding="utf-8"))
def load_json(path: Path) -> dict:
return json.loads(require_path(path, "file").read_text(encoding="utf-8"))
def lock_validation_time(lock_obj: dict, fallback_started_at: str) -> str:
return lock_obj.get("validationTime") or lock_obj.get("validation_time") or fallback_started_at
def build_case(bundle_root: Path, repo_root: Path, rir: str) -> dict:
if rir not in RIR_CONFIG:
raise SystemExit(
f"unsupported rir: {rir}; expected one of: {', '.join(sorted(RIR_CONFIG))}"
)
rir_root = require_path(bundle_root / rir, "dir")
cfg = RIR_CONFIG[rir]
timing_summary = load_timing_summary(bundle_root)
if rir not in timing_summary:
raise SystemExit(f"timing-summary.json missing entry for rir: {rir}")
timing_entry = timing_summary[rir]
durations = timing_entry.get("durations") or {}
base_timing = require_path(rir_root / "timings" / "base-replay.json", "file")
delta_timing = require_path(rir_root / "timings" / "delta-replay.json", "file")
base_timing_obj = json.loads(base_timing.read_text(encoding="utf-8"))
delta_timing_obj = json.loads(delta_timing.read_text(encoding="utf-8"))
base_locks_obj = load_json(rir_root / "base-locks.json")
delta_locks_obj = load_json(rir_root / "locks-delta.json")
case = {
"bundle_root": str(bundle_root),
"repo_root": str(repo_root),
"rir": rir,
"trust_anchor": cfg["trust_anchor"],
"rir_root": str(rir_root),
"base_archive": str(require_path(rir_root / "base-payload-archive", "dir")),
"base_locks": str(require_path(rir_root / "base-locks.json", "file")),
"base_vrps_csv": str(require_path(rir_root / "base-vrps.csv", "file")),
"delta_archive": str(require_path(rir_root / "payload-delta-archive", "dir")),
"delta_locks": str(require_path(rir_root / "locks-delta.json", "file")),
"delta_record_csv": str(require_path(rir_root / "record-delta.csv", "file")),
"replay_delta_csv": str(require_path(rir_root / "replay-delta.csv", "file")),
"verification_json": str(require_path(rir_root / "verification.json", "file")),
"readme": str(require_path(rir_root / "README.md", "file")),
"timings_dir": str(require_path(rir_root / "timings", "dir")),
"base_timing_json": str(base_timing),
"delta_timing_json": str(delta_timing),
"tal_path": str(require_path(repo_root / cfg["tal"], "file")),
"ta_path": str(require_path(repo_root / cfg["ta"], "file")),
"validation_times": {
"snapshot": lock_validation_time(base_locks_obj, base_timing_obj["startedAt"]),
"delta": lock_validation_time(delta_locks_obj, delta_timing_obj["startedAt"]),
},
"timing_started_at": {
"snapshot_replay": base_timing_obj["startedAt"],
"delta_replay": delta_timing_obj["startedAt"],
},
"routinator_timings": {
"base_replay_seconds": float(durations["base-replay"]),
"delta_replay_seconds": float(durations["delta-replay"]),
},
}
return case
def emit_env(case: dict) -> str:
ordered = {
"BUNDLE_ROOT": case["bundle_root"],
"RIR": case["rir"],
"TRUST_ANCHOR": case["trust_anchor"],
"RIR_ROOT": case["rir_root"],
"TAL_PATH": case["tal_path"],
"TA_PATH": case["ta_path"],
"PAYLOAD_REPLAY_ARCHIVE": case["base_archive"],
"PAYLOAD_REPLAY_LOCKS": case["base_locks"],
"ROUTINATOR_BASE_RECORD_CSV": case["base_vrps_csv"],
"PAYLOAD_BASE_ARCHIVE": case["base_archive"],
"PAYLOAD_BASE_LOCKS": case["base_locks"],
"PAYLOAD_DELTA_ARCHIVE": case["delta_archive"],
"PAYLOAD_DELTA_LOCKS": case["delta_locks"],
"ROUTINATOR_DELTA_RECORD_CSV": case["delta_record_csv"],
"SNAPSHOT_VALIDATION_TIME": case["validation_times"]["snapshot"],
"DELTA_VALIDATION_TIME": case["validation_times"]["delta"],
"ROUTINATOR_BASE_REPLAY_SECONDS": str(case["routinator_timings"]["base_replay_seconds"]),
"ROUTINATOR_DELTA_REPLAY_SECONDS": str(case["routinator_timings"]["delta_replay_seconds"]),
}
return "\n".join(
f"export {key}={shlex.quote(value)}" for key, value in ordered.items()
)
def main() -> int:
parser = argparse.ArgumentParser(description="Resolve one RIR case inside a multi-RIR replay bundle")
parser.add_argument("--bundle-root", type=Path, default=None)
parser.add_argument("--repo-root", type=Path, default=None)
parser.add_argument("--rir", required=True, choices=sorted(RIR_CONFIG))
parser.add_argument("--format", choices=["json", "env"], default="json")
args = parser.parse_args()
repo_root = (args.repo_root or default_repo_root()).resolve()
bundle_root = (args.bundle_root or default_bundle_root(repo_root)).resolve()
case = build_case(bundle_root, repo_root, args.rir)
if args.format == "env":
print(emit_env(case))
else:
print(json.dumps(case, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,57 @@
#!/usr/bin/env python3
import argparse
import csv
import ipaddress
import json
from pathlib import Path
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Convert rpki report.json VRPs into Routinator-compatible CSV"
)
p.add_argument("--report", required=True, help="path to rpki report.json")
p.add_argument("--out", required=True, help="output CSV path")
p.add_argument(
"--trust-anchor",
default="unknown",
help="Trust Anchor column value (default: unknown)",
)
return p.parse_args()
def sort_key(vrp: dict):
network = ipaddress.ip_network(vrp["prefix"], strict=False)
return (
int(vrp["asn"]),
network.version,
int(network.network_address),
network.prefixlen,
int(vrp["max_length"]),
)
def main() -> int:
args = parse_args()
report = json.loads(Path(args.report).read_text(encoding="utf-8"))
vrps = list(report.get("vrps") or [])
vrps.sort(key=sort_key)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["ASN", "IP Prefix", "Max Length", "Trust Anchor"])
for vrp in vrps:
w.writerow([
f"AS{vrp['asn']}",
vrp["prefix"],
vrp["max_length"],
args.trust_anchor,
])
print(out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
DELTA_ROOT="${DELTA_ROOT:-$ROOT_DIR/target/live/apnic_delta_demo/20260315-170223-autoplay}"
TAL_PATH="${TAL_PATH:-$ROOT_DIR/tests/fixtures/tal/apnic-rfc7730-https.tal}"
TA_PATH="${TA_PATH:-$ROOT_DIR/tests/fixtures/ta/apnic-ta.cer}"
PAYLOAD_BASE_ARCHIVE="${PAYLOAD_BASE_ARCHIVE:-$DELTA_ROOT/base-payload-archive}"
PAYLOAD_BASE_LOCKS="${PAYLOAD_BASE_LOCKS:-$DELTA_ROOT/base-locks.json}"
PAYLOAD_DELTA_ARCHIVE="${PAYLOAD_DELTA_ARCHIVE:-$DELTA_ROOT/payload-delta-archive}"
PAYLOAD_DELTA_LOCKS="${PAYLOAD_DELTA_LOCKS:-$DELTA_ROOT/locks-delta.json}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
PAYLOAD_BASE_VALIDATION_TIME="${PAYLOAD_BASE_VALIDATION_TIME:-}"
TRUST_ANCHOR="${TRUST_ANCHOR:-apnic}"
ROUTINATOR_RECORD_CSV="${ROUTINATOR_RECORD_CSV:-$DELTA_ROOT/record-delta.csv}"
MAX_DEPTH="${MAX_DEPTH:-}"
MAX_INSTANCES="${MAX_INSTANCES:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/payload_delta_replay_runs}"
mkdir -p "$OUT_DIR"
if [[ -z "$PAYLOAD_BASE_VALIDATION_TIME" ]]; then
PAYLOAD_BASE_VALIDATION_TIME="$(python3 - "$PAYLOAD_BASE_LOCKS" <<'LOCKPY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text(encoding='utf-8'))
print(data.get('validationTime') or data.get('validation_time') or '')
LOCKPY
)"
fi
if [[ -z "$VALIDATION_TIME" ]]; then
VALIDATION_TIME="$(python3 - "$PAYLOAD_DELTA_LOCKS" <<'LOCKPY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text(encoding='utf-8'))
print(data.get('validationTime') or data.get('validation_time') or '2026-03-15T10:00:00Z')
LOCKPY
)"
fi
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_delta_replay_${TS}}"
DB_DIR="${DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
REPORT_JSON="${REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
RUN_LOG="${RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
META_JSON="${META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_summary.md}"
VRPS_CSV="${VRPS_CSV:-$OUT_DIR/${RUN_NAME}_vrps.csv}"
COMPARE_SUMMARY_MD="${COMPARE_SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_compare_summary.md}"
ONLY_IN_OURS_CSV="${ONLY_IN_OURS_CSV:-$OUT_DIR/${RUN_NAME}_only_in_ours.csv}"
ONLY_IN_RECORD_CSV="${ONLY_IN_RECORD_CSV:-$OUT_DIR/${RUN_NAME}_only_in_record.csv}"
cmd=(cargo run --release --bin rpki --
--db "$DB_DIR"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
--payload-base-archive "$PAYLOAD_BASE_ARCHIVE"
--payload-base-locks "$PAYLOAD_BASE_LOCKS"
--payload-delta-archive "$PAYLOAD_DELTA_ARCHIVE"
--payload-delta-locks "$PAYLOAD_DELTA_LOCKS"
--validation-time "$VALIDATION_TIME"
--report-json "$REPORT_JSON")
if [[ -n "$MAX_DEPTH" ]]; then
cmd+=(--max-depth "$MAX_DEPTH")
fi
if [[ -n "$MAX_INSTANCES" ]]; then
cmd+=(--max-instances "$MAX_INSTANCES")
fi
run_start_s="$(date +%s)"
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
PAYLOAD_BASE_ARCHIVE="$PAYLOAD_BASE_ARCHIVE" \
PAYLOAD_BASE_LOCKS="$PAYLOAD_BASE_LOCKS" \
PAYLOAD_DELTA_ARCHIVE="$PAYLOAD_DELTA_ARCHIVE" \
PAYLOAD_DELTA_LOCKS="$PAYLOAD_DELTA_LOCKS" \
PAYLOAD_BASE_VALIDATION_TIME="$PAYLOAD_BASE_VALIDATION_TIME" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
VALIDATION_TIME="$VALIDATION_TIME" \
RUN_DURATION_S="$run_duration_s" \
python3 - "$REPORT_JSON" "$META_JSON" "$SUMMARY_MD" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
report_path = Path(sys.argv[1])
meta_path = Path(sys.argv[2])
summary_path = Path(sys.argv[3])
rep = json.loads(report_path.read_text(encoding='utf-8'))
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
meta = {
'recorded_at_utc': now,
'payload_base_archive': os.environ['PAYLOAD_BASE_ARCHIVE'],
'payload_base_locks': os.environ['PAYLOAD_BASE_LOCKS'],
'payload_delta_archive': os.environ['PAYLOAD_DELTA_ARCHIVE'],
'payload_delta_locks': os.environ['PAYLOAD_DELTA_LOCKS'],
'db_dir': os.environ['DB_DIR'],
'report_json': os.environ['REPORT_JSON'],
'run_log': os.environ['RUN_LOG'],
'validation_time_arg': os.environ['VALIDATION_TIME'],
'base_validation_time_arg': os.environ.get('PAYLOAD_BASE_VALIDATION_TIME') or os.environ['VALIDATION_TIME'],
'durations_secs': {'rpki_run': int(os.environ['RUN_DURATION_S'])},
'counts': {
'publication_points_processed': rep['tree']['instances_processed'],
'publication_points_failed': rep['tree']['instances_failed'],
'vrps': len(rep['vrps']),
'aspas': len(rep['aspas']),
'audit_publication_points': len(rep['publication_points']),
},
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
summary = []
summary.append('# Payload Delta Replay Summary\n\n')
for key in ['payload_base_archive','payload_base_locks','payload_delta_archive','payload_delta_locks','db_dir','report_json','base_validation_time_arg','validation_time_arg']:
summary.append(f'- {key}: `{meta[key]}`\n')
summary.append('\n## Results\n\n| metric | value |\n|---|---:|\n')
for k,v in meta['counts'].items():
summary.append(f'| {k} | {v} |\n')
summary.append('\n## Durations\n\n| step | seconds |\n|---|---:|\n')
for k,v in meta['durations_secs'].items():
summary.append(f'| {k} | {v} |\n')
summary_path.write_text(''.join(summary), encoding='utf-8')
print(summary_path)
PY
python3 scripts/payload_replay/report_to_routinator_csv.py \
--report "$REPORT_JSON" \
--out "$VRPS_CSV" \
--trust-anchor "$TRUST_ANCHOR" >/dev/null
if [[ -f "$ROUTINATOR_RECORD_CSV" ]]; then
./scripts/payload_replay/compare_with_routinator_record.sh \
"$VRPS_CSV" \
"$ROUTINATOR_RECORD_CSV" \
"$COMPARE_SUMMARY_MD" \
"$ONLY_IN_OURS_CSV" \
"$ONLY_IN_RECORD_CSV" >/dev/null
fi
echo "== payload delta replay run complete ==" >&2
echo "- db: $DB_DIR" >&2
echo "- report: $REPORT_JSON" >&2
echo "- run log: $RUN_LOG" >&2
echo "- meta json: $META_JSON" >&2
echo "- summary md: $SUMMARY_MD" >&2
echo "- vrps csv: $VRPS_CSV" >&2
if [[ -f "$COMPARE_SUMMARY_MD" ]]; then
echo "- compare summary: $COMPARE_SUMMARY_MD" >&2
echo "- only in ours: $ONLY_IN_OURS_CSV" >&2
echo "- only in record: $ONLY_IN_RECORD_CSV" >&2
fi

View File

@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
TAL_PATH="${TAL_PATH:-$ROOT_DIR/tests/fixtures/tal/apnic-rfc7730-https.tal}"
TA_PATH="${TA_PATH:-$ROOT_DIR/tests/fixtures/ta/apnic-ta.cer}"
PAYLOAD_REPLAY_ARCHIVE="${PAYLOAD_REPLAY_ARCHIVE:-$ROOT_DIR/target/live/payload_replay/payload-archive}"
PAYLOAD_REPLAY_LOCKS="${PAYLOAD_REPLAY_LOCKS:-$ROOT_DIR/target/live/payload_replay/locks.json}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
TRUST_ANCHOR="${TRUST_ANCHOR:-apnic}"
ROUTINATOR_RECORD_CSV="${ROUTINATOR_RECORD_CSV:-$ROOT_DIR/target/live/payload_replay/record.csv}"
MAX_DEPTH="${MAX_DEPTH:-}"
MAX_INSTANCES="${MAX_INSTANCES:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/payload_replay_runs}"
mkdir -p "$OUT_DIR"
if [[ -z "$VALIDATION_TIME" ]]; then
VALIDATION_TIME="$(python3 - "$PAYLOAD_REPLAY_LOCKS" <<'LOCKPY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text(encoding='utf-8'))
print(data.get('validationTime') or data.get('validation_time') or '2026-03-13T02:30:00Z')
LOCKPY
)"
fi
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_replay_${TS}}"
DB_DIR="${DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
REPORT_JSON="${REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
RUN_LOG="${RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
META_JSON="${META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_summary.md}"
VRPS_CSV="${VRPS_CSV:-$OUT_DIR/${RUN_NAME}_vrps.csv}"
COMPARE_SUMMARY_MD="${COMPARE_SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_compare_summary.md}"
ONLY_IN_OURS_CSV="${ONLY_IN_OURS_CSV:-$OUT_DIR/${RUN_NAME}_only_in_ours.csv}"
ONLY_IN_RECORD_CSV="${ONLY_IN_RECORD_CSV:-$OUT_DIR/${RUN_NAME}_only_in_record.csv}"
cmd=(cargo run --release --bin rpki --
--db "$DB_DIR"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE"
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS"
--validation-time "$VALIDATION_TIME"
--report-json "$REPORT_JSON")
if [[ -n "$MAX_DEPTH" ]]; then
cmd+=(--max-depth "$MAX_DEPTH")
fi
if [[ -n "$MAX_INSTANCES" ]]; then
cmd+=(--max-instances "$MAX_INSTANCES")
fi
run_start_s="$(date +%s)"
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
TAL_PATH="$TAL_PATH" \
TA_PATH="$TA_PATH" \
PAYLOAD_REPLAY_ARCHIVE="$PAYLOAD_REPLAY_ARCHIVE" \
PAYLOAD_REPLAY_LOCKS="$PAYLOAD_REPLAY_LOCKS" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
VALIDATION_TIME="$VALIDATION_TIME" \
RUN_DURATION_S="$run_duration_s" \
python3 - "$REPORT_JSON" "$META_JSON" "$SUMMARY_MD" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
report_path = Path(sys.argv[1])
meta_path = Path(sys.argv[2])
summary_path = Path(sys.argv[3])
rep = json.loads(report_path.read_text(encoding="utf-8"))
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
meta = {
"recorded_at_utc": now,
"tal_path": os.environ["TAL_PATH"],
"ta_path": os.environ["TA_PATH"],
"payload_replay_archive": os.environ["PAYLOAD_REPLAY_ARCHIVE"],
"payload_replay_locks": os.environ["PAYLOAD_REPLAY_LOCKS"],
"db_dir": os.environ["DB_DIR"],
"report_json": os.environ["REPORT_JSON"],
"run_log": os.environ["RUN_LOG"],
"validation_time_arg": os.environ["VALIDATION_TIME"],
"durations_secs": {
"rpki_run": int(os.environ["RUN_DURATION_S"]),
},
"counts": {
"publication_points_processed": rep["tree"]["instances_processed"],
"publication_points_failed": rep["tree"]["instances_failed"],
"vrps": len(rep["vrps"]),
"aspas": len(rep["aspas"]),
"audit_publication_points": len(rep["publication_points"]),
},
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
summary = []
summary.append("# Payload Replay Summary\n\n")
summary.append(f"- recorded_at_utc: `{now}`\n")
summary.append(f"- tal_path: `{meta['tal_path']}`\n")
summary.append(f"- ta_path: `{meta['ta_path']}`\n")
summary.append(f"- payload_replay_archive: `{meta['payload_replay_archive']}`\n")
summary.append(f"- payload_replay_locks: `{meta['payload_replay_locks']}`\n")
summary.append(f"- db: `{meta['db_dir']}`\n")
summary.append(f"- report_json: `{meta['report_json']}`\n")
summary.append(f"- validation_time_arg: `{meta['validation_time_arg']}`\n\n")
summary.append("## Results\n\n")
summary.append("| metric | value |\n")
summary.append("|---|---:|\n")
for k, v in meta["counts"].items():
summary.append(f"| {k} | {v} |\n")
summary.append("\n## Durations\n\n")
summary.append("| step | seconds |\n")
summary.append("|---|---:|\n")
for k, v in meta["durations_secs"].items():
summary.append(f"| {k} | {v} |\n")
summary_path.write_text("".join(summary), encoding="utf-8")
print(summary_path)
PY
python3 scripts/payload_replay/report_to_routinator_csv.py \
--report "$REPORT_JSON" \
--out "$VRPS_CSV" \
--trust-anchor "$TRUST_ANCHOR" >/dev/null
if [[ -f "$ROUTINATOR_RECORD_CSV" ]]; then
./scripts/payload_replay/compare_with_routinator_record.sh \
"$VRPS_CSV" \
"$ROUTINATOR_RECORD_CSV" \
"$COMPARE_SUMMARY_MD" \
"$ONLY_IN_OURS_CSV" \
"$ONLY_IN_RECORD_CSV" >/dev/null
fi
echo "== payload replay run complete ==" >&2
echo "- db: $DB_DIR" >&2
echo "- report: $REPORT_JSON" >&2
echo "- run log: $RUN_LOG" >&2
echo "- meta json: $META_JSON" >&2
echo "- summary md: $SUMMARY_MD" >&2
echo "- vrps csv: $VRPS_CSV" >&2
if [[ -f "$COMPARE_SUMMARY_MD" ]]; then
echo "- compare summary: $COMPARE_SUMMARY_MD" >&2
echo "- only in ours: $ONLY_IN_OURS_CSV" >&2
echo "- only in record: $ONLY_IN_RECORD_CSV" >&2
fi

View File

@ -0,0 +1,178 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BUNDLE_ROOT="${BUNDLE_ROOT:-$ROOT_DIR/../../rpki/target/live/20260316-112341-multi-final3}"
CASE_INFO_SCRIPT="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
PROFILE_RUN_ROOT="${PROFILE_RUN_ROOT:-$ROOT_DIR/target/live/analyze_runs}"
mkdir -p "$PROFILE_RUN_ROOT"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_snapshot_profile_${TS}}"
RUN_DIR="$PROFILE_RUN_ROOT/$RUN_NAME"
mkdir -p "$RUN_DIR"
ANALYZE_ROOT="$ROOT_DIR/target/live/analyze"
mkdir -p "$ANALYZE_ROOT"
mapfile -t ANALYZE_BEFORE < <(find "$ANALYZE_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
if [[ "${DRY_RUN:-0}" == "1" && ! -d "$BUNDLE_ROOT" ]]; then
TRUST_ANCHOR="apnic"
TAL_PATH="$ROOT_DIR/tests/fixtures/tal/apnic-rfc7730-https.tal"
TA_PATH="$ROOT_DIR/tests/fixtures/ta/apnic-ta.cer"
PAYLOAD_REPLAY_ARCHIVE="$BUNDLE_ROOT/apnic/base-payload-archive"
PAYLOAD_REPLAY_LOCKS="$BUNDLE_ROOT/apnic/base-locks.json"
SNAPSHOT_VALIDATION_TIME="2026-03-16T00:00:00Z"
ROUTINATOR_BASE_REPLAY_SECONDS="0"
else
eval "$(python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir apnic --format env)"
fi
DB_DIR="${DB_DIR:-$RUN_DIR/db}"
REPORT_JSON="${REPORT_JSON:-$RUN_DIR/report.json}"
RUN_LOG="${RUN_LOG:-$RUN_DIR/run.log}"
META_JSON="${META_JSON:-$RUN_DIR/meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$RUN_DIR/summary.md}"
rm -rf "$DB_DIR"
cmd=(cargo run --release --features profile --bin rpki --
--db "$DB_DIR"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE"
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS"
--validation-time "$SNAPSHOT_VALIDATION_TIME"
--analyze
--profile-cpu
--report-json "$REPORT_JSON")
if [[ -n "${MAX_DEPTH:-}" ]]; then
cmd+=(--max-depth "$MAX_DEPTH")
fi
if [[ -n "${MAX_INSTANCES:-}" ]]; then
cmd+=(--max-instances "$MAX_INSTANCES")
fi
if [[ "${DRY_RUN:-0}" == "1" ]]; then
printf '%q ' "${cmd[@]}"
echo
exit 0
fi
run_start_s="$(date +%s)"
(
echo '# command:'
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
mapfile -t ANALYZE_AFTER < <(find "$ANALYZE_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
ANALYZE_DIR=""
for candidate in "${ANALYZE_AFTER[@]}"; do
seen=0
for old in "${ANALYZE_BEFORE[@]}"; do
if [[ "$candidate" == "$old" ]]; then
seen=1
break
fi
done
if [[ "$seen" == "0" ]]; then
ANALYZE_DIR="$candidate"
fi
done
if [[ -z "$ANALYZE_DIR" ]]; then
ANALYZE_DIR="$(find "$ANALYZE_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -n 1)"
fi
BUNDLE_ROOT="$BUNDLE_ROOT" \
TRUST_ANCHOR="$TRUST_ANCHOR" \
TAL_PATH="$TAL_PATH" \
TA_PATH="$TA_PATH" \
PAYLOAD_REPLAY_ARCHIVE="$PAYLOAD_REPLAY_ARCHIVE" \
PAYLOAD_REPLAY_LOCKS="$PAYLOAD_REPLAY_LOCKS" \
SNAPSHOT_VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
ROUTINATOR_BASE_REPLAY_SECONDS="$ROUTINATOR_BASE_REPLAY_SECONDS" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
ANALYZE_DIR="$ANALYZE_DIR" \
RUN_DURATION_S="$run_duration_s" \
python3 - "$META_JSON" "$SUMMARY_MD" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
meta_path = Path(sys.argv[1])
summary_path = Path(sys.argv[2])
report_path = Path(os.environ['REPORT_JSON'])
report = json.loads(report_path.read_text(encoding='utf-8'))
recorded = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
meta = {
'recorded_at_utc': recorded,
'bundle_root': os.environ['BUNDLE_ROOT'],
'trust_anchor': os.environ['TRUST_ANCHOR'],
'tal_path': os.environ['TAL_PATH'],
'ta_path': os.environ['TA_PATH'],
'payload_replay_archive': os.environ['PAYLOAD_REPLAY_ARCHIVE'],
'payload_replay_locks': os.environ['PAYLOAD_REPLAY_LOCKS'],
'validation_time_arg': os.environ['SNAPSHOT_VALIDATION_TIME'],
'routinator_base_replay_seconds': float(os.environ['ROUTINATOR_BASE_REPLAY_SECONDS']),
'db_dir': os.environ['DB_DIR'],
'report_json': os.environ['REPORT_JSON'],
'run_log': os.environ['RUN_LOG'],
'analyze_dir': os.environ.get('ANALYZE_DIR') or '',
'durations_secs': {
'rpki_run_wall': int(os.environ['RUN_DURATION_S']),
},
'counts': {
'publication_points_processed': report['tree']['instances_processed'],
'publication_points_failed': report['tree']['instances_failed'],
'vrps': len(report['vrps']),
'aspas': len(report['aspas']),
'audit_publication_points': len(report['publication_points']),
},
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
ratio = meta['durations_secs']['rpki_run_wall'] / meta['routinator_base_replay_seconds'] if meta['routinator_base_replay_seconds'] else None
lines = []
lines.append('# APNIC Snapshot Replay Profile Summary\n\n')
lines.append(f"- recorded_at_utc: `{recorded}`\n")
lines.append(f"- bundle_root: `{meta['bundle_root']}`\n")
lines.append(f"- tal_path: `{meta['tal_path']}`\n")
lines.append(f"- ta_path: `{meta['ta_path']}`\n")
lines.append(f"- payload_replay_archive: `{meta['payload_replay_archive']}`\n")
lines.append(f"- payload_replay_locks: `{meta['payload_replay_locks']}`\n")
lines.append(f"- validation_time_arg: `{meta['validation_time_arg']}`\n")
lines.append(f"- db_dir: `{meta['db_dir']}`\n")
lines.append(f"- report_json: `{meta['report_json']}`\n")
lines.append(f"- run_log: `{meta['run_log']}`\n")
lines.append(f"- analyze_dir: `{meta['analyze_dir']}`\n\n")
lines.append('## Timing\n\n')
lines.append('| metric | value |\n')
lines.append('|---|---:|\n')
lines.append(f"| ours_snapshot_replay_wall_s | {meta['durations_secs']['rpki_run_wall']} |\n")
lines.append(f"| routinator_base_replay_s | {meta['routinator_base_replay_seconds']:.3f} |\n")
if ratio is not None:
lines.append(f"| ratio_ours_over_routinator | {ratio:.3f} |\n")
lines.append('\n## Counts\n\n')
for key, value in meta['counts'].items():
lines.append(f"- {key}: `{value}`\n")
summary_path.write_text(''.join(lines), encoding='utf-8')
PY
echo "== APNIC snapshot replay profiling complete ==" >&2
echo "- run_dir: $RUN_DIR" >&2
echo "- analyze_dir: $ANALYZE_DIR" >&2
echo "- report_json: $REPORT_JSON" >&2
echo "- run_log: $RUN_LOG" >&2
echo "- meta_json: $META_JSON" >&2
echo "- summary_md: $SUMMARY_MD" >&2

View File

@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "usage: $0 <rir> [describe|snapshot|delta|both]" >&2
exit 2
fi
RIR="$1"
MODE="${2:-both}"
BUNDLE_ROOT="${BUNDLE_ROOT:-$ROOT_DIR/../../rpki/target/live/20260316-112341-multi-final3}"
CASE_INFO_SCRIPT="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
CASE_REPORT_SCRIPT="$ROOT_DIR/scripts/payload_replay/write_multi_rir_case_report.py"
MULTI_RIR_OUT_DIR="${MULTI_RIR_OUT_DIR:-$ROOT_DIR/target/live/multi_rir_replay_runs/$RIR}"
mkdir -p "$MULTI_RIR_OUT_DIR"
eval "$(python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir "$RIR" --format env)"
SNAPSHOT_DB_DIR="${SNAPSHOT_DB_DIR:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_replay_db}"
SNAPSHOT_REPORT_MD="${SNAPSHOT_REPORT_MD:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_compare_summary.md}"
SNAPSHOT_META_JSON="${SNAPSHOT_META_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_meta.json}"
SNAPSHOT_RUN_LOG="${SNAPSHOT_RUN_LOG:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_run.log}"
SNAPSHOT_REPORT_JSON="${SNAPSHOT_REPORT_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_report.json}"
SNAPSHOT_VRPS_CSV="${SNAPSHOT_VRPS_CSV:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_vrps.csv}"
SNAPSHOT_ONLY_OURS="${SNAPSHOT_ONLY_OURS:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_only_in_ours.csv}"
SNAPSHOT_ONLY_RECORD="${SNAPSHOT_ONLY_RECORD:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_only_in_record.csv}"
DELTA_DB_DIR="${DELTA_DB_DIR:-$MULTI_RIR_OUT_DIR/${RIR}_delta_replay_db}"
DELTA_REPORT_MD="${DELTA_REPORT_MD:-$MULTI_RIR_OUT_DIR/${RIR}_delta_compare_summary.md}"
DELTA_META_JSON="${DELTA_META_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_delta_meta.json}"
DELTA_RUN_LOG="${DELTA_RUN_LOG:-$MULTI_RIR_OUT_DIR/${RIR}_delta_run.log}"
DELTA_REPORT_JSON="${DELTA_REPORT_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_delta_report.json}"
DELTA_VRPS_CSV="${DELTA_VRPS_CSV:-$MULTI_RIR_OUT_DIR/${RIR}_delta_vrps.csv}"
DELTA_ONLY_OURS="${DELTA_ONLY_OURS:-$MULTI_RIR_OUT_DIR/${RIR}_delta_only_in_ours.csv}"
DELTA_ONLY_RECORD="${DELTA_ONLY_RECORD:-$MULTI_RIR_OUT_DIR/${RIR}_delta_only_in_record.csv}"
CASE_REPORT_JSON="${CASE_REPORT_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_case_report.json}"
CASE_REPORT_MD="${CASE_REPORT_MD:-$MULTI_RIR_OUT_DIR/${RIR}_case_report.md}"
case "$MODE" in
describe)
python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir "$RIR"
;;
snapshot)
rm -rf "$SNAPSHOT_DB_DIR"
ROUTINATOR_RECORD_CSV="$ROUTINATOR_BASE_RECORD_CSV" \
VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$SNAPSHOT_DB_DIR" \
RUN_NAME="${RUN_NAME:-${RIR}_snapshot_replay}" \
META_JSON="$SNAPSHOT_META_JSON" \
RUN_LOG="$SNAPSHOT_RUN_LOG" \
REPORT_JSON="$SNAPSHOT_REPORT_JSON" \
VRPS_CSV="$SNAPSHOT_VRPS_CSV" \
COMPARE_SUMMARY_MD="$SNAPSHOT_REPORT_MD" \
ONLY_IN_OURS_CSV="$SNAPSHOT_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$SNAPSHOT_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_replay.sh
;;
delta)
rm -rf "$DELTA_DB_DIR"
ROUTINATOR_RECORD_CSV="$ROUTINATOR_DELTA_RECORD_CSV" \
VALIDATION_TIME="$DELTA_VALIDATION_TIME" \
PAYLOAD_BASE_VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$DELTA_DB_DIR" \
RUN_NAME="${RUN_NAME:-${RIR}_delta_replay}" \
DELTA_ROOT="$RIR_ROOT" \
META_JSON="$DELTA_META_JSON" \
RUN_LOG="$DELTA_RUN_LOG" \
REPORT_JSON="$DELTA_REPORT_JSON" \
VRPS_CSV="$DELTA_VRPS_CSV" \
COMPARE_SUMMARY_MD="$DELTA_REPORT_MD" \
ONLY_IN_OURS_CSV="$DELTA_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$DELTA_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_delta_replay.sh
;;
both)
rm -rf "$SNAPSHOT_DB_DIR" "$DELTA_DB_DIR"
ROUTINATOR_RECORD_CSV="$ROUTINATOR_BASE_RECORD_CSV" \
VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$SNAPSHOT_DB_DIR" \
RUN_NAME="${RUN_NAME_SNAPSHOT:-${RIR}_snapshot_replay}" \
META_JSON="$SNAPSHOT_META_JSON" \
RUN_LOG="$SNAPSHOT_RUN_LOG" \
REPORT_JSON="$SNAPSHOT_REPORT_JSON" \
VRPS_CSV="$SNAPSHOT_VRPS_CSV" \
COMPARE_SUMMARY_MD="$SNAPSHOT_REPORT_MD" \
ONLY_IN_OURS_CSV="$SNAPSHOT_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$SNAPSHOT_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_replay.sh
ROUTINATOR_RECORD_CSV="$ROUTINATOR_DELTA_RECORD_CSV" \
VALIDATION_TIME="$DELTA_VALIDATION_TIME" \
PAYLOAD_BASE_VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$DELTA_DB_DIR" \
RUN_NAME="${RUN_NAME_DELTA:-${RIR}_delta_replay}" \
DELTA_ROOT="$RIR_ROOT" \
META_JSON="$DELTA_META_JSON" \
RUN_LOG="$DELTA_RUN_LOG" \
REPORT_JSON="$DELTA_REPORT_JSON" \
VRPS_CSV="$DELTA_VRPS_CSV" \
COMPARE_SUMMARY_MD="$DELTA_REPORT_MD" \
ONLY_IN_OURS_CSV="$DELTA_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$DELTA_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_delta_replay.sh
python3 "$CASE_REPORT_SCRIPT" \
--rir "$RIR" \
--snapshot-meta "$SNAPSHOT_META_JSON" \
--snapshot-compare "$SNAPSHOT_REPORT_MD" \
--delta-meta "$DELTA_META_JSON" \
--delta-compare "$DELTA_REPORT_MD" \
--routinator-base-seconds "$ROUTINATOR_BASE_REPLAY_SECONDS" \
--routinator-delta-seconds "$ROUTINATOR_DELTA_REPLAY_SECONDS" \
--out-md "$CASE_REPORT_MD" \
--out-json "$CASE_REPORT_JSON" >/dev/null
echo "- case report: $CASE_REPORT_MD" >&2
echo "- case report json: $CASE_REPORT_JSON" >&2
;;
*)
echo "unsupported mode: $MODE; expected describe|snapshot|delta|both" >&2
exit 2
;;
esac

View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BUNDLE_ROOT="${BUNDLE_ROOT:-$ROOT_DIR/../../rpki/target/live/20260316-112341-multi-final3}"
SUITE_OUT_DIR="${SUITE_OUT_DIR:-$ROOT_DIR/target/live/multi_rir_replay_runs}"
RIRS="${RIRS:-afrinic apnic arin lacnic ripe}"
CASE_SCRIPT="$ROOT_DIR/scripts/payload_replay/run_multi_rir_replay_case.sh"
SUMMARY_SCRIPT="$ROOT_DIR/scripts/payload_replay/write_multi_rir_summary.py"
mkdir -p "$SUITE_OUT_DIR"
for rir in $RIRS; do
MULTI_RIR_OUT_DIR="$SUITE_OUT_DIR/$rir" \
BUNDLE_ROOT="$BUNDLE_ROOT" \
"$CASE_SCRIPT" "$rir" both
echo "completed $rir" >&2
echo >&2
done
python3 "$SUMMARY_SCRIPT" \
--case-root "$SUITE_OUT_DIR" \
--out-md "$SUITE_OUT_DIR/multi_rir_summary.md" \
--out-json "$SUITE_OUT_DIR/multi_rir_summary.json" \
--rirs $RIRS >/dev/null
echo "== multi-RIR replay suite complete ==" >&2
echo "- suite_out_dir: $SUITE_OUT_DIR" >&2
echo "- summary_md: $SUITE_OUT_DIR/multi_rir_summary.md" >&2
echo "- summary_json: $SUITE_OUT_DIR/multi_rir_summary.json" >&2

View File

@ -0,0 +1,133 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Generate one multi-RIR replay case report")
p.add_argument("--rir", required=True)
p.add_argument("--snapshot-meta", required=True)
p.add_argument("--snapshot-compare", required=True)
p.add_argument("--delta-meta", required=True)
p.add_argument("--delta-compare", required=True)
p.add_argument("--routinator-base-seconds", required=True, type=float)
p.add_argument("--routinator-delta-seconds", required=True, type=float)
p.add_argument("--out-md", required=True)
p.add_argument("--out-json", required=True)
return p.parse_args()
def read_json(path: str) -> dict:
return json.loads(Path(path).read_text(encoding="utf-8"))
def parse_compare_md(path: str) -> dict:
lines = Path(path).read_text(encoding="utf-8").splitlines()
out = {}
for line in lines:
if not line.startswith("| "):
continue
parts = [p.strip() for p in line.strip("|").split("|")]
if len(parts) != 2:
continue
key, value = parts
if key in {"metric", "---"}:
continue
try:
out[key] = int(value)
except ValueError:
pass
return out
def ratio(ours: float, baseline: float) -> float | None:
if baseline <= 0:
return None
return ours / baseline
def build_report(args: argparse.Namespace) -> dict:
snapshot_meta = read_json(args.snapshot_meta)
delta_meta = read_json(args.delta_meta)
snapshot_compare = parse_compare_md(args.snapshot_compare)
delta_compare = parse_compare_md(args.delta_compare)
snapshot_ours = float(snapshot_meta["durations_secs"]["rpki_run"])
delta_ours = float(delta_meta["durations_secs"]["rpki_run"])
report = {
"rir": args.rir,
"snapshot": {
"meta_json": str(Path(args.snapshot_meta).resolve()),
"compare_md": str(Path(args.snapshot_compare).resolve()),
"ours_seconds": snapshot_ours,
"routinator_seconds": args.routinator_base_seconds,
"ratio": ratio(snapshot_ours, args.routinator_base_seconds),
"compare": snapshot_compare,
"match": snapshot_compare.get("only_in_ours", -1) == 0
and snapshot_compare.get("only_in_record", -1) == 0,
"counts": snapshot_meta.get("counts", {}),
},
"delta": {
"meta_json": str(Path(args.delta_meta).resolve()),
"compare_md": str(Path(args.delta_compare).resolve()),
"ours_seconds": delta_ours,
"routinator_seconds": args.routinator_delta_seconds,
"ratio": ratio(delta_ours, args.routinator_delta_seconds),
"compare": delta_compare,
"match": delta_compare.get("only_in_ours", -1) == 0
and delta_compare.get("only_in_record", -1) == 0,
"counts": delta_meta.get("counts", {}),
},
}
return report
def write_md(path: Path, report: dict) -> None:
snapshot = report["snapshot"]
delta = report["delta"]
lines = []
lines.append(f"# {report['rir'].upper()} Replay Report\n\n")
lines.append("## Summary\n\n")
lines.append("| mode | match | ours_s | routinator_s | ratio | only_in_ours | only_in_record |\n")
lines.append("|---|---|---:|---:|---:|---:|---:|\n")
lines.append(
f"| snapshot | {str(snapshot['match']).lower()} | {snapshot['ours_seconds']:.3f} | {snapshot['routinator_seconds']:.3f} | {snapshot['ratio']:.3f} | {snapshot['compare'].get('only_in_ours', 0)} | {snapshot['compare'].get('only_in_record', 0)} |\n"
)
lines.append(
f"| delta | {str(delta['match']).lower()} | {delta['ours_seconds']:.3f} | {delta['routinator_seconds']:.3f} | {delta['ratio']:.3f} | {delta['compare'].get('only_in_ours', 0)} | {delta['compare'].get('only_in_record', 0)} |\n"
)
lines.append("\n## Snapshot Inputs\n\n")
lines.append(f"- meta_json: `{snapshot['meta_json']}`\n")
lines.append(f"- compare_md: `{snapshot['compare_md']}`\n")
lines.append("\n## Delta Inputs\n\n")
lines.append(f"- meta_json: `{delta['meta_json']}`\n")
lines.append(f"- compare_md: `{delta['compare_md']}`\n")
lines.append("\n## Counts\n\n")
lines.append("### Snapshot\n\n")
for k, v in sorted(snapshot.get("counts", {}).items()):
lines.append(f"- {k}: `{v}`\n")
lines.append("\n### Delta\n\n")
for k, v in sorted(delta.get("counts", {}).items()):
lines.append(f"- {k}: `{v}`\n")
path.write_text("".join(lines), encoding="utf-8")
def main() -> int:
args = parse_args()
report = build_report(args)
out_json = Path(args.out_json)
out_md = Path(args.out_md)
out_json.parent.mkdir(parents=True, exist_ok=True)
out_md.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_md(out_md, report)
print(out_md)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
DEFAULT_RIRS = ["afrinic", "apnic", "arin", "lacnic", "ripe"]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Aggregate per-RIR replay case reports")
p.add_argument("--case-root", required=True, help="directory containing <rir>/<rir>_case_report.json")
p.add_argument("--out-md", required=True)
p.add_argument("--out-json", required=True)
p.add_argument("--rirs", nargs="*", default=None, help="RIRs to include (default: all 5)")
return p.parse_args()
def read_case(case_root: Path, rir: str) -> dict:
path = case_root / rir / f"{rir}_case_report.json"
return json.loads(path.read_text(encoding="utf-8"))
def geomean(values: list[float]) -> float:
vals = [v for v in values if v > 0]
if not vals:
return 0.0
return math.exp(sum(math.log(v) for v in vals) / len(vals))
def build_summary(cases: list[dict]) -> dict:
snapshot_ratios = [c["snapshot"]["ratio"] for c in cases]
delta_ratios = [c["delta"]["ratio"] for c in cases]
return {
"cases": cases,
"summary": {
"snapshot_all_match": all(c["snapshot"]["match"] for c in cases),
"delta_all_match": all(c["delta"]["match"] for c in cases),
"snapshot_ratio_geomean": geomean(snapshot_ratios),
"delta_ratio_geomean": geomean(delta_ratios),
"all_ratio_geomean": geomean(snapshot_ratios + delta_ratios),
},
}
def write_md(path: Path, data: dict) -> None:
lines = []
lines.append("# Multi-RIR Replay Summary\n\n")
lines.append("## Correctness + Timing\n\n")
lines.append("| RIR | snapshot_match | snapshot_ours_s | snapshot_routinator_s | snapshot_ratio | delta_match | delta_ours_s | delta_routinator_s | delta_ratio |\n")
lines.append("|---|---|---:|---:|---:|---|---:|---:|---:|\n")
for case in data["cases"]:
lines.append(
f"| {case['rir']} | {str(case['snapshot']['match']).lower()} | {case['snapshot']['ours_seconds']:.3f} | {case['snapshot']['routinator_seconds']:.3f} | {case['snapshot']['ratio']:.3f} | {str(case['delta']['match']).lower()} | {case['delta']['ours_seconds']:.3f} | {case['delta']['routinator_seconds']:.3f} | {case['delta']['ratio']:.3f} |\n"
)
s = data["summary"]
lines.append("\n## Aggregate Metrics\n\n")
lines.append("| metric | value |\n")
lines.append("|---|---:|\n")
lines.append(f"| snapshot_all_match | {str(s['snapshot_all_match']).lower()} |\n")
lines.append(f"| delta_all_match | {str(s['delta_all_match']).lower()} |\n")
lines.append(f"| snapshot_ratio_geomean | {s['snapshot_ratio_geomean']:.3f} |\n")
lines.append(f"| delta_ratio_geomean | {s['delta_ratio_geomean']:.3f} |\n")
lines.append(f"| all_ratio_geomean | {s['all_ratio_geomean']:.3f} |\n")
path.write_text("".join(lines), encoding="utf-8")
def main() -> int:
args = parse_args()
case_root = Path(args.case_root)
rirs = args.rirs or DEFAULT_RIRS
cases = [read_case(case_root, rir) for rir in rirs]
data = build_summary(cases)
out_md = Path(args.out_md)
out_json = Path(args.out_json)
out_md.parent.mkdir(parents=True, exist_ok=True)
out_json.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_md(out_md, data)
print(out_md)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,191 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
./scripts/periodic/compare_ccr_cir_round.sh \
--ours-ccr <path> \
--rpki-client-ccr <path> \
--out-dir <path> \
[--ours-cir <path>] \
[--rpki-client-cir <path>] \
[--trust-anchor <name>] \
[--sample-limit <n>] \
[--always-compare-cir]
USAGE
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OURS_CCR=""
CLIENT_CCR=""
OURS_CIR=""
CLIENT_CIR=""
OUT_DIR=""
TRUST_ANCHOR="unknown"
SAMPLE_LIMIT="20"
ALWAYS_COMPARE_CIR=0
while [[ $# -gt 0 ]]; do
case "$1" in
--ours-ccr) OURS_CCR="$2"; shift 2 ;;
--rpki-client-ccr) CLIENT_CCR="$2"; shift 2 ;;
--ours-cir) OURS_CIR="$2"; shift 2 ;;
--rpki-client-cir) CLIENT_CIR="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--trust-anchor) TRUST_ANCHOR="$2"; shift 2 ;;
--sample-limit) SAMPLE_LIMIT="$2"; shift 2 ;;
--always-compare-cir) ALWAYS_COMPARE_CIR=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OURS_CCR" && -n "$CLIENT_CCR" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
if [[ -n "$OURS_CIR" || -n "$CLIENT_CIR" ]]; then
[[ -n "$OURS_CIR" && -n "$CLIENT_CIR" ]] || { echo "--ours-cir and --rpki-client-cir must be provided together" >&2; exit 2; }
fi
mkdir -p "$OUT_DIR/ccr"
"$ROOT_DIR/scripts/periodic/compare_ccr_round.sh" \
--ours-ccr "$OURS_CCR" \
--rpki-client-ccr "$CLIENT_CCR" \
--out-dir "$OUT_DIR/ccr" \
--trust-anchor "$TRUST_ANCHOR" >/dev/null
RUN_CIR="$(python3 - <<'PY' "$OUT_DIR/ccr/compare-summary.json" "$ALWAYS_COMPARE_CIR" "$OURS_CIR" "$CLIENT_CIR"
import json
import sys
summary = json.load(open(sys.argv[1], "r", encoding="utf-8"))
always = sys.argv[2] == "1"
has_cir = bool(sys.argv[3]) and bool(sys.argv[4])
ccr_match = summary.get("stateDigestMatch") is True or summary.get("allMatch") is True
print("1" if has_cir and (always or not ccr_match) else "0")
PY
)"
if [[ "$RUN_CIR" == "1" ]]; then
mkdir -p "$OUT_DIR/cir"
"$ROOT_DIR/scripts/periodic/compare_cir_round.sh" \
--ours-cir "$OURS_CIR" \
--rpki-client-cir "$CLIENT_CIR" \
--out-dir "$OUT_DIR/cir" \
--sample-limit "$SAMPLE_LIMIT" >/dev/null
fi
python3 - <<'PY' \
"$OUT_DIR/ccr/compare-summary.json" \
"$OUT_DIR/cir/cir-compare-summary.json" \
"$OUT_DIR/summary.json" \
"$OUT_DIR/summary.md" \
"$ALWAYS_COMPARE_CIR" \
"$OURS_CIR" \
"$CLIENT_CIR" \
"$TRUST_ANCHOR" \
"$SAMPLE_LIMIT"
import json
import sys
from pathlib import Path
ccr_path = Path(sys.argv[1])
cir_path = Path(sys.argv[2])
summary_json = Path(sys.argv[3])
summary_md = Path(sys.argv[4])
always_compare_cir = sys.argv[5] == "1"
ours_cir = sys.argv[6]
client_cir = sys.argv[7]
trust_anchor = sys.argv[8]
sample_limit = int(sys.argv[9])
ccr = json.load(open(ccr_path, "r", encoding="utf-8"))
ccr_match = ccr.get("stateDigestMatch") is True or ccr.get("allMatch") is True
cir_available = bool(ours_cir) and bool(client_cir)
cir_compared = cir_path.exists()
cir = json.load(open(cir_path, "r", encoding="utf-8")) if cir_compared else None
if ccr_match:
if cir_compared and cir and cir.get("allMatch") is not True:
diagnosis = "ccr_state_same_but_cir_process_diff"
else:
diagnosis = "ccr_state_digest_match"
else:
if not cir_available:
diagnosis = "ccr_mismatch_cir_not_available"
elif not cir_compared:
diagnosis = "ccr_mismatch_cir_compare_skipped"
elif cir.get("trustAnchors", {}).get("match") is not True:
diagnosis = "trust_anchor_input_difference"
elif cir.get("objects", {}).get("match") is not True:
diagnosis = "input_object_or_manifest_accepted_set_difference"
elif (
cir.get("rejects", {}).get("match") is not True
or cir.get("rejectListSha256Match") is not True
):
diagnosis = "validation_reject_policy_difference"
else:
diagnosis = "ccr_projection_sorting_encoding_or_non_cir_state_difference"
combined = {
"allMatch": bool(ccr_match and (not cir_compared or (cir and cir.get("allMatch") is True))),
"diagnosis": diagnosis,
"comparePath": ccr.get("comparePath"),
"stateDigestMatch": ccr_match,
"mismatchedStates": ccr.get("mismatchedStates", []),
"mismatchedComponents": ccr.get("mismatchedComponents", []),
"vrps": ccr.get("vrps"),
"vaps": ccr.get("vaps"),
"trustAnchor": trust_anchor,
"sampleLimit": sample_limit,
"ccr": {
"summaryPath": str(ccr_path),
"stateDigestMatch": ccr_match,
"comparePath": ccr.get("comparePath"),
"mismatchedStates": ccr.get("mismatchedStates", []),
"mismatchedComponents": ccr.get("mismatchedComponents", []),
"vrpMatch": ccr.get("vrps", {}).get("match"),
"vapMatch": ccr.get("vaps", {}).get("match"),
},
"cir": {
"available": cir_available,
"compared": cir_compared,
"summaryPath": str(cir_path) if cir_compared else None,
"comparePolicy": "always" if always_compare_cir else "on_ccr_mismatch",
"allMatch": cir.get("allMatch") if cir else None,
"objectsMatch": cir.get("objects", {}).get("match") if cir else None,
"rejectsMatch": cir.get("rejects", {}).get("match") if cir else None,
"trustAnchorsMatch": cir.get("trustAnchors", {}).get("match") if cir else None,
"talsMatch": cir.get("trustAnchors", {}).get("match") if cir else None,
"rejectListSha256Match": cir.get("rejectListSha256Match") if cir else None,
"oursObjectCount": cir.get("ours", {}).get("objectCount") if cir else None,
"rpkiClientObjectCount": cir.get("rpkiClient", {}).get("objectCount") if cir else None,
"oursTrustAnchorCount": cir.get("ours", {}).get("trustAnchorCount") if cir else None,
"rpkiClientTrustAnchorCount": cir.get("rpkiClient", {}).get("trustAnchorCount") if cir else None,
"oursRejectCount": cir.get("ours", {}).get("rejectCount") if cir else None,
"rpkiClientRejectCount": cir.get("rpkiClient", {}).get("rejectCount") if cir else None,
},
}
summary_json.write_text(json.dumps(combined, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
lines = [
"# CCR/CIR Compare Summary",
"",
f"- `allMatch`: `{str(combined['allMatch']).lower()}`",
f"- `diagnosis`: `{combined['diagnosis']}`",
f"- `ccrStateDigestMatch`: `{str(ccr_match).lower()}`",
f"- `ccrMismatchedStates`: `{','.join(combined['ccr']['mismatchedStates'])}`",
f"- `cirCompared`: `{str(cir_compared).lower()}`",
]
if cir:
lines.extend([
f"- `cirObjectsMatch`: `{str(combined['cir']['objectsMatch']).lower()}`",
f"- `cirRejectsMatch`: `{str(combined['cir']['rejectsMatch']).lower()}`",
f"- `cirTrustAnchorsMatch`: `{str(combined['cir']['trustAnchorsMatch']).lower()}`",
f"- `cirCounts`: ours objects `{combined['cir']['oursObjectCount']}`, rpki-client objects `{combined['cir']['rpkiClientObjectCount']}`, ours trustAnchors `{combined['cir']['oursTrustAnchorCount']}`, rpki-client trustAnchors `{combined['cir']['rpkiClientTrustAnchorCount']}`, ours rejects `{combined['cir']['oursRejectCount']}`, rpki-client rejects `{combined['cir']['rpkiClientRejectCount']}`",
])
else:
lines.append(f"- `cirSkippedReason`: `{'ccr matched' if ccr_match and not always_compare_cir else 'CIR inputs unavailable'}`")
summary_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(summary_json)
PY

View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/compare_ccr_round.sh \
--ours-ccr <path> \
--rpki-client-ccr <path> \
--out-dir <path> \
[--trust-anchor <name>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OURS_CCR=""
CLIENT_CCR=""
OUT_DIR=""
TRUST_ANCHOR="unknown"
CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views"
CCR_STATE_COMPARE_BIN="$ROOT_DIR/target/release/ccr_state_compare"
while [[ $# -gt 0 ]]; do
case "$1" in
--ours-ccr) OURS_CCR="$2"; shift 2 ;;
--rpki-client-ccr) CLIENT_CCR="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--trust-anchor) TRUST_ANCHOR="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OURS_CCR" && -n "$CLIENT_CCR" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
if [[ ! -x "$CCR_STATE_COMPARE_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin ccr_state_compare --bin ccr_to_compare_views
)
fi
OURS_VRPS="$OUT_DIR/ours-vrps.csv"
OURS_VAPS="$OUT_DIR/ours-vaps.csv"
CLIENT_VRPS="$OUT_DIR/rpki-client-vrps.csv"
CLIENT_VAPS="$OUT_DIR/rpki-client-vaps.csv"
SUMMARY_JSON="$OUT_DIR/compare-summary.json"
SUMMARY_MD="$OUT_DIR/compare-summary.md"
"$CCR_STATE_COMPARE_BIN" \
--ours-ccr "$OURS_CCR" \
--rpki-client-ccr "$CLIENT_CCR" \
--out-json "$SUMMARY_JSON" \
--out-md "$SUMMARY_MD" \
--out-dir "$OUT_DIR" \
--trust-anchor "$TRUST_ANCHOR" \
--fallback-compare-views
echo "$OUT_DIR"

View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
./scripts/periodic/compare_cir_round.sh \
--ours-cir <path> \
--rpki-client-cir <path> \
--out-dir <path> \
[--sample-limit <n>]
USAGE
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OURS_CIR=""
CLIENT_CIR=""
OUT_DIR=""
SAMPLE_LIMIT="20"
CIR_STATE_COMPARE_BIN="$ROOT_DIR/target/release/cir_state_compare"
while [[ $# -gt 0 ]]; do
case "$1" in
--ours-cir) OURS_CIR="$2"; shift 2 ;;
--rpki-client-cir) CLIENT_CIR="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--sample-limit) SAMPLE_LIMIT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OURS_CIR" && -n "$CLIENT_CIR" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
if [[ ! -x "$CIR_STATE_COMPARE_BIN" ]]; then
(cd "$ROOT_DIR" && cargo build --release --bin cir_state_compare)
fi
"$CIR_STATE_COMPARE_BIN" \
--ours-cir "$OURS_CIR" \
--rpki-client-cir "$CLIENT_CIR" \
--out-json "$OUT_DIR/cir-compare-summary.json" \
--out-md "$OUT_DIR/cir-compare-summary.md" \
--sample-limit "$SAMPLE_LIMIT"
echo "$OUT_DIR"

View File

@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_apnic_ours_parallel_round_remote.sh \
--run-root <path> \
--round-id <round-XXX> \
--kind <snapshot|delta> \
--ssh-target <user@host> \
--remote-root <path> \
[--scheduled-at <RFC3339>] \
[--skip-sync]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
ROUND_ID=""
KIND=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
SCHEDULED_AT=""
SKIP_SYNC=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--round-id) ROUND_ID="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--scheduled-at) SCHEDULED_AT="$2"; shift 2 ;;
--skip-sync) SKIP_SYNC=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" && -n "$ROUND_ID" && -n "$KIND" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
[[ "$KIND" == "snapshot" || "$KIND" == "delta" ]] || { echo "--kind must be snapshot or delta" >&2; exit 2; }
LOCAL_OUT="$RUN_ROOT/rounds/$ROUND_ID/ours"
REMOTE_REPO="$REMOTE_ROOT/repo"
REMOTE_OUT="$REMOTE_ROOT/rounds/$ROUND_ID/ours"
REMOTE_WORK_DB="$REMOTE_ROOT/state/ours/work-db"
REMOTE_RAW_STORE="$REMOTE_ROOT/state/ours/raw-store.db"
REMOTE_REPO_BYTES="$REMOTE_ROOT/state/ours/repo-bytes.db"
mkdir -p "$LOCAL_OUT"
if [[ "$SKIP_SYNC" -eq 0 ]]; then
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_REPO/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_REPO/target/release' '$REMOTE_OUT' '$REMOTE_ROOT/state/ours'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_REPO/target/release/"
else
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_OUT' '$REMOTE_ROOT/state/ours'"
fi
ssh "$SSH_TARGET" \
REMOTE_REPO="$REMOTE_REPO" \
REMOTE_OUT="$REMOTE_OUT" \
REMOTE_WORK_DB="$REMOTE_WORK_DB" \
REMOTE_RAW_STORE="$REMOTE_RAW_STORE" \
REMOTE_REPO_BYTES="$REMOTE_REPO_BYTES" \
KIND="$KIND" \
ROUND_ID="$ROUND_ID" \
SCHEDULED_AT="$SCHEDULED_AT" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_REPO"
mkdir -p "$REMOTE_OUT"
if [[ "$KIND" == "snapshot" ]]; then
rm -rf "$REMOTE_WORK_DB" "$REMOTE_RAW_STORE" "$REMOTE_REPO_BYTES"
fi
mkdir -p "$(dirname "$REMOTE_WORK_DB")"
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
ccr_out="$REMOTE_OUT/result.ccr"
cir_out="$REMOTE_OUT/result.cir"
report_out="$REMOTE_OUT/report.json"
run_log="$REMOTE_OUT/run.log"
meta_out="$REMOTE_OUT/round-result.json"
set +e
env RPKI_PROGRESS_LOG=1 RPKI_PROGRESS_SLOW_SECS=0 target/release/rpki \
--db "$REMOTE_WORK_DB" \
--raw-store-db "$REMOTE_RAW_STORE" \
--repo-bytes-db "$REMOTE_REPO_BYTES" \
--tal-path tests/fixtures/tal/apnic-rfc7730-https.tal \
--ta-path tests/fixtures/ta/apnic-ta.cer \
--ccr-out "$ccr_out" \
--report-json "$report_out" \
--cir-enable \
--cir-out "$cir_out" \
--cir-tal-uri "https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer" \
>"$run_log" 2>&1
exit_code=$?
set -e
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "$meta_out" "$ROUND_ID" "$KIND" "$SCHEDULED_AT" "$started_at_iso" "$finished_at_iso" "$REMOTE_WORK_DB" "$REMOTE_RAW_STORE" "$exit_code" "$started_at_ms" "$finished_at_ms"
import json, sys
(
path,
round_id,
kind,
scheduled_at,
started_at,
finished_at,
work_db,
raw_store,
exit_code,
start_ms,
end_ms,
) = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"roundId": round_id,
"kind": kind,
"scheduledAt": scheduled_at or None,
"startedAt": started_at,
"finishedAt": finished_at,
"durationMs": int(end_ms) - int(start_ms),
"remoteWorkDbPath": work_db,
"remoteRawStoreDbPath": raw_store,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
exit "$exit_code"
EOS
rsync -a "$SSH_TARGET:$REMOTE_OUT/" "$LOCAL_OUT/"
echo "$LOCAL_OUT"

View File

@ -0,0 +1,284 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_apnic_parallel_dual_rp_periodic_ccr_compare.sh \
--run-root <path> \
[--ssh-target <user@host>] \
[--remote-root <path>] \
[--rpki-client-bin <path>] \
[--round-count <n>] \
[--interval-secs <n>] \
[--start-at <RFC3339>] \
[--dry-run]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
RPKI_CLIENT_BIN="${RPKI_CLIENT_BIN:-/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client}"
RPKI_CLIENT_LIBTLS_PATH="${RPKI_CLIENT_LIBTLS_PATH:-/home/yuyr/dev/rpki-client-9.7/.deps/libtls/root/usr/lib/x86_64-linux-gnu/libtls.so.28}"
ROUND_COUNT=10
INTERVAL_SECS=600
START_AT=""
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--rpki-client-libtls) RPKI_CLIENT_LIBTLS_PATH="$2"; shift 2 ;;
--round-count) ROUND_COUNT="$2"; shift 2 ;;
--interval-secs) INTERVAL_SECS="$2"; shift 2 ;;
--start-at) START_AT="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" ]] || { usage >&2; exit 2; }
[[ "$ROUND_COUNT" =~ ^[0-9]+$ ]] || { echo "--round-count must be an integer" >&2; exit 2; }
[[ "$INTERVAL_SECS" =~ ^[0-9]+$ ]] || { echo "--interval-secs must be an integer" >&2; exit 2; }
if [[ "$DRY_RUN" -ne 1 ]]; then
[[ -n "$REMOTE_ROOT" ]] || { echo "--remote-root is required unless --dry-run" >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
[[ -f "$RPKI_CLIENT_LIBTLS_PATH" ]] || { echo "rpki-client libtls not found: $RPKI_CLIENT_LIBTLS_PATH" >&2; exit 2; }
fi
mkdir -p "$RUN_ROOT"
python3 - <<'PY' "$RUN_ROOT" "$SSH_TARGET" "$REMOTE_ROOT" "$ROUND_COUNT" "$INTERVAL_SECS" "$START_AT" "$DRY_RUN"
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
run_root = Path(sys.argv[1]).resolve()
ssh_target = sys.argv[2]
remote_root = sys.argv[3]
round_count = int(sys.argv[4])
interval_secs = int(sys.argv[5])
start_at_arg = sys.argv[6]
dry_run = bool(int(sys.argv[7]))
def parse_rfc3339_utc(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
def fmt(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
base_time = parse_rfc3339_utc(start_at_arg) if start_at_arg else datetime.now(timezone.utc)
(run_root / "rounds").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "ours" / "work-db").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "ours" / "raw-store.db").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "rpki-client" / "cache").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "rpki-client" / "out").mkdir(parents=True, exist_ok=True)
meta = {
"version": 1,
"rir": "apnic",
"roundCount": round_count,
"intervalSecs": interval_secs,
"baseScheduledAt": fmt(base_time),
"mode": "dry_run" if dry_run else "remote_periodic",
"execution": {
"mode": "remote",
"sshTarget": ssh_target,
"remoteRoot": remote_root or None,
},
}
(run_root / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
rounds = []
for idx in range(round_count):
round_id = f"round-{idx+1:03d}"
kind = "snapshot" if idx == 0 else "delta"
scheduled_at = base_time + timedelta(seconds=interval_secs * idx)
round_dir = run_root / "rounds" / round_id
for name in ("ours", "rpki-client", "compare"):
(round_dir / name).mkdir(parents=True, exist_ok=True)
round_meta = {
"roundId": round_id,
"kind": kind,
"scheduledAt": fmt(scheduled_at),
"status": "dry_run" if dry_run else "pending",
"paths": {
"ours": f"rounds/{round_id}/ours",
"rpkiClient": f"rounds/{round_id}/rpki-client",
"compare": f"rounds/{round_id}/compare",
},
}
(round_dir / "round-meta.json").write_text(json.dumps(round_meta, indent=2), encoding="utf-8")
rounds.append(round_meta)
final_summary = {
"version": 1,
"status": "dry_run" if dry_run else "pending",
"roundCount": round_count,
"allMatch": None,
"rounds": rounds,
}
(run_root / "final-summary.json").write_text(json.dumps(final_summary, indent=2), encoding="utf-8")
PY
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "$RUN_ROOT"
exit 0
fi
if [[ ! -x "$ROOT_DIR/target/release/rpki" || ! -x "$ROOT_DIR/target/release/ccr_to_compare_views" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki --bin ccr_to_compare_views
)
fi
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_ROOT/repo/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT/repo/target/release' '$REMOTE_ROOT/bin' '$REMOTE_ROOT/lib' '$REMOTE_ROOT/rounds' '$REMOTE_ROOT/state/ours' '$REMOTE_ROOT/state/rpki-client'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_ROOT/repo/target/release/"
rsync -a "$RPKI_CLIENT_BIN" "$SSH_TARGET:$REMOTE_ROOT/bin/rpki-client"
rsync -aL "$RPKI_CLIENT_LIBTLS_PATH" "$SSH_TARGET:$REMOTE_ROOT/lib/libtls.so.28"
for idx in $(seq 1 "$ROUND_COUNT"); do
ROUND_ID="$(printf 'round-%03d' "$idx")"
ROUND_DIR="$RUN_ROOT/rounds/$ROUND_ID"
SCHEDULED_AT="$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['scheduledAt'])
PY
)"
python3 - <<'PY' "$SCHEDULED_AT"
from datetime import datetime, timezone
import sys, time
scheduled = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc)
delay = (scheduled - datetime.now(timezone.utc)).total_seconds()
if delay > 0:
time.sleep(delay)
PY
"$ROOT_DIR/scripts/periodic/run_apnic_ours_parallel_round_remote.sh" \
--run-root "$RUN_ROOT" \
--round-id "$ROUND_ID" \
--kind "$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['kind'])
PY
)" \
--ssh-target "$SSH_TARGET" \
--remote-root "$REMOTE_ROOT" \
--scheduled-at "$SCHEDULED_AT" \
--skip-sync &
OURS_PID=$!
"$ROOT_DIR/scripts/periodic/run_apnic_rpki_client_round_remote.sh" \
--run-root "$RUN_ROOT" \
--round-id "$ROUND_ID" \
--kind "$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['kind'])
PY
)" \
--ssh-target "$SSH_TARGET" \
--remote-root "$REMOTE_ROOT" \
--scheduled-at "$SCHEDULED_AT" \
--rpki-client-bin "$RPKI_CLIENT_BIN" \
--skip-sync &
CLIENT_PID=$!
set +e
wait "$OURS_PID"; OURS_STATUS=$?
wait "$CLIENT_PID"; CLIENT_STATUS=$?
set -e
rsync -az "$SSH_TARGET:$REMOTE_ROOT/rounds/$ROUND_ID/ours/" "$ROUND_DIR/ours/"
rsync -az "$SSH_TARGET:$REMOTE_ROOT/rounds/$ROUND_ID/rpki-client/" "$ROUND_DIR/rpki-client/"
if [[ "$OURS_STATUS" -eq 0 && "$CLIENT_STATUS" -eq 0 \
&& -f "$ROUND_DIR/ours/result.ccr" && -f "$ROUND_DIR/rpki-client/result.ccr" ]]; then
"$ROOT_DIR/scripts/periodic/compare_ccr_round.sh" \
--ours-ccr "$ROUND_DIR/ours/result.ccr" \
--rpki-client-ccr "$ROUND_DIR/rpki-client/result.ccr" \
--out-dir "$ROUND_DIR/compare" \
--trust-anchor apnic >/dev/null
fi
python3 - <<'PY' "$ROUND_DIR/round-meta.json" "$ROUND_DIR/ours/round-result.json" "$ROUND_DIR/rpki-client/round-result.json" "$ROUND_DIR/compare/compare-summary.json"
import json, sys
from datetime import datetime, timezone
from pathlib import Path
meta_path, ours_path, client_path, compare_path = sys.argv[1:]
meta = json.load(open(meta_path, 'r', encoding='utf-8'))
ours = json.load(open(ours_path, 'r', encoding='utf-8'))
client = json.load(open(client_path, 'r', encoding='utf-8'))
scheduled = datetime.fromisoformat(meta['scheduledAt'].replace('Z', '+00:00')).astimezone(timezone.utc)
started = [
datetime.fromisoformat(v.replace('Z', '+00:00')).astimezone(timezone.utc)
for v in [ours.get('startedAt'), client.get('startedAt')] if v
]
finished = [
datetime.fromisoformat(v.replace('Z', '+00:00')).astimezone(timezone.utc)
for v in [ours.get('finishedAt'), client.get('finishedAt')] if v
]
if started:
start_at = min(started)
meta['startedAt'] = start_at.strftime('%Y-%m-%dT%H:%M:%SZ')
meta['startLagMs'] = max(int((start_at - scheduled).total_seconds() * 1000), 0)
if finished:
finish_at = max(finished)
meta['finishedAt'] = finish_at.strftime('%Y-%m-%dT%H:%M:%SZ')
meta['status'] = 'completed' if ours.get('exitCode') == 0 and client.get('exitCode') == 0 else 'failed'
meta['ours'] = {'exitCode': ours.get('exitCode'), 'durationMs': ours.get('durationMs')}
meta['rpkiClient'] = {'exitCode': client.get('exitCode'), 'durationMs': client.get('durationMs')}
if Path(compare_path).exists():
compare = json.load(open(compare_path, 'r', encoding='utf-8'))
meta['compare'] = {
'allMatch': compare.get('allMatch'),
'vrpMatch': compare.get('vrps', {}).get('match'),
'vapMatch': compare.get('vaps', {}).get('match'),
'oursVrps': compare.get('vrps', {}).get('ours'),
'rpkiClientVrps': compare.get('vrps', {}).get('rpkiClient'),
'oursVaps': compare.get('vaps', {}).get('ours'),
'rpkiClientVaps': compare.get('vaps', {}).get('rpkiClient'),
}
json.dump(meta, open(meta_path, 'w', encoding='utf-8'), indent=2)
PY
ssh "$SSH_TARGET" "rm -rf '$REMOTE_ROOT/rounds/$ROUND_ID'"
done
python3 - <<'PY' "$RUN_ROOT/final-summary.json" "$RUN_ROOT/rounds"
import json, sys
from pathlib import Path
summary_path = Path(sys.argv[1])
rounds_root = Path(sys.argv[2])
rounds = []
all_match = True
for round_dir in sorted(rounds_root.glob('round-*')):
meta = json.load(open(round_dir / 'round-meta.json', 'r', encoding='utf-8'))
rounds.append(meta)
compare = meta.get('compare')
if compare is None or compare.get('allMatch') is not True:
all_match = False
summary = {
'version': 1,
'status': 'completed',
'roundCount': len(rounds),
'allMatch': all_match,
'rounds': rounds,
}
json.dump(summary, open(summary_path, 'w', encoding='utf-8'), indent=2)
PY
echo "$RUN_ROOT"

View File

@ -0,0 +1,164 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_apnic_rpki_client_round_remote.sh \
--run-root <path> \
--round-id <round-XXX> \
--kind <snapshot|delta> \
--ssh-target <user@host> \
--remote-root <path> \
[--scheduled-at <RFC3339>] \
[--rpki-client-bin <local path>] \
[--skip-sync]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
ROUND_ID=""
KIND=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
SCHEDULED_AT=""
RPKI_CLIENT_BIN="${RPKI_CLIENT_BIN:-/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client}"
SKIP_SYNC=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--round-id) ROUND_ID="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--scheduled-at) SCHEDULED_AT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--skip-sync) SKIP_SYNC=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" && -n "$ROUND_ID" && -n "$KIND" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
[[ "$KIND" == "snapshot" || "$KIND" == "delta" ]] || { echo "--kind must be snapshot or delta" >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
LOCAL_OUT="$RUN_ROOT/rounds/$ROUND_ID/rpki-client"
REMOTE_REPO="$REMOTE_ROOT/repo"
REMOTE_BIN_DIR="$REMOTE_ROOT/bin"
REMOTE_BIN="$REMOTE_BIN_DIR/rpki-client"
REMOTE_OUT="$REMOTE_ROOT/rounds/$ROUND_ID/rpki-client"
REMOTE_CACHE="$REMOTE_ROOT/state/rpki-client/cache"
REMOTE_STATE_OUT="$REMOTE_ROOT/state/rpki-client/out"
REMOTE_STATE_ROOT="$REMOTE_ROOT/state/rpki-client"
mkdir -p "$LOCAL_OUT"
if [[ "$SKIP_SYNC" -eq 0 ]]; then
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_REPO/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_BIN_DIR' '$REMOTE_OUT' '$REMOTE_STATE_ROOT'"
rsync -a "$RPKI_CLIENT_BIN" "$SSH_TARGET:$REMOTE_BIN"
else
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_BIN_DIR' '$REMOTE_OUT' '$REMOTE_STATE_ROOT'"
fi
ssh "$SSH_TARGET" \
REMOTE_ROOT="$REMOTE_ROOT" \
REMOTE_BIN="$REMOTE_BIN" \
REMOTE_OUT="$REMOTE_OUT" \
REMOTE_CACHE="$REMOTE_CACHE" \
REMOTE_STATE_OUT="$REMOTE_STATE_OUT" \
REMOTE_STATE_ROOT="$REMOTE_STATE_ROOT" \
KIND="$KIND" \
ROUND_ID="$ROUND_ID" \
SCHEDULED_AT="$SCHEDULED_AT" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_ROOT"
mkdir -p "$REMOTE_OUT"
if [[ "$KIND" == "snapshot" ]]; then
rm -rf "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$REMOTE_STATE_ROOT/ta" "$REMOTE_STATE_ROOT/.ta"
fi
mkdir -p "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$REMOTE_STATE_ROOT/ta" "$REMOTE_STATE_ROOT/.ta"
chmod -R 0777 "$REMOTE_STATE_ROOT"
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
ccr_out="$REMOTE_OUT/result.ccr"
run_log="$REMOTE_OUT/run.log"
meta_out="$REMOTE_OUT/round-result.json"
set +e
(
cd "$REMOTE_STATE_ROOT"
LD_LIBRARY_PATH="$REMOTE_ROOT/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
"$REMOTE_BIN" -vv -t "../../repo/tests/fixtures/tal/apnic-rfc7730-https.tal" -d "cache" "out"
) >"$run_log" 2>&1
exit_code=$?
set -e
if [[ -f "$REMOTE_STATE_OUT/rpki.ccr" ]]; then
cp "$REMOTE_STATE_OUT/rpki.ccr" "$ccr_out"
fi
if [[ -f "$REMOTE_STATE_OUT/openbgpd" ]]; then
cp "$REMOTE_STATE_OUT/openbgpd" "$REMOTE_OUT/openbgpd"
fi
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "$meta_out" "$ROUND_ID" "$KIND" "$SCHEDULED_AT" "$started_at_iso" "$finished_at_iso" "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$exit_code" "$started_at_ms" "$finished_at_ms"
import json, sys
(
path,
round_id,
kind,
scheduled_at,
started_at,
finished_at,
cache_path,
out_path,
exit_code,
start_ms,
end_ms,
) = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"roundId": round_id,
"kind": kind,
"scheduledAt": scheduled_at or None,
"startedAt": started_at,
"finishedAt": finished_at,
"durationMs": int(end_ms) - int(start_ms),
"remoteCachePath": cache_path,
"remoteOutPath": out_path,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
exit "$exit_code"
EOS
rsync -a "$SSH_TARGET:$REMOTE_OUT/" "$LOCAL_OUT/"
echo "$LOCAL_OUT"

View File

@ -0,0 +1,324 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_arin_dual_rp_periodic_ccr_compare.sh \
--run-root <path> \
[--ssh-target <user@host>] \
[--remote-root <path>] \
[--rpki-client-bin <path>] \
[--round-count <n>] \
[--interval-secs <n>] \
[--start-at <RFC3339>] \
[--dry-run]
M1 behavior:
- creates the periodic run skeleton
- writes per-round scheduling metadata
- does not execute RP binaries yet
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
RPKI_CLIENT_BIN="${RPKI_CLIENT_BIN:-/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client}"
RPKI_CLIENT_LIBTLS_PATH="${RPKI_CLIENT_LIBTLS_PATH:-/home/yuyr/dev/rpki-client-9.7/.deps/libtls/root/usr/lib/x86_64-linux-gnu/libtls.so.28}"
ROUND_COUNT=10
INTERVAL_SECS=600
START_AT=""
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--rpki-client-libtls) RPKI_CLIENT_LIBTLS_PATH="$2"; shift 2 ;;
--round-count) ROUND_COUNT="$2"; shift 2 ;;
--interval-secs) INTERVAL_SECS="$2"; shift 2 ;;
--start-at) START_AT="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" ]] || { usage >&2; exit 2; }
[[ "$ROUND_COUNT" =~ ^[0-9]+$ ]] || { echo "--round-count must be an integer" >&2; exit 2; }
[[ "$INTERVAL_SECS" =~ ^[0-9]+$ ]] || { echo "--interval-secs must be an integer" >&2; exit 2; }
if [[ "$DRY_RUN" -ne 1 ]]; then
[[ -n "$REMOTE_ROOT" ]] || { echo "--remote-root is required unless --dry-run" >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
[[ -f "$RPKI_CLIENT_LIBTLS_PATH" ]] || { echo "rpki-client libtls not found: $RPKI_CLIENT_LIBTLS_PATH" >&2; exit 2; }
fi
mkdir -p "$RUN_ROOT"
python3 - <<'PY' "$RUN_ROOT" "$SSH_TARGET" "$REMOTE_ROOT" "$ROUND_COUNT" "$INTERVAL_SECS" "$START_AT" "$DRY_RUN"
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
run_root = Path(sys.argv[1]).resolve()
ssh_target = sys.argv[2]
remote_root = sys.argv[3]
round_count = int(sys.argv[4])
interval_secs = int(sys.argv[5])
start_at_arg = sys.argv[6]
dry_run = bool(int(sys.argv[7]))
def parse_rfc3339_utc(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
def fmt(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
base_time = (
parse_rfc3339_utc(start_at_arg)
if start_at_arg
else datetime.now(timezone.utc)
)
(run_root / "rounds").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "ours" / "work-db").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "ours" / "raw-store.db").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "ours" / "repo-bytes.db").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "rpki-client" / "cache").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "rpki-client" / "out").mkdir(parents=True, exist_ok=True)
meta = {
"version": 1,
"rir": "arin",
"roundCount": round_count,
"intervalSecs": interval_secs,
"baseScheduledAt": fmt(base_time),
"mode": "dry_run" if dry_run else "skeleton_only",
"execution": {
"mode": "remote",
"sshTarget": ssh_target,
"remoteRoot": remote_root or None,
},
"state": {
"ours": {
"workDbPath": "state/ours/work-db",
"rawStoreDbPath": "state/ours/raw-store.db",
"repoBytesDbPath": "state/ours/repo-bytes.db",
"remoteWorkDbPath": "state/ours/work-db",
"remoteRawStoreDbPath": "state/ours/raw-store.db",
"remoteRepoBytesDbPath": "state/ours/repo-bytes.db",
},
"rpkiClient": {
"cachePath": "state/rpki-client/cache",
"outPath": "state/rpki-client/out",
"remoteCachePath": "state/rpki-client/cache",
"remoteOutPath": "state/rpki-client/out",
},
},
}
(run_root / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
rounds = []
for idx in range(round_count):
round_id = f"round-{idx+1:03d}"
kind = "snapshot" if idx == 0 else "delta"
scheduled_at = base_time + timedelta(seconds=interval_secs * idx)
round_dir = run_root / "rounds" / round_id
for name in ("ours", "rpki-client", "compare"):
(round_dir / name).mkdir(parents=True, exist_ok=True)
# M1 only builds the schedule skeleton, so lag is defined relative to the schedule model.
started_at = scheduled_at if dry_run else None
finished_at = scheduled_at if dry_run else None
round_meta = {
"roundId": round_id,
"kind": kind,
"scheduledAt": fmt(scheduled_at),
"startedAt": fmt(started_at) if started_at else None,
"finishedAt": fmt(finished_at) if finished_at else None,
"startLagMs": 0 if dry_run else None,
"status": "dry_run" if dry_run else "pending",
"paths": {
"ours": f"rounds/{round_id}/ours",
"rpkiClient": f"rounds/{round_id}/rpki-client",
"compare": f"rounds/{round_id}/compare",
},
}
(round_dir / "round-meta.json").write_text(
json.dumps(round_meta, indent=2), encoding="utf-8"
)
rounds.append(round_meta)
final_summary = {
"version": 1,
"status": "dry_run" if dry_run else "pending",
"roundCount": round_count,
"allMatch": None,
"rounds": rounds,
}
(run_root / "final-summary.json").write_text(
json.dumps(final_summary, indent=2), encoding="utf-8"
)
PY
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "$RUN_ROOT"
exit 0
fi
if [[ ! -x "$ROOT_DIR/target/release/rpki" || ! -x "$ROOT_DIR/target/release/ccr_to_compare_views" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki --bin ccr_to_compare_views
)
fi
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_ROOT/repo/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT/repo/target/release' '$REMOTE_ROOT/bin' '$REMOTE_ROOT/lib' '$REMOTE_ROOT/rounds' '$REMOTE_ROOT/state/ours' '$REMOTE_ROOT/state/rpki-client'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_ROOT/repo/target/release/"
rsync -a "$RPKI_CLIENT_BIN" "$SSH_TARGET:$REMOTE_ROOT/bin/rpki-client"
rsync -aL "$RPKI_CLIENT_LIBTLS_PATH" "$SSH_TARGET:$REMOTE_ROOT/lib/libtls.so.28"
for idx in $(seq 1 "$ROUND_COUNT"); do
ROUND_ID="$(printf 'round-%03d' "$idx")"
ROUND_DIR="$RUN_ROOT/rounds/$ROUND_ID"
SCHEDULED_AT="$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['scheduledAt'])
PY
)"
python3 - <<'PY' "$SCHEDULED_AT"
from datetime import datetime, timezone
import sys, time
scheduled = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc)
now = datetime.now(timezone.utc)
delay = (scheduled - now).total_seconds()
if delay > 0:
time.sleep(delay)
PY
"$ROOT_DIR/scripts/periodic/run_arin_ours_round_remote.sh" \
--run-root "$RUN_ROOT" \
--round-id "$ROUND_ID" \
--kind "$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['kind'])
PY
)" \
--ssh-target "$SSH_TARGET" \
--remote-root "$REMOTE_ROOT" \
--scheduled-at "$SCHEDULED_AT" \
--skip-sync &
OURS_PID=$!
"$ROOT_DIR/scripts/periodic/run_arin_rpki_client_round_remote.sh" \
--run-root "$RUN_ROOT" \
--round-id "$ROUND_ID" \
--kind "$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['kind'])
PY
)" \
--ssh-target "$SSH_TARGET" \
--remote-root "$REMOTE_ROOT" \
--scheduled-at "$SCHEDULED_AT" \
--rpki-client-bin "$RPKI_CLIENT_BIN" \
--skip-sync &
CLIENT_PID=$!
set +e
wait "$OURS_PID"
OURS_STATUS=$?
wait "$CLIENT_PID"
CLIENT_STATUS=$?
set -e
if [[ "$OURS_STATUS" -eq 0 && "$CLIENT_STATUS" -eq 0 \
&& -f "$ROUND_DIR/ours/result.ccr" && -f "$ROUND_DIR/rpki-client/result.ccr" ]]; then
"$ROOT_DIR/scripts/periodic/compare_ccr_round.sh" \
--ours-ccr "$ROUND_DIR/ours/result.ccr" \
--rpki-client-ccr "$ROUND_DIR/rpki-client/result.ccr" \
--out-dir "$ROUND_DIR/compare"
fi
python3 - <<'PY' "$ROUND_DIR/round-meta.json" "$ROUND_DIR/ours/round-result.json" "$ROUND_DIR/rpki-client/round-result.json" "$ROUND_DIR/compare/compare-summary.json"
import json, sys
from datetime import datetime, timezone
round_meta_path, ours_result_path, client_result_path, compare_path = sys.argv[1:]
meta = json.load(open(round_meta_path, 'r', encoding='utf-8'))
ours = json.load(open(ours_result_path, 'r', encoding='utf-8'))
client = json.load(open(client_result_path, 'r', encoding='utf-8'))
scheduled = datetime.fromisoformat(meta['scheduledAt'].replace('Z', '+00:00')).astimezone(timezone.utc)
started_candidates = []
for item in (ours, client):
if item.get('startedAt'):
started_candidates.append(datetime.fromisoformat(item['startedAt'].replace('Z', '+00:00')).astimezone(timezone.utc))
finished_candidates = []
for item in (ours, client):
if item.get('finishedAt'):
finished_candidates.append(datetime.fromisoformat(item['finishedAt'].replace('Z', '+00:00')).astimezone(timezone.utc))
if started_candidates:
started_at = min(started_candidates)
meta['startedAt'] = started_at.strftime('%Y-%m-%dT%H:%M:%SZ')
lag_ms = int((started_at - scheduled).total_seconds() * 1000)
meta['startLagMs'] = max(lag_ms, 0)
if finished_candidates:
finished_at = max(finished_candidates)
meta['finishedAt'] = finished_at.strftime('%Y-%m-%dT%H:%M:%SZ')
meta['status'] = 'completed' if ours.get('exitCode') == 0 and client.get('exitCode') == 0 else 'failed'
meta['ours'] = {
'exitCode': ours.get('exitCode'),
'durationMs': json.load(open(ours_result_path.replace('round-result.json', 'timing.json'), 'r', encoding='utf-8')).get('durationMs'),
}
meta['rpkiClient'] = {
'exitCode': client.get('exitCode'),
'durationMs': json.load(open(client_result_path.replace('round-result.json', 'timing.json'), 'r', encoding='utf-8')).get('durationMs'),
}
if compare_path and __import__('pathlib').Path(compare_path).exists():
compare = json.load(open(compare_path, 'r', encoding='utf-8'))
meta['compare'] = {
'allMatch': compare.get('allMatch'),
'vrpMatch': compare.get('vrps', {}).get('match'),
'vapMatch': compare.get('vaps', {}).get('match'),
}
json.dump(meta, open(round_meta_path, 'w', encoding='utf-8'), indent=2)
PY
done
python3 - <<'PY' "$RUN_ROOT/final-summary.json" "$RUN_ROOT/rounds"
import json, sys
from pathlib import Path
summary_path = Path(sys.argv[1])
rounds_root = Path(sys.argv[2])
rounds = []
all_match = True
for round_dir in sorted(rounds_root.glob('round-*')):
meta = json.load(open(round_dir / 'round-meta.json', 'r', encoding='utf-8'))
rounds.append(meta)
compare = meta.get('compare')
if compare is None or compare.get('allMatch') is not True:
all_match = False
summary = {
'version': 1,
'status': 'completed',
'roundCount': len(rounds),
'allMatch': all_match,
'rounds': rounds,
}
json.dump(summary, open(summary_path, 'w', encoding='utf-8'), indent=2)
PY
echo "$RUN_ROOT"

View File

@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_arin_ours_round_remote.sh \
--run-root <path> \
--round-id <round-XXX> \
--kind <snapshot|delta> \
--ssh-target <user@host> \
--remote-root <path> \
[--scheduled-at <RFC3339>] \
[--skip-sync]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
ROUND_ID=""
KIND=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
SCHEDULED_AT=""
SKIP_SYNC=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--round-id) ROUND_ID="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--scheduled-at) SCHEDULED_AT="$2"; shift 2 ;;
--skip-sync) SKIP_SYNC=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" && -n "$ROUND_ID" && -n "$KIND" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
[[ "$KIND" == "snapshot" || "$KIND" == "delta" ]] || { echo "--kind must be snapshot or delta" >&2; exit 2; }
LOCAL_OUT="$RUN_ROOT/rounds/$ROUND_ID/ours"
REMOTE_REPO="$REMOTE_ROOT/repo"
REMOTE_OUT="$REMOTE_ROOT/rounds/$ROUND_ID/ours"
REMOTE_WORK_DB="$REMOTE_ROOT/state/ours/work-db"
REMOTE_RAW_STORE="$REMOTE_ROOT/state/ours/raw-store.db"
REMOTE_REPO_BYTES="$REMOTE_ROOT/state/ours/repo-bytes.db"
mkdir -p "$LOCAL_OUT"
if [[ "$SKIP_SYNC" -eq 0 ]]; then
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_REPO/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_REPO/target/release' '$REMOTE_OUT' '$REMOTE_ROOT/state/ours'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_REPO/target/release/"
else
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_OUT' '$REMOTE_ROOT/state/ours'"
fi
ssh "$SSH_TARGET" \
REMOTE_REPO="$REMOTE_REPO" \
REMOTE_OUT="$REMOTE_OUT" \
REMOTE_WORK_DB="$REMOTE_WORK_DB" \
REMOTE_RAW_STORE="$REMOTE_RAW_STORE" \
REMOTE_REPO_BYTES="$REMOTE_REPO_BYTES" \
KIND="$KIND" \
ROUND_ID="$ROUND_ID" \
SCHEDULED_AT="$SCHEDULED_AT" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_REPO"
mkdir -p "$REMOTE_OUT"
if [[ "$KIND" == "snapshot" ]]; then
rm -rf "$REMOTE_WORK_DB" "$REMOTE_RAW_STORE" "$REMOTE_REPO_BYTES"
fi
mkdir -p "$(dirname "$REMOTE_WORK_DB")"
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
ccr_out="$REMOTE_OUT/result.ccr"
report_out="$REMOTE_OUT/report.json"
run_log="$REMOTE_OUT/run.log"
timing_out="$REMOTE_OUT/timing.json"
meta_out="$REMOTE_OUT/round-result.json"
set +e
env RPKI_PROGRESS_LOG=1 target/release/rpki \
--db "$REMOTE_WORK_DB" \
--raw-store-db "$REMOTE_RAW_STORE" \
--repo-bytes-db "$REMOTE_REPO_BYTES" \
--tal-path tests/fixtures/tal/arin.tal \
--ta-path tests/fixtures/ta/arin-ta.cer \
--ccr-out "$ccr_out" \
--report-json "$report_out" \
>"$run_log" 2>&1
exit_code=$?
set -e
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "$timing_out" "$started_at_ms" "$finished_at_ms" "$started_at_iso" "$finished_at_iso" "$exit_code"
import json, sys
path, start_ms, end_ms, started_at, finished_at, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"durationMs": int(end_ms) - int(start_ms),
"startedAt": started_at,
"finishedAt": finished_at,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
python3 - <<'PY' "$meta_out" "$ROUND_ID" "$KIND" "$SCHEDULED_AT" "$started_at_iso" "$finished_at_iso" "$REMOTE_WORK_DB" "$REMOTE_RAW_STORE" "$exit_code"
import json, sys
path, round_id, kind, scheduled_at, started_at, finished_at, work_db, raw_store, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"roundId": round_id,
"kind": kind,
"scheduledAt": scheduled_at or None,
"startedAt": started_at,
"finishedAt": finished_at,
"remoteWorkDbPath": work_db,
"remoteRawStoreDbPath": raw_store,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
exit "$exit_code"
EOS
rsync -a "$SSH_TARGET:$REMOTE_OUT/" "$LOCAL_OUT/"
echo "$LOCAL_OUT"

View File

@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_arin_rpki_client_round_remote.sh \
--run-root <path> \
--round-id <round-XXX> \
--kind <snapshot|delta> \
--ssh-target <user@host> \
--remote-root <path> \
[--scheduled-at <RFC3339>] \
[--rpki-client-bin <local path>] \
[--skip-sync]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
ROUND_ID=""
KIND=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
SCHEDULED_AT=""
RPKI_CLIENT_BIN="${RPKI_CLIENT_BIN:-/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client}"
SKIP_SYNC=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--round-id) ROUND_ID="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--scheduled-at) SCHEDULED_AT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--skip-sync) SKIP_SYNC=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" && -n "$ROUND_ID" && -n "$KIND" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
[[ "$KIND" == "snapshot" || "$KIND" == "delta" ]] || { echo "--kind must be snapshot or delta" >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
LOCAL_OUT="$RUN_ROOT/rounds/$ROUND_ID/rpki-client"
REMOTE_REPO="$REMOTE_ROOT/repo"
REMOTE_BIN_DIR="$REMOTE_ROOT/bin"
REMOTE_BIN="$REMOTE_BIN_DIR/rpki-client"
REMOTE_OUT="$REMOTE_ROOT/rounds/$ROUND_ID/rpki-client"
REMOTE_CACHE="$REMOTE_ROOT/state/rpki-client/cache"
REMOTE_STATE_OUT="$REMOTE_ROOT/state/rpki-client/out"
REMOTE_STATE_ROOT="$REMOTE_ROOT/state/rpki-client"
mkdir -p "$LOCAL_OUT"
if [[ "$SKIP_SYNC" -eq 0 ]]; then
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_REPO/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_BIN_DIR' '$REMOTE_OUT' '$REMOTE_STATE_ROOT'"
rsync -a "$RPKI_CLIENT_BIN" "$SSH_TARGET:$REMOTE_BIN"
else
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_BIN_DIR' '$REMOTE_OUT' '$REMOTE_STATE_ROOT'"
fi
ssh "$SSH_TARGET" \
REMOTE_ROOT="$REMOTE_ROOT" \
REMOTE_REPO="$REMOTE_REPO" \
REMOTE_BIN="$REMOTE_BIN" \
REMOTE_OUT="$REMOTE_OUT" \
REMOTE_CACHE="$REMOTE_CACHE" \
REMOTE_STATE_OUT="$REMOTE_STATE_OUT" \
REMOTE_STATE_ROOT="$REMOTE_STATE_ROOT" \
KIND="$KIND" \
ROUND_ID="$ROUND_ID" \
SCHEDULED_AT="$SCHEDULED_AT" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_ROOT"
mkdir -p "$REMOTE_OUT"
if [[ "$KIND" == "snapshot" ]]; then
rm -rf "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$REMOTE_STATE_ROOT/ta" "$REMOTE_STATE_ROOT/.ta"
fi
mkdir -p "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$REMOTE_STATE_ROOT/ta" "$REMOTE_STATE_ROOT/.ta"
chmod -R 0777 "$REMOTE_STATE_ROOT"
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
ccr_out="$REMOTE_OUT/result.ccr"
run_log="$REMOTE_OUT/run.log"
timing_out="$REMOTE_OUT/timing.json"
meta_out="$REMOTE_OUT/round-result.json"
set +e
(
cd "$REMOTE_STATE_ROOT"
LD_LIBRARY_PATH="$REMOTE_ROOT/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
"$REMOTE_BIN" -vv -t "../../repo/tests/fixtures/tal/arin.tal" -d "cache" "out"
) >"$run_log" 2>&1
exit_code=$?
set -e
if [[ -f "$REMOTE_STATE_OUT/rpki.ccr" ]]; then
cp "$REMOTE_STATE_OUT/rpki.ccr" "$ccr_out"
fi
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "$timing_out" "$started_at_ms" "$finished_at_ms" "$started_at_iso" "$finished_at_iso" "$exit_code"
import json, sys
path, start_ms, end_ms, started_at, finished_at, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"durationMs": int(end_ms) - int(start_ms),
"startedAt": started_at,
"finishedAt": finished_at,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
python3 - <<'PY' "$meta_out" "$ROUND_ID" "$KIND" "$SCHEDULED_AT" "$started_at_iso" "$finished_at_iso" "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$exit_code"
import json, sys
path, round_id, kind, scheduled_at, started_at, finished_at, cache_path, out_path, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"roundId": round_id,
"kind": kind,
"scheduledAt": scheduled_at or None,
"startedAt": started_at,
"finishedAt": finished_at,
"remoteCachePath": cache_path,
"remoteOutPath": out_path,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
exit "$exit_code"
EOS
rsync -a "$SSH_TARGET:$REMOTE_OUT/" "$LOCAL_OUT/"
echo "$LOCAL_OUT"

View File

@ -0,0 +1,45 @@
# Replay Verify Scripts
## `run_multi_rir_ccr_replay_verify.sh`
用途:
- 通用 multi-RIR CCR replay verify 入口
- 通过 `--rir` 指定一个或多个 RIR按顺序执行
- 通过 `--mode` 指定 `snapshot``delta``both`
- 默认每个 RIR 的 RocksDB 目录在 compare/verify 结束后自动删除;传 `--keep-db` 才保留
- 同一次执行的所有产物都会先落到 `rpki/target/replay/<timestamp>/`
- 该时间戳目录下再按 RIR 分目录:
- `<rir>_ccr_replay_<timestamp>`
默认输入:
- 历史 replay fixture root: `/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3`
- 每个 RIR 的 TAL / TA / validation time / record CSV 由 `scripts/payload_replay/multi_rir_case_info.py` 解析
用法:
- 单个 RIR
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode both`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode snapshot`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode delta`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode both`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic,ripe --mode snapshot`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir afrinic,apnic,arin,lacnic,ripe --mode both`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode delta --keep-db`
可覆盖环境变量:
- `BUNDLE_ROOT`
- `OUT_ROOT`(默认:`rpki/target/replay`
- `RUN_TAG`
主要产物:
- 单次执行根目录:
- `rpki/target/replay/<timestamp>/`
- 每个 RIR 子目录下:
- `<rir>_snapshot.ccr`
- `<rir>_delta.ccr`
- `<rir>_*_report.json`
- `<rir>_*_ccr_vrps.csv`
- `<rir>_*_ccr_compare_summary.md`
- `<rir>_*_ccr_verify.json`
- 同次执行总汇总:
- `multi_rir_ccr_replay_verify_<timestamp>_summary.md`
- `multi_rir_ccr_replay_verify_<timestamp>_summary.json`

View File

@ -0,0 +1,284 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
usage() {
cat <<'USAGE'
Usage:
run_multi_rir_ccr_replay_verify.sh --rir <rir[,rir...]> [--mode snapshot|delta|both] [--keep-db]
Options:
--rir <list> Comma-separated RIR list, e.g. apnic or apnic,ripe
--mode <mode> snapshot | delta | both (default: both)
--keep-db Keep per-run RocksDB directories (default: remove after verify)
--bundle-root <p> Override bundle root
--out-root <p> Override output root (default: rpki/target/replay)
--run-tag <tag> Override timestamp suffix for all RIR runs
USAGE
}
MODE="both"
KEEP_DB=0
RIR_LIST=""
BUNDLE_ROOT="${BUNDLE_ROOT:-/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3}"
OUT_ROOT="${OUT_ROOT:-$ROOT_DIR/target/replay}"
RUN_TAG="${RUN_TAG:-$(date -u +%Y%m%dT%H%M%SZ)}"
while [[ $# -gt 0 ]]; do
case "$1" in
--rir)
shift
RIR_LIST="${1:-}"
;;
--mode)
shift
MODE="${1:-}"
;;
--keep-db)
KEEP_DB=1
;;
--bundle-root)
shift
BUNDLE_ROOT="${1:-}"
;;
--out-root)
shift
OUT_ROOT="${1:-}"
;;
--run-tag)
shift
RUN_TAG="${1:-}"
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift || true
done
if [[ -z "$RIR_LIST" ]]; then
echo "--rir is required" >&2
usage >&2
exit 2
fi
case "$MODE" in
snapshot|delta|both) ;;
*)
echo "invalid --mode: $MODE" >&2
usage >&2
exit 2
;;
esac
CASE_INFO_SCRIPT="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
mkdir -p "$OUT_ROOT"
RUN_ROOT="$OUT_ROOT/$RUN_TAG"
mkdir -p "$RUN_ROOT"
cargo build --release --bin rpki --bin ccr_to_routinator_csv --bin ccr_verify >/dev/null
summary_md="$RUN_ROOT/multi_rir_ccr_replay_verify_${RUN_TAG}_summary.md"
summary_json="$RUN_ROOT/multi_rir_ccr_replay_verify_${RUN_TAG}_summary.json"
python3 - <<'PY' >/dev/null
PY
summary_json_tmp="$(mktemp)"
printf '[]' > "$summary_json_tmp"
run_one_mode() {
local rir="$1"
local mode="$2"
local run_dir="$3"
local trust_anchor="$4"
local tal_path="$5"
local ta_path="$6"
local base_archive="$7"
local base_locks="$8"
local base_csv="$9"
local delta_archive="${10}"
local delta_locks="${11}"
local delta_csv="${12}"
local snapshot_validation_time="${13}"
local delta_validation_time="${14}"
local db_dir="$run_dir/${rir}_${mode}_db"
local report_json="$run_dir/${rir}_${mode}_report.json"
local run_log="$run_dir/${rir}_${mode}_run.log"
local ccr_path="$run_dir/${rir}_${mode}.ccr"
local csv_path="$run_dir/${rir}_${mode}_ccr_vrps.csv"
local compare_md="$run_dir/${rir}_${mode}_ccr_compare_summary.md"
local only_ours="$run_dir/${rir}_${mode}_ccr_only_in_ours.csv"
local only_record="$run_dir/${rir}_${mode}_ccr_only_in_record.csv"
local verify_json="$run_dir/${rir}_${mode}_ccr_verify.json"
local meta_json="$run_dir/${rir}_${mode}_meta.json"
rm -rf "$db_dir"
local -a cmd=(target/release/rpki --db "$db_dir" --tal-path "$tal_path" --ta-path "$ta_path")
if [[ "$mode" == "snapshot" ]]; then
cmd+=(--payload-replay-archive "$base_archive" --payload-replay-locks "$base_locks" --validation-time "$snapshot_validation_time")
else
cmd+=(
--payload-base-archive "$base_archive"
--payload-base-locks "$base_locks"
--payload-base-validation-time "$snapshot_validation_time"
--payload-delta-archive "$delta_archive"
--payload-delta-locks "$delta_locks"
--validation-time "$delta_validation_time"
)
fi
cmd+=(--report-json "$report_json" --ccr-out "$ccr_path")
local start_s end_s duration_s
start_s="$(date +%s)"
(
echo "# ${rir} ${mode} command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$run_log" >/dev/null
end_s="$(date +%s)"
duration_s="$((end_s - start_s))"
target/release/ccr_to_routinator_csv \
--ccr "$ccr_path" \
--out "$csv_path" \
--trust-anchor "$trust_anchor" >/dev/null
local record_csv
if [[ "$mode" == "snapshot" ]]; then
record_csv="$base_csv"
else
record_csv="$delta_csv"
fi
./scripts/payload_replay/compare_with_routinator_record.sh \
"$csv_path" \
"$record_csv" \
"$compare_md" \
"$only_ours" \
"$only_record" >/dev/null
target/release/ccr_verify \
--ccr "$ccr_path" \
--db "$db_dir" > "$verify_json"
python3 - "$report_json" "$meta_json" "$mode" "$duration_s" <<'PY'
import json, sys
from pathlib import Path
report = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
meta = {
'mode': sys.argv[3],
'duration_seconds': int(sys.argv[4]),
'validation_time': report.get('validation_time_rfc3339_utc'),
'publication_points_processed': report['tree']['instances_processed'],
'publication_points_failed': report['tree']['instances_failed'],
'vrps': len(report['vrps']),
'aspas': len(report['aspas']),
}
Path(sys.argv[2]).write_text(json.dumps(meta, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
PY
if [[ "$KEEP_DB" -eq 0 ]]; then
rm -rf "$db_dir"
fi
}
IFS=',' read -r -a RIRS <<< "$RIR_LIST"
for rir in "${RIRS[@]}"; do
rir="$(echo "$rir" | xargs)"
[[ -n "$rir" ]] || continue
eval "$(python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir "$rir" --format env)"
run_dir="$RUN_ROOT/${rir}_ccr_replay_${RUN_TAG}"
mkdir -p "$run_dir"
if [[ "$MODE" == "snapshot" || "$MODE" == "both" ]]; then
run_one_mode \
"$rir" snapshot "$run_dir" "$TRUST_ANCHOR" "$TAL_PATH" "$TA_PATH" \
"$PAYLOAD_REPLAY_ARCHIVE" "$PAYLOAD_REPLAY_LOCKS" "$ROUTINATOR_BASE_RECORD_CSV" \
"$PAYLOAD_DELTA_ARCHIVE" "$PAYLOAD_DELTA_LOCKS" "$ROUTINATOR_DELTA_RECORD_CSV" \
"$SNAPSHOT_VALIDATION_TIME" "$DELTA_VALIDATION_TIME"
fi
if [[ "$MODE" == "delta" || "$MODE" == "both" ]]; then
run_one_mode \
"$rir" delta "$run_dir" "$TRUST_ANCHOR" "$TAL_PATH" "$TA_PATH" \
"$PAYLOAD_REPLAY_ARCHIVE" "$PAYLOAD_REPLAY_LOCKS" "$ROUTINATOR_BASE_RECORD_CSV" \
"$PAYLOAD_DELTA_ARCHIVE" "$PAYLOAD_DELTA_LOCKS" "$ROUTINATOR_DELTA_RECORD_CSV" \
"$SNAPSHOT_VALIDATION_TIME" "$DELTA_VALIDATION_TIME"
fi
python3 - "$summary_json_tmp" "$run_dir" "$rir" "$MODE" <<'PY'
import json, sys
from pathlib import Path
summary_path = Path(sys.argv[1])
run_dir = Path(sys.argv[2])
rir = sys.argv[3]
mode = sys.argv[4]
rows = json.loads(summary_path.read_text(encoding='utf-8'))
for submode in ['snapshot','delta']:
if mode not in ('both', submode):
continue
compare = run_dir / f'{rir}_{submode}_ccr_compare_summary.md'
meta = run_dir / f'{rir}_{submode}_meta.json'
verify = run_dir / f'{rir}_{submode}_ccr_verify.json'
if not compare.exists() or not meta.exists() or not verify.exists():
continue
compare_text = compare.read_text(encoding='utf-8')
meta_obj = json.loads(meta.read_text(encoding='utf-8'))
verify_obj = json.loads(verify.read_text(encoding='utf-8'))
def metric(name):
prefix = f'| {name} | '
for line in compare_text.splitlines():
if line.startswith(prefix):
return int(line.split('|')[2].strip())
raise SystemExit(f'missing metric {name} in {compare}')
rows.append({
'rir': rir,
'mode': submode,
'run_dir': str(run_dir),
'duration_seconds': meta_obj['duration_seconds'],
'vrps': meta_obj['vrps'],
'aspas': meta_obj['aspas'],
'only_in_ours': metric('only_in_ours'),
'only_in_record': metric('only_in_record'),
'intersection': metric('intersection'),
'state_hashes_ok': verify_obj.get('state_hashes_ok'),
})
summary_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
PY
done
python3 - "$summary_json_tmp" "$summary_json" "$summary_md" "$RUN_TAG" <<'PY'
import json, sys
from pathlib import Path
rows = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
out_json = Path(sys.argv[2])
out_md = Path(sys.argv[3])
run_tag = sys.argv[4]
out_json.write_text(json.dumps(rows, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
parts = []
parts.append('# Multi-RIR CCR Replay Verify Summary\n\n')
parts.append(f'- run_tag: `{run_tag}`\n\n')
parts.append('| rir | mode | duration_s | vrps | aspas | only_in_ours | only_in_record | state_hashes_ok |\n')
parts.append('|---|---|---:|---:|---:|---:|---:|---|\n')
for row in rows:
parts.append(f"| {row['rir']} | {row['mode']} | {row['duration_seconds']} | {row['vrps']} | {row['aspas']} | {row['only_in_ours']} | {row['only_in_record']} | {row['state_hashes_ok']} |\n")
out_md.write_text(''.join(parts), encoding='utf-8')
PY
rm -f "$summary_json_tmp"
echo "== multi-rir replay verify complete ==" >&2
echo "- summary: $summary_md" >&2
echo "- summary json: $summary_json" >&2

View File

@ -0,0 +1,172 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PROFILE="${PROFILE:-release}"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/target/portable-soak}"
PACKAGE_PREFIX="${PACKAGE_PREFIX:-portable-soak}"
PACKAGE_DIR_NAME="${PACKAGE_DIR_NAME:-portable-soak}"
usage() {
cat <<'USAGE'
Usage:
scripts/soak/build_portable_soak_package.sh [--out-dir <path>] [--profile <profile>]
Requires release binaries to already exist. Build them first, for example:
cargo build --release --bin rpki --bin rpki_daemon --bin db_stats
USAGE
}
die() {
echo "error: $*" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--out-dir)
shift
OUT_DIR="${1:?--out-dir requires a value}"
;;
--profile)
shift
PROFILE="${1:?--profile requires a value}"
;;
--help|-h)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
shift
done
command -v python3 >/dev/null 2>&1 || die "python3 is required"
command -v tar >/dev/null 2>&1 || die "tar is required"
if [[ "$PROFILE" == "release" ]]; then
TARGET_BIN_DIR="$REPO_ROOT/target/release"
else
TARGET_BIN_DIR="$REPO_ROOT/target/$PROFILE"
fi
REQUIRED_BINS=(rpki rpki_daemon db_stats)
OPTIONAL_BINS=(
ccr_dump
ccr_state_compare
ccr_to_compare_views
ccr_to_routinator_csv
ccr_verify
cir_drop_report
cir_dump_reject_list
cir_extract_inputs
cir_materialize
cir_probe_rpki_client_cache
cir_state_compare
rrdp_state_dump
)
for binary_name in "${REQUIRED_BINS[@]}"; do
[[ -x "$TARGET_BIN_DIR/$binary_name" ]] || die "missing required binary: $TARGET_BIN_DIR/$binary_name"
done
mkdir -p "$OUT_DIR"
GIT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || printf 'unknown')"
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
PACKAGE_NAME="${PACKAGE_PREFIX}-${TIMESTAMP}-${GIT_SHA}"
BUILD_ROOT="$REPO_ROOT/target/portable-soak-build"
STAGE_DIR="$BUILD_ROOT/$PACKAGE_DIR_NAME"
ARCHIVE_PATH="$OUT_DIR/$PACKAGE_NAME.tar.gz"
rm -rf "$STAGE_DIR" "$ARCHIVE_PATH"
mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/fixtures" "$STAGE_DIR/scripts" \
"$STAGE_DIR/runs" "$STAGE_DIR/state" "$STAGE_DIR/logs" "$STAGE_DIR/tmp"
install -m 0755 "$SCRIPT_DIR/run_soak.sh" "$STAGE_DIR/run_soak.sh"
install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/.env"
install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/portable-soak.env.example"
COPIED_BIN_LIST="$STAGE_DIR/copied-binaries.txt"
MISSING_OPTIONAL_BIN_LIST="$STAGE_DIR/missing-optional-binaries.txt"
: > "$COPIED_BIN_LIST"
: > "$MISSING_OPTIONAL_BIN_LIST"
for binary_name in "${REQUIRED_BINS[@]}"; do
install -m 0755 "$TARGET_BIN_DIR/$binary_name" "$STAGE_DIR/bin/$binary_name"
printf '%s\n' "$binary_name" >> "$COPIED_BIN_LIST"
done
for binary_name in "${OPTIONAL_BINS[@]}"; do
if [[ -x "$TARGET_BIN_DIR/$binary_name" ]]; then
install -m 0755 "$TARGET_BIN_DIR/$binary_name" "$STAGE_DIR/bin/$binary_name"
printf '%s\n' "$binary_name" >> "$COPIED_BIN_LIST"
else
printf '%s\n' "$binary_name" >> "$MISSING_OPTIONAL_BIN_LIST"
fi
done
cp -a "$REPO_ROOT/tests/fixtures/tal" "$STAGE_DIR/fixtures/"
cp -a "$REPO_ROOT/tests/fixtures/ta" "$STAGE_DIR/fixtures/"
cp -a "$REPO_ROOT/scripts/periodic" "$STAGE_DIR/scripts/"
cp -a "$REPO_ROOT/scripts/cir" "$STAGE_DIR/scripts/"
find "$STAGE_DIR/scripts" -type d -name __pycache__ -prune -exec rm -rf {} +
(cd "$STAGE_DIR" && find fixtures -type f | sort > fixtures.txt)
(cd "$STAGE_DIR" && find scripts -type f | sort > scripts.txt)
GIT_DIRTY="false"
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null || true)" ]]; then
GIT_DIRTY="true"
fi
GIT_STATUS="$(git -C "$REPO_ROOT" status --short 2>/dev/null || true)"
python3 - "$STAGE_DIR/manifest.json" "$PACKAGE_NAME" "$TIMESTAMP" "$REPO_ROOT" "$GIT_SHA" \
"$GIT_DIRTY" "$PROFILE" "$TARGET_BIN_DIR" "$GIT_STATUS" <<'PY'
import json
import pathlib
import sys
(
manifest_path,
package_name,
created_at,
repo_root,
git_sha,
git_dirty,
profile,
target_bin_dir,
git_status,
) = sys.argv[1:]
stage_dir = pathlib.Path(manifest_path).parent
def read_lines(name):
path = stage_dir / name
if not path.exists():
return []
return [line for line in path.read_text(encoding="utf-8").splitlines() if line]
manifest = {
"packageName": package_name,
"createdAtUtc": created_at,
"sourceRepo": repo_root,
"gitCommit": git_sha,
"gitDirty": git_dirty == "true",
"gitStatusShort": git_status.splitlines(),
"rustProfile": profile,
"targetBinDir": target_bin_dir,
"copiedBinaries": read_lines("copied-binaries.txt"),
"missingOptionalBinaries": read_lines("missing-optional-binaries.txt"),
"fixtures": read_lines("fixtures.txt"),
"scripts": read_lines("scripts.txt"),
}
pathlib.Path(manifest_path).write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
tar -C "$BUILD_ROOT" -czf "$ARCHIVE_PATH" "$PACKAGE_DIR_NAME"
printf '%s\n' "$ARCHIVE_PATH"

View File

@ -0,0 +1,71 @@
# portable soak 运行配置。
# 复制为 .env 后可以在远端直接调整;所有路径默认相对 package 根目录。
# 最大运行轮次。重复执行 run_soak.sh 时会从已有最后一轮之后继续编号。
# 正整数表示固定运行轮次;负数(例如 -1表示持续运行不自动停止0 非法。
MAX_RUNS=3
# 两轮之间等待秒数。做连续无等待验收时设置为 0。
INTERVAL_SECS=0
# 要运行的 RIR 列表,逗号分隔。
# 合法值只有afrinic, apnic, arin, lacnic, ripe。
# 示例RIRS=apnic,arin 或 RIRS=afrinic,apnic,arin,lacnic,ripe
RIRS=afrinic,apnic,arin,lacnic,ripe
# 运行根目录。默认使用 package 根目录;如需把产物写到独立数据盘,可改成绝对路径。
RUN_ROOT="${PACKAGE_ROOT}"
# 保留最近多少轮 run 目录。持续运行模式建议设置为 5 或按磁盘容量评估。
RETAIN_RUNS=10
# 是否输出 compact report JSON。1 表示启用0 表示关闭。
OUTPUT_COMPACT_REPORT=1
# 是否复用持久 rsync mirror。1 表示跨 run 复用;失败隔离数据库时也不会清理 mirror。
ALLOW_RSYNC_MIRROR_REUSE=1
# rsync 同步/去重 scope。
# module-root 表示扩大实际拉取到 rsync module 根目录,并在同一 module 下复用成功拉取结果;
# host 表示按 rsync host 做失败短路,但实际拉取仍限定当前发布点,避免同一不可达 host 重复等待超时;
# publication-point 表示只按当前发布点去重。
RSYNC_SCOPE=module-root
# 前一轮失败或不完整时,是否隔离旧数据库和运行态目录后强制下一轮 snapshot。
# 建议保持 1设置为 0 时,检测到前一轮失败会直接停止。
FAILURE_SNAPSHOT_RESET=1
# 每隔多少轮执行一次 db_stats --exact。设置为空或 0 表示关闭 exact 统计。
DB_STATS_EXACT_EVERY=3
# 是否开启 ours RP progress log。1 表示开启。
RPKI_PROGRESS_LOG=1
# progress log 慢步骤阈值,单位秒。
RPKI_PROGRESS_SLOW_SECS=10
# phase2 stage fresh 慢发布点阈值,单位毫秒。
RPKI_PROGRESS_STAGE_FRESH_SLOW_MS=1000
# phase2 PP 控制面慢发布点阈值,单位毫秒。
RPKI_PROGRESS_PP_CONTROL_SLOW_MS=100
# 是否在运行前尝试禁用 rpki-client timer 并杀掉竞争 RP 进程。
DISABLE_COMPETING_RPS=1
# 传给 rpki 子进程的额外参数。多个参数用空格分隔。
# 示例RPKI_EXTRA_ARGS="--enable-roa-validation-cache"
# 实验性 transport 预热RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-roa-validation-cache"
RPKI_EXTRA_ARGS=""
# 是否为每轮输出 timing profile 到 runs/run_xxxx/analyze/timing.json。
# 性能 profile 或打点验证时设置为 1普通 soak 建议保持 0避免额外开销。
RPKI_ANALYZE=0
# 可选覆盖路径;默认由 package 自动推导。
# BIN_DIR="${PACKAGE_ROOT}/bin"
# FIXTURE_DIR="${PACKAGE_ROOT}/fixtures"
# DB_DIR="${RUN_ROOT}/state/db"
# META_DIR="${RUN_ROOT}/state/meta"
# TMP_DIR="${RUN_ROOT}/tmp"
# RSYNC_MIRROR_ROOT="${RUN_ROOT}/state/rsync-mirror"

700
scripts/soak/run_soak.sh Executable file
View File

@ -0,0 +1,700 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_ROOT="${PACKAGE_ROOT:-$SCRIPT_DIR}"
ENV_FILE="${ENV_FILE:-$PACKAGE_ROOT/.env}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
MAX_RUNS="${MAX_RUNS:-3}"
INTERVAL_SECS="${INTERVAL_SECS:-0}"
RIRS="${RIRS:-afrinic,apnic,arin,lacnic,ripe}"
RUN_ROOT="${RUN_ROOT:-$PACKAGE_ROOT}"
RETAIN_RUNS="${RETAIN_RUNS:-10}"
OUTPUT_COMPACT_REPORT="${OUTPUT_COMPACT_REPORT:-1}"
ALLOW_RSYNC_MIRROR_REUSE="${ALLOW_RSYNC_MIRROR_REUSE:-1}"
RSYNC_SCOPE="${RSYNC_SCOPE:-module-root}"
FAILURE_SNAPSHOT_RESET="${FAILURE_SNAPSHOT_RESET:-1}"
DB_STATS_EXACT_EVERY="${DB_STATS_EXACT_EVERY:-3}"
RPKI_PROGRESS_LOG="${RPKI_PROGRESS_LOG:-1}"
RPKI_PROGRESS_SLOW_SECS="${RPKI_PROGRESS_SLOW_SECS:-10}"
RPKI_PROGRESS_STAGE_FRESH_SLOW_MS="${RPKI_PROGRESS_STAGE_FRESH_SLOW_MS:-1000}"
RPKI_PROGRESS_PP_CONTROL_SLOW_MS="${RPKI_PROGRESS_PP_CONTROL_SLOW_MS:-100}"
DISABLE_COMPETING_RPS="${DISABLE_COMPETING_RPS:-1}"
RPKI_EXTRA_ARGS="${RPKI_EXTRA_ARGS:-}"
RPKI_ANALYZE="${RPKI_ANALYZE:-0}"
BIN_DIR="${BIN_DIR:-$PACKAGE_ROOT/bin}"
FIXTURE_DIR="${FIXTURE_DIR:-$PACKAGE_ROOT/fixtures}"
STATE_ROOT="$RUN_ROOT/state"
RUNS_ROOT="$RUN_ROOT/runs"
LOG_ROOT="$RUN_ROOT/logs"
DB_DIR="${DB_DIR:-$STATE_ROOT/db}"
META_DIR="${META_DIR:-$STATE_ROOT/meta}"
TMP_DIR="${TMP_DIR:-$RUN_ROOT/tmp}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-$STATE_ROOT/rsync-mirror}"
INVALID_ROOT="$STATE_ROOT/invalid"
RPKI_BIN="$BIN_DIR/rpki"
RPKI_DAEMON_BIN="$BIN_DIR/rpki_daemon"
DB_STATS_BIN="$BIN_DIR/db_stats"
usage() {
cat <<'USAGE'
Usage:
./run_soak.sh
配置来自 package 根目录下的 .env也可以用 ENV_FILE=/path/to/.env 覆盖。
USAGE
}
die() {
echo "error: $*" >&2
exit 2
}
is_true() {
case "${1:-}" in
1|true|TRUE|yes|YES|on|ON) return 0 ;;
*) return 1 ;;
esac
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
validate_positive_int() {
local name="$1"
local value="$2"
[[ "$value" =~ ^[0-9]+$ ]] || die "$name must be an integer: $value"
[[ "$value" != "0" ]] || die "$name must be > 0"
}
validate_non_negative_int() {
local name="$1"
local value="$2"
[[ "$value" =~ ^[0-9]+$ ]] || die "$name must be an integer: $value"
}
validate_max_runs() {
[[ "$MAX_RUNS" =~ ^-?[0-9]+$ ]] || die "MAX_RUNS must be an integer: $MAX_RUNS"
[[ "$MAX_RUNS" != "0" ]] || die "MAX_RUNS must be non-zero; use a positive value for fixed runs or -1 for continuous mode"
}
validate_rsync_scope() {
case "$RSYNC_SCOPE" in
host|publication-point|module-root)
;;
*)
die "RSYNC_SCOPE must be host, publication-point, or module-root: $RSYNC_SCOPE"
;;
esac
}
normalize_token() {
local token="$1"
token="${token#"${token%%[![:space:]]*}"}"
token="${token%"${token##*[![:space:]]}"}"
printf '%s' "$token" | tr '[:upper:]' '[:lower:]'
}
parse_rirs() {
RIR_LIST=()
local raw_token
local normalized
IFS=',' read -r -a raw_rirs <<< "$RIRS"
for raw_token in "${raw_rirs[@]}"; do
normalized="$(normalize_token "$raw_token")"
[[ -n "$normalized" ]] || continue
case "$normalized" in
afrinic|apnic|arin|lacnic|ripe)
RIR_LIST+=("$normalized")
;;
*)
die "invalid RIRS entry: $raw_token; allowed: afrinic,apnic,arin,lacnic,ripe"
;;
esac
done
[[ "${#RIR_LIST[@]}" -gt 0 ]] || die "RIRS must contain at least one RIR"
}
tal_file_for_rir() {
case "$1" in
afrinic) printf '%s' "$FIXTURE_DIR/tal/afrinic.tal" ;;
apnic) printf '%s' "$FIXTURE_DIR/tal/apnic-rfc7730-https.tal" ;;
arin) printf '%s' "$FIXTURE_DIR/tal/arin.tal" ;;
lacnic) printf '%s' "$FIXTURE_DIR/tal/lacnic.tal" ;;
ripe) printf '%s' "$FIXTURE_DIR/tal/ripe-ncc.tal" ;;
*) die "unknown RIR: $1" ;;
esac
}
ta_file_for_rir() {
case "$1" in
afrinic) printf '%s' "$FIXTURE_DIR/ta/afrinic-ta.cer" ;;
apnic) printf '%s' "$FIXTURE_DIR/ta/apnic-ta.cer" ;;
arin) printf '%s' "$FIXTURE_DIR/ta/arin-ta.cer" ;;
lacnic) printf '%s' "$FIXTURE_DIR/ta/lacnic-ta.cer" ;;
ripe) printf '%s' "$FIXTURE_DIR/ta/ripe-ncc-ta.cer" ;;
*) die "unknown RIR: $1" ;;
esac
}
cir_tal_uri_for_rir() {
case "$1" in
afrinic) printf '%s' "https://rpki.afrinic.net/tal/afrinic.tal" ;;
apnic) printf '%s' "https://rpki.apnic.net/tal/apnic-rfc7730-https.tal" ;;
arin) printf '%s' "https://www.arin.net/resources/manage/rpki/arin.tal" ;;
lacnic) printf '%s' "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal" ;;
ripe) printf '%s' "https://tal.rpki.ripe.net/ripe-ncc.tal" ;;
*) die "unknown RIR: $1" ;;
esac
}
compare_view_trust_anchor() {
if [[ "${#RIR_LIST[@]}" -eq 1 ]]; then
printf '%s' "${RIR_LIST[0]}"
else
printf '%s' "all5"
fi
}
max_existing_run_index() {
local max_index=0
local run_dir
local run_name
local numeric_part
shopt -s nullglob
for run_dir in "$RUNS_ROOT"/run_[0-9][0-9][0-9][0-9]; do
[[ -d "$run_dir" ]] || continue
run_name="$(basename "$run_dir")"
numeric_part="${run_name#run_}"
if (( 10#$numeric_part > max_index )); then
max_index=$((10#$numeric_part))
fi
done
shopt -u nullglob
printf '%s' "$max_index"
}
json_status_is_success() {
local json_path="$1"
python3 - "$json_path" <<'PY'
import json
import sys
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except Exception:
sys.exit(1)
sys.exit(0 if data.get("status") == "success" else 1)
PY
}
previous_run_success() {
local run_dir="$1"
[[ -d "$run_dir" ]] || return 1
[[ -f "$run_dir/run-meta.json" ]] || return 1
[[ -f "$run_dir/run-summary.json" ]] || return 1
json_status_is_success "$run_dir/run-meta.json" || return 1
json_status_is_success "$run_dir/run-summary.json" || return 1
for required_artifact in report.json result.ccr input.cir stage-timing.json process-time.txt stdout.log stderr.log; do
[[ -f "$run_dir/$required_artifact" ]] || return 1
done
return 0
}
move_if_exists() {
local source_path="$1"
local target_dir="$2"
if [[ -e "$source_path" ]]; then
mkdir -p "$target_dir"
mv "$source_path" "$target_dir/"
fi
}
db_state_exists() {
[[ -e "$DB_DIR/work-db" || -e "$DB_DIR/repo-bytes.db" ]]
}
isolate_state_after_failure() {
local previous_run_id="$1"
local timestamp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
local invalid_dir="$INVALID_ROOT/${previous_run_id}-${timestamp}"
mkdir -p "$invalid_dir"
move_if_exists "$DB_DIR" "$invalid_dir"
move_if_exists "$META_DIR" "$invalid_dir"
move_if_exists "$TMP_DIR" "$invalid_dir"
mkdir -p "$DB_DIR" "$META_DIR" "$TMP_DIR"
INVALID_DB_PATH="$invalid_dir/$(basename "$DB_DIR")"
INVALID_STATE_PATH="$invalid_dir/$(basename "$META_DIR")"
INVALID_TMP_PATH="$invalid_dir/$(basename "$TMP_DIR")"
}
write_run_meta() {
local output_path="$1"
local status="$2"
local run_index="$3"
local run_id="$4"
local sync_mode="$5"
local snapshot_reason="$6"
local previous_run_id="$7"
local previous_run_success_value="$8"
local started_at="$9"
local completed_at="${10}"
local invalid_db_path="${11}"
local invalid_state_path="${12}"
local invalid_tmp_path="${13}"
local daemon_exit_code="${14}"
local package_root="${15}"
local env_file="${16}"
python3 - "$output_path" "$status" "$run_index" "$run_id" "$sync_mode" "$snapshot_reason" \
"$previous_run_id" "$previous_run_success_value" "$started_at" "$completed_at" \
"$invalid_db_path" "$invalid_state_path" "$invalid_tmp_path" "$daemon_exit_code" \
"$package_root" "$env_file" <<'PY'
import json
import sys
def nullable(value):
return None if value == "" else value
def nullable_bool(value):
if value == "":
return None
return value == "true"
def nullable_int(value):
if value == "":
return None
return int(value)
(
output_path,
status,
run_index,
run_id,
sync_mode,
snapshot_reason,
previous_run_id,
previous_run_success,
started_at,
completed_at,
invalid_db_path,
invalid_state_path,
invalid_tmp_path,
daemon_exit_code,
package_root,
env_file,
) = sys.argv[1:]
data = {
"status": status,
"run_index": int(run_index),
"run_id": run_id,
"sync_mode": sync_mode,
"snapshot_reason": nullable(snapshot_reason),
"previous_run_id": nullable(previous_run_id),
"previous_run_success": nullable_bool(previous_run_success),
"started_at_rfc3339_utc": started_at,
"completed_at_rfc3339_utc": nullable(completed_at),
"invalid_db_path": nullable(invalid_db_path),
"invalid_state_path": nullable(invalid_state_path),
"invalid_tmp_path": nullable(invalid_tmp_path),
"daemon_exit_code": nullable_int(daemon_exit_code),
"package_root": package_root,
"env_file": env_file,
}
with open(output_path, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
PY
}
summary_status() {
local summary_path="$1"
python3 - "$summary_path" <<'PY'
import json
import sys
try:
with open(sys.argv[1], "r", encoding="utf-8") as handle:
print(json.load(handle).get("status", "missing"))
except Exception:
print("missing")
PY
}
prepare_competing_rp_state() {
if ! is_true "$DISABLE_COMPETING_RPS"; then
return 0
fi
systemctl disable --now rpki-client.timer >/dev/null 2>&1 || true
systemctl stop rpki-client.service >/dev/null 2>&1 || true
pkill -x rpki-client >/dev/null 2>&1 || true
pkill -x routinator >/dev/null 2>&1 || true
}
write_machine_snapshot() {
local suffix="$1"
df -h > "$LOG_ROOT/df-${suffix}.txt" 2>&1 || true
free -h > "$LOG_ROOT/free-${suffix}.txt" 2>&1 || true
ps -eo pid,ppid,stat,pcpu,pmem,rss,args --sort=-pcpu \
| grep -E 'rpki_daemon|/bin/rpki|rpki-client|routinator' \
| grep -v grep > "$LOG_ROOT/process-${suffix}.txt" || true
systemctl is-active rpki-client.timer > "$LOG_ROOT/rpki-client-timer-active-${suffix}.txt" 2>&1 || true
systemctl is-enabled rpki-client.timer > "$LOG_ROOT/rpki-client-timer-enabled-${suffix}.txt" 2>&1 || true
}
build_child_args() {
CHILD_ARGS=(
--db "$DB_DIR/work-db"
--repo-bytes-db "$DB_DIR/repo-bytes.db"
--rsync-scope "$RSYNC_SCOPE"
)
if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then
CHILD_ARGS+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
else
CHILD_ARGS+=(--rsync-mirror-root "$TMP_DIR/rsync-mirror-{run_id}")
fi
CHILD_ARGS+=(
--parallel-phase2-ready-batch-size 256
--parallel-phase2-ready-batch-wall-time-budget-ms 100
--parallel-phase2-result-drain-batch-size 2048
--parallel-phase2-finalize-batch-size 256
--parallel-phase2-finalize-batch-wall-time-budget-ms 100
)
local rir_name
for rir_name in "${RIR_LIST[@]}"; do
CHILD_ARGS+=(--tal-path "$(tal_file_for_rir "$rir_name")")
CHILD_ARGS+=(--ta-path "$(ta_file_for_rir "$rir_name")")
done
CHILD_ARGS+=(
--report-json "{run_out}/report.json"
)
if is_true "$OUTPUT_COMPACT_REPORT"; then
CHILD_ARGS+=(--report-json-compact)
fi
CHILD_ARGS+=(
--ccr-out "{run_out}/result.ccr"
--cir-enable
--cir-out "{run_out}/input.cir"
)
for rir_name in "${RIR_LIST[@]}"; do
CHILD_ARGS+=(--cir-tal-uri "$(cir_tal_uri_for_rir "$rir_name")")
done
CHILD_ARGS+=(
--vrps-csv-out "{run_out}/vrps.csv"
--vaps-csv-out "{run_out}/vaps.csv"
--compare-view-trust-anchor "$(compare_view_trust_anchor)"
)
if [[ -n "$RPKI_EXTRA_ARGS" ]]; then
# shellcheck disable=SC2206
local extra_args=( $RPKI_EXTRA_ARGS )
CHILD_ARGS+=("${extra_args[@]}")
fi
}
copy_inner_run_outputs() {
local daemon_state_root="$1"
local run_dir="$2"
local outer_run_index="$3"
local outer_run_id="$4"
local inner_run_dir
inner_run_dir="$(find "$daemon_state_root/runs" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -n 1 || true)"
if [[ -n "$inner_run_dir" && -d "$inner_run_dir" ]]; then
shopt -s dotglob nullglob
cp -a "$inner_run_dir"/. "$run_dir"/
shopt -u dotglob nullglob
fi
[[ -f "$daemon_state_root/daemon-status.json" ]] && cp "$daemon_state_root/daemon-status.json" "$run_dir/daemon-status.inner.json"
[[ -f "$daemon_state_root/daemon-runs.jsonl" ]] && cp "$daemon_state_root/daemon-runs.jsonl" "$run_dir/daemon-runs.inner.jsonl"
normalize_outer_run_metadata "$run_dir" "$outer_run_index" "$outer_run_id" "$inner_run_dir" "$daemon_state_root"
}
normalize_outer_run_metadata() {
local run_dir="$1"
local outer_run_index="$2"
local outer_run_id="$3"
local inner_run_dir="$4"
local daemon_state_root="$5"
python3 - "$run_dir" "$outer_run_index" "$outer_run_id" "$inner_run_dir" "$daemon_state_root" <<'PY'
import json
import pathlib
import sys
run_dir = pathlib.Path(sys.argv[1]).resolve()
outer_run_index = int(sys.argv[2])
outer_run_id = sys.argv[3]
inner_run_dir = sys.argv[4]
daemon_state_root = pathlib.Path(sys.argv[5])
def replace_paths(value):
if isinstance(value, dict):
return {key: replace_paths(item) for key, item in value.items()}
if isinstance(value, list):
return [replace_paths(item) for item in value]
if isinstance(value, str) and inner_run_dir:
return value.replace(inner_run_dir, str(run_dir))
return value
def normalize_summary(summary):
summary = dict(summary)
summary.setdefault("innerRunSeq", summary.get("runSeq"))
summary.setdefault("innerRunId", summary.get("runId"))
summary.setdefault("innerRunDir", summary.get("runDir"))
summary = replace_paths(summary)
summary["runSeq"] = outer_run_index
summary["runId"] = outer_run_id
summary["runDir"] = str(run_dir)
return summary
summary_path = run_dir / "run-summary.json"
if summary_path.exists():
summary = json.loads(summary_path.read_text(encoding="utf-8"))
summary_path.write_text(
json.dumps(normalize_summary(summary), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
inner_status_path = run_dir / "daemon-status.inner.json"
if not inner_status_path.exists():
raw_status_path = daemon_state_root / "daemon-status.json"
if raw_status_path.exists():
inner_status_path.write_text(raw_status_path.read_text(encoding="utf-8"), encoding="utf-8")
if inner_status_path.exists():
status = json.loads(inner_status_path.read_text(encoding="utf-8"))
status.setdefault("innerLastRunId", status.get("lastRunId"))
status["lastRunId"] = outer_run_id
status["outerRunId"] = outer_run_id
status["outerRunIndex"] = outer_run_index
(run_dir / "daemon-status.json").write_text(
json.dumps(status, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
inner_runs_path = run_dir / "daemon-runs.inner.jsonl"
if not inner_runs_path.exists():
raw_runs_path = daemon_state_root / "daemon-runs.jsonl"
if raw_runs_path.exists():
inner_runs_path.write_text(raw_runs_path.read_text(encoding="utf-8"), encoding="utf-8")
if inner_runs_path.exists():
lines = []
for line in inner_runs_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
lines.append(json.dumps(normalize_summary(json.loads(line)), sort_keys=True))
(run_dir / "daemon-runs.jsonl").write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
PY
}
apply_outer_retention() {
local dirs=()
local retain_limit="$RETAIN_RUNS"
local keep_run="${1:-}"
local run_dir
shopt -s nullglob
for run_dir in "$RUNS_ROOT"/run_[0-9][0-9][0-9][0-9]; do
[[ -d "$run_dir" ]] && dirs+=("$run_dir")
done
shopt -u nullglob
if (( ${#dirs[@]} <= retain_limit )); then
return 0
fi
mapfile -t dirs < <(printf '%s\n' "${dirs[@]}" | sort)
local remove_count=$(( ${#dirs[@]} - retain_limit ))
local removed_count=0
local candidate
for candidate in "${dirs[@]}"; do
if [[ -n "$keep_run" && "$(basename "$candidate")" == "$keep_run" ]]; then
continue
fi
rm -rf "$candidate"
removed_count=$((removed_count + 1))
if (( removed_count >= remove_count )); then
break
fi
done
}
run_one_round() {
local run_index="$1"
local run_id
run_id="$(printf 'run_%04d' "$run_index")"
local run_dir="$RUNS_ROOT/$run_id"
local previous_run_id="$2"
local previous_success_value="$3"
local sync_mode="$4"
local snapshot_reason="$5"
local daemon_state_root="$TMP_DIR/daemon-$run_id"
local started_at
local completed_at
local daemon_exit_code
local summary_state
mkdir -p "$run_dir" "$daemon_state_root"
apply_outer_retention "$run_id"
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
write_run_meta "$run_dir/run-meta.json" "running" "$run_index" "$run_id" "$sync_mode" \
"$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "" \
"$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "" "$PACKAGE_ROOT" "$ENV_FILE"
build_child_args
if is_true "$RPKI_ANALYZE"; then
CHILD_ARGS+=(--analysis-out "$run_dir/analyze")
fi
local daemon_args=(
--state-root "$daemon_state_root"
--rpki-bin "$RPKI_BIN"
--interval-secs 0
--max-runs 1
--retain-runs "$RETAIN_RUNS"
--work-db "$DB_DIR/work-db"
--repo-bytes-db "$DB_DIR/repo-bytes.db"
)
if [[ -x "$DB_STATS_BIN" ]]; then
daemon_args+=(--db-stats-bin "$DB_STATS_BIN")
if [[ -n "${DB_STATS_EXACT_EVERY:-}" && "$DB_STATS_EXACT_EVERY" != "0" ]]; then
daemon_args+=(--db-stats-exact-every "$DB_STATS_EXACT_EVERY")
fi
fi
set +e
env \
RPKI_PROGRESS_LOG="$RPKI_PROGRESS_LOG" \
RPKI_PROGRESS_SLOW_SECS="$RPKI_PROGRESS_SLOW_SECS" \
RPKI_PROGRESS_STAGE_FRESH_SLOW_MS="$RPKI_PROGRESS_STAGE_FRESH_SLOW_MS" \
RPKI_PROGRESS_PP_CONTROL_SLOW_MS="$RPKI_PROGRESS_PP_CONTROL_SLOW_MS" \
"$RPKI_DAEMON_BIN" "${daemon_args[@]}" -- "${CHILD_ARGS[@]}" \
> "$run_dir/daemon-stdout.log" 2> "$run_dir/daemon-stderr.log"
daemon_exit_code=$?
set -e
copy_inner_run_outputs "$daemon_state_root" "$run_dir" "$run_index" "$run_id"
completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
summary_state="$(summary_status "$run_dir/run-summary.json")"
local final_status="failed"
if [[ "$daemon_exit_code" -eq 0 && "$summary_state" == "success" ]]; then
final_status="success"
fi
write_run_meta "$run_dir/run-meta.json" "$final_status" "$run_index" "$run_id" "$sync_mode" \
"$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "$completed_at" \
"$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "$daemon_exit_code" "$PACKAGE_ROOT" "$ENV_FILE"
printf '%s\n' "$run_id" > "$META_DIR/last-run-id"
apply_outer_retention
[[ "$final_status" == "success" ]]
}
main() {
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
require_command python3
require_command date
require_command find
validate_max_runs
validate_non_negative_int "INTERVAL_SECS" "$INTERVAL_SECS"
validate_positive_int "RETAIN_RUNS" "$RETAIN_RUNS"
validate_rsync_scope
if [[ -n "${DB_STATS_EXACT_EVERY:-}" && "$DB_STATS_EXACT_EVERY" != "0" ]]; then
validate_positive_int "DB_STATS_EXACT_EVERY" "$DB_STATS_EXACT_EVERY"
fi
parse_rirs
[[ -x "$RPKI_BIN" ]] || die "missing executable: $RPKI_BIN"
[[ -x "$RPKI_DAEMON_BIN" ]] || die "missing executable: $RPKI_DAEMON_BIN"
local rir_name
for rir_name in "${RIR_LIST[@]}"; do
[[ -f "$(tal_file_for_rir "$rir_name")" ]] || die "missing TAL fixture for $rir_name"
[[ -f "$(ta_file_for_rir "$rir_name")" ]] || die "missing TA fixture for $rir_name"
done
mkdir -p "$RUNS_ROOT" "$LOG_ROOT" "$DB_DIR" "$META_DIR" "$TMP_DIR" "$INVALID_ROOT"
if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then
mkdir -p "$RSYNC_MIRROR_ROOT"
fi
prepare_competing_rp_state
write_machine_snapshot "before"
local max_index
local next_index
local run_forever=0
local stop_index=0
max_index="$(max_existing_run_index)"
next_index=$((max_index + 1))
if (( MAX_RUNS < 0 )); then
run_forever=1
echo "run_soak mode=continuous max_existing_run_index=$max_index next_run=$(printf 'run_%04d' "$next_index")"
else
stop_index=$((max_index + MAX_RUNS))
echo "run_soak mode=fixed max_existing_run_index=$max_index next_run=$(printf 'run_%04d' "$next_index") stop_run=$(printf 'run_%04d' "$stop_index")"
fi
local any_failed=0
while (( run_forever == 1 || next_index <= stop_index )); do
INVALID_DB_PATH=""
INVALID_STATE_PATH=""
INVALID_TMP_PATH=""
local previous_run_id=""
local previous_success_value=""
local sync_mode="snapshot"
local snapshot_reason=""
if (( next_index > 1 )); then
previous_run_id="$(printf 'run_%04d' $((next_index - 1)))"
if previous_run_success "$RUNS_ROOT/$previous_run_id"; then
previous_success_value="true"
if [[ -e "$DB_DIR/work-db" ]]; then
sync_mode="delta"
else
sync_mode="snapshot"
snapshot_reason="missing_db"
fi
else
previous_success_value="false"
if is_true "$FAILURE_SNAPSHOT_RESET"; then
isolate_state_after_failure "$previous_run_id"
sync_mode="snapshot"
snapshot_reason="previous_run_failed"
else
die "previous run is not successful: $previous_run_id"
fi
fi
else
sync_mode="snapshot"
if db_state_exists; then
isolate_state_after_failure "no_previous_run"
snapshot_reason="no_successful_previous_run"
else
snapshot_reason="first_run"
fi
fi
echo "starting run $(printf 'run_%04d' "$next_index") sync_mode=$sync_mode"
if run_one_round "$next_index" "$previous_run_id" "$previous_success_value" "$sync_mode" "$snapshot_reason"; then
echo "completed run $(printf 'run_%04d' "$next_index") status=success"
else
echo "completed run $(printf 'run_%04d' "$next_index") status=failed" >&2
any_failed=1
fi
if (( (run_forever == 1 || next_index < stop_index) && INTERVAL_SECS > 0 )); then
sleep "$INTERVAL_SECS"
fi
next_index=$((next_index + 1))
done
write_machine_snapshot "after"
exit "$any_failed"
}
main "$@"

123
scripts/stage2_perf_compare_m4.sh Executable file
View File

@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
# M4: Compare decode+profile (OURS) vs routinator baseline (rpki crate 0.19.1)
# on selected_der_v2 fixtures (cer/crl/manifest/roa/aspa).
#
# Outputs under:
# - rpki/target/bench/stage2_selected_der_v2_routinator_decode_release.{csv,md}
# - rpki/target/bench/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.{csv,md}
#
# Notes:
# - OURS decode benchmark is produced by:
# `cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture`
# and writes `stage2_selected_der_v2_decode_release.csv` when BENCH_OUT_CSV is set.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RPKI_DIR="$ROOT_DIR"
OUT_DIR="$RPKI_DIR/target/bench"
mkdir -p "$OUT_DIR"
OURS_CSV="${OURS_CSV:-$OUT_DIR/stage2_selected_der_v2_decode_release.csv}"
ROUT_CSV="${ROUT_CSV:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.csv}"
ROUT_MD="${ROUT_MD:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.md}"
COMPARE_CSV="${COMPARE_CSV:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.csv}"
COMPARE_MD="${COMPARE_MD:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.md}"
WARMUP_ITERS="${WARMUP_ITERS:-10}"
ROUNDS="${ROUNDS:-3}"
MIN_ROUND_MS="${MIN_ROUND_MS:-200}"
if [[ ! -f "$OURS_CSV" ]]; then
echo "ERROR: missing OURS CSV: $OURS_CSV" >&2
echo "Hint: run:" >&2
echo " cd rpki && BENCH_WARMUP_ITERS=$WARMUP_ITERS BENCH_ROUNDS=$ROUNDS BENCH_MIN_ROUND_MS=$MIN_ROUND_MS \\" >&2
echo " BENCH_OUT_CSV=target/bench/stage2_selected_der_v2_decode_release.csv \\" >&2
echo " cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture" >&2
exit 2
fi
echo "[1/2] Run routinator baseline bench (release)..." >&2
(cd "$RPKI_DIR/benchmark/routinator_object_bench" && cargo run --release -q -- \
--dir "$RPKI_DIR/tests/benchmark/selected_der_v2" \
--warmup-iters "$WARMUP_ITERS" \
--rounds "$ROUNDS" \
--min-round-ms "$MIN_ROUND_MS" \
--out-csv "$ROUT_CSV" \
--out-md "$ROUT_MD")
echo "[2/2] Join CSVs + compute ratios..." >&2
python3 - "$OURS_CSV" "$ROUT_CSV" "$COMPARE_CSV" "$COMPARE_MD" <<'PY'
import csv
import sys
from pathlib import Path
ours_path = Path(sys.argv[1])
rout_path = Path(sys.argv[2])
out_csv_path = Path(sys.argv[3])
out_md_path = Path(sys.argv[4])
def read_csv(path: Path):
with path.open(newline="") as f:
return list(csv.DictReader(f))
ours_rows = read_csv(ours_path)
rout_rows = read_csv(rout_path)
rout_by_key = {(r["type"], r["sample"]): r for r in rout_rows}
out_rows = []
for r in ours_rows:
key = (r["type"], r["sample"])
rr = rout_by_key.get(key)
if rr is None:
raise SystemExit(f"missing routinator row for {key}")
ours_ns = float(r["avg_ns_per_op"])
rout_ns = float(rr["avg_ns_per_op"])
ratio = (ours_ns / rout_ns) if rout_ns != 0.0 else float("inf")
out_rows.append({
"type": r["type"],
"sample": r["sample"],
"size_bytes": r["size_bytes"],
"complexity": r["complexity"],
"ours_avg_ns_per_op": f"{ours_ns:.2f}",
"ours_ops_per_sec": f"{float(r['ops_per_sec']):.2f}",
"rout_avg_ns_per_op": f"{rout_ns:.2f}",
"rout_ops_per_sec": f"{float(rr['ops_per_sec']):.2f}",
"ratio_ours_over_rout": f"{ratio:.4f}",
})
out_rows.sort(key=lambda x: (x["type"], x["sample"]))
out_csv_path.parent.mkdir(parents=True, exist_ok=True)
with out_csv_path.open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(out_rows[0].keys()))
w.writeheader()
w.writerows(out_rows)
lines = []
lines.append("# Stage2 ours vs routinator (decode+profile, selected_der_v2)\n")
lines.append(f"- ours_csv: `{ours_path}`\n")
lines.append(f"- rout_csv: `{rout_path}`\n")
lines.append("\n")
lines.append("| type | sample | size_bytes | complexity | ours ns/op | rout ns/op | ratio |\n")
lines.append("|---|---|---:|---:|---:|---:|---:|\n")
for r in out_rows:
lines.append(
f"| {r['type']} | {r['sample']} | {r['size_bytes']} | {r['complexity']} | "
f"{r['ours_avg_ns_per_op']} | {r['rout_avg_ns_per_op']} | {r['ratio_ours_over_rout']} |\n"
)
out_md_path.parent.mkdir(parents=True, exist_ok=True)
out_md_path.write_text("".join(lines), encoding="utf-8")
PY
echo "Done." >&2
echo "- routinator CSV: $ROUT_CSV" >&2
echo "- compare CSV: $COMPARE_CSV" >&2
echo "- compare MD: $COMPARE_MD" >&2

View File

@ -182,6 +182,8 @@ RFC 引用RFC 5280 §4.2.2.1RFC 5280 §4.2.2.2RFC 5280 §4.2.1.6。
| `1.2.840.113549.1.9.5` | CMS signedAttrs: signing-time | RFC 9589 §4更新 RFC 6488 §3(1f)/(1g) | | `1.2.840.113549.1.9.5` | CMS signedAttrs: signing-time | RFC 9589 §4更新 RFC 6488 §3(1f)/(1g) |
| `1.2.840.113549.1.9.16.1.24` | ROA eContentType: id-ct-routeOriginAuthz | RFC 9582 §3 | | `1.2.840.113549.1.9.16.1.24` | ROA eContentType: id-ct-routeOriginAuthz | RFC 9582 §3 |
| `1.2.840.113549.1.9.16.1.26` | Manifest eContentType: id-ct-rpkiManifest | RFC 9286 §4.1 | | `1.2.840.113549.1.9.16.1.26` | Manifest eContentType: id-ct-rpkiManifest | RFC 9286 §4.1 |
| `1.2.840.113549.1.9.16.1.35` | Ghostbusters eContentType: id-ct-rpkiGhostbusters | RFC 6493 §6RFC 6493 §9.1 |
| `1.2.840.113549.1.9.16.1.49` | ASPA eContentType: id-ct-ASPA | `draft-ietf-sidrops-aspa-profile-21` §2 |
| `1.3.6.1.5.5.7.1.1` | X.509 v3 扩展authorityInfoAccess | RFC 5280 §4.2.2.1 | | `1.3.6.1.5.5.7.1.1` | X.509 v3 扩展authorityInfoAccess | RFC 5280 §4.2.2.1 |
| `1.3.6.1.5.5.7.1.11` | X.509 v3 扩展subjectInfoAccess | RFC 5280 §4.2.2.2RPKI 约束见 RFC 6487 §4.8.8 | | `1.3.6.1.5.5.7.1.11` | X.509 v3 扩展subjectInfoAccess | RFC 5280 §4.2.2.2RPKI 约束见 RFC 6487 §4.8.8 |
| `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 | | `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 |

View File

@ -1,36 +1,94 @@
# 01. Trust Anchor Locator (TAL) # 01. TALTrust Anchor Locator
## 1.1 对象定位 ## 1.1 对象定位
TAL是一个数据格式/配置文件目的是告诉RP信任锚的公钥是什么以及相关对象可以从哪里获取。
## 1.2 数据格式 RFC 8630 §2.2 TALTrust Anchor Locator用于向 RP 提供:
TAL是一个配置文件格式定义如下
``` 1) 可检索“当前 TA 证书”的一个或多个 URI以及
The TAL is an ordered sequence of: 2) 该 TA 证书的 `subjectPublicKeyInfo`SPKI期望值用于绑定/防替换)。
1. an optional comment section consisting of one or more lines each starting with the "#" character, followed by human-readable informational UTF-8 text, conforming to the restrictions defined
in Section 2 of [RFC5198], and ending with a line break, RFC 8630 §2RFC 8630 §2.3。
2. a URI section that is comprised of one or more ordered lines, each containing a TA URI, and ending with a line break,
3. a line break, and ## 1.2 原始载体与编码
4. a subjectPublicKeyInfo [RFC5280] in DER format [X.509], encoded in base64 (see Section 4 of [RFC4648]). To avoid long lines,
line breaks MAY be inserted into the base64-encoded string. - 载体文本文件ASCII/UTF-8 兼容的行文本)。
Note that line breaks in this file can use either "<CRLF>" or "<LF>". - 行结束:允许 `CRLF``LF`。RFC 8630 §2.2。
- 结构:`[可选注释区] + URI 区 + 空行 + Base64(SPKI DER)`。RFC 8630 §2.2。
### 1.2.1 注释区
- 一行或多行,以 `#` 开头,后随人类可读 UTF-8 文本。RFC 8630 §2.2。
- 注释行文本需符合 RFC 5198 §2 的限制RFC 8630 §2.2 引用)。
### 1.2.2 URI 区
- 一行或多行,每行一个 TA URI按序排列。RFC 8630 §2.2。
- TA URI **MUST**`rsync``https`。RFC 8630 §2.2。
### 1.2.3 空行分隔
- URI 区后必须有一个额外的换行(即空行),用于与 Base64 区分隔。RFC 8630 §2.2(第 3 点)。
### 1.2.4 SPKIBase64
- `subjectPublicKeyInfo` 以 DER 编码ASN.1)后,再 Base64 编码表示。RFC 8630 §2.2(第 4 点)。
- 为避免长行Base64 字符串中 **MAY** 插入换行。RFC 8630 §2.2。
- SPKI ASN.1 类型来自 X.509 / RFC 5280。RFC 8630 §2.2(第 4 点RFC 5280 §4.1.2.7。
#### 1.2.4.1 `SubjectPublicKeyInfo` 的 ASN.1 定义RFC 5280 §4.1
TAL 中携带的是一个 X.509 `SubjectPublicKeyInfo` 的 DER 字节串(再 Base64。其 ASN.1 定义如下RFC 5280 §4.1。
```asn1
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
``` ```
## 1.3 抽象数据模型 其中 `algorithm`/`subjectPublicKey` 的取值受 RPKI 算法 profile 约束(例如 RSA 2048 + SHA-256 等SKI/AKI 计算仍用 SHA-1。RFC 5280 §4.1.2.7RFC 7935 §2-§3.1RFC 6487 §4.8.2-§4.8.3。
### 1.3.1 TAL ## 1.3 解析规则(语义层)
输入:`TalFileBytes: bytes`
解析步骤:
1) 按 `LF` / `CRLF` 识别行。RFC 8630 §2.2。
2) 从文件开头读取所有以 `#` 开头的行,作为 `comments`(保留去掉 `#` 后的 UTF-8 文本或保留原始行均可,但需保持 UTF-8。RFC 8630 §2.2。
3) 继续读取一行或多行非空行,作为 `ta_uris`保持顺序。RFC 8630 §2.2(第 2 点)。
4) 读取一个空行必须存在。RFC 8630 §2.2(第 3 点)。
5) 将剩余行拼接为 Base64 文本移除行分隔Base64 解码得到 `subject_public_key_info_der`。RFC 8630 §2.2(第 4 点)。
6) 可选:将 `subject_public_key_info_der` 解析为 X.509 `SubjectPublicKeyInfo` 结构(用于与 TA 证书比对。RFC 8630 §2.3RFC 5280 §4.1.2.7。
URI 解析与约束:
- `ta_uris[*]` 的 scheme **MUST**`rsync``https`。RFC 8630 §2.2。
- 每个 `ta_uri` **MUST** 指向“单个对象”,且 **MUST NOT** 指向目录或集合。RFC 8630 §2.3。
## 1.4 抽象数据模型(接口)
### 1.4.1 `Tal`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 | | 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|----------|-------------|-------------------------|--------------------------------------------|---------------| |---|---|---|---|---|
| uris | Vec<TalUri> | 指向TA的URI列表 | 允许rsync和https协议。 | RFC 8630 §2.1 | | `raw` | `bytes` | TAL 原始文件字节 | 原样保留(可选但建议) | RFC 8630 §2.2 |
| comment | Vec<String> | 注释(可选) | | RFC 8630 §2.2 | | `comments` | `list[Utf8Text]` | 注释行(按出现顺序) | 每行以 `#` 开头;文本为 UTF-8内容限制见 RFC 5198 §2 | RFC 8630 §2.2 |
| spki_der | Vec<u8> | 原始的subjectPublicKeyInfo | x.509 SubjectPublicKeyInfo DER编码再base64编码 | RFC 8630 §2.2 | | `ta_uris` | `list[Uri]` | TA 证书位置列表 | 至少 1 个;按序;每个 scheme 必须是 `rsync``https` | RFC 8630 §2.2 |
| `subject_public_key_info_der` | `DerBytes` | TA 证书 SPKI 的期望 DER | Base64 解码所得 DERBase64 中可有换行 | RFC 8630 §2.2 |
### 1.4.2 `TaUri`(可选细化)
### 1.3.2 TalUri > 若你的实现希望对 URI 做更强类型化,可在 `Tal.ta_uris` 上进一步拆分为 `TaUri` 结构。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 | | 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|-------|--------|---------|---------|---------------| |---|---|---|---|---|
| Rsync | String | rsync地址 | | RFC 8630 §2.1 | | `uri` | `Uri` | 完整 URI 文本 | scheme 为 `rsync``https` | RFC 8630 §2.2 |
| Https | String | https地址 | | RFC 8630 §2.1 | | `scheme` | `enum` | `rsync` / `https` | 从 `uri` 解析 | RFC 8630 §2.2 |
## 1.5 字段级约束清单(实现对照)
- TAL 由(可选)注释区 + URI 区 + 空行 + Base64(SPKI DER) 组成。RFC 8630 §2.2。
- URI 区至少 1 行,每行一个 TA URI顺序有意义。RFC 8630 §2.2。
- TA URI 仅允许 `rsync``https`。RFC 8630 §2.2。
- Base64 区允许插入换行。RFC 8630 §2.2。
- 每个 TA URI 必须引用单个对象,不能指向目录/集合。RFC 8630 §2.3。

View File

@ -1,121 +0,0 @@
# 02. Trust Anchor (TA)
## 2.1 对象定位
TA是一个自签名的CA证书。
## 2.2 原始载体与编码
- 载体X.509 certificates.
- 编码DER遵循 RFC 5280 的 certificate 结构与字段语义但受限于RFC 8630 §2.3
## 2.3 抽象数据类型
### 2.3.1 TA
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|-------------------|------------------|---------------|---------|---------------|
| name | String | 标识该TA如apnic等 | | |
| cert_der | Vec<u8> | 原始DER内容 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
| publication_point | Uri | 获取该TA的URI | | |
### 2.3.2 ResourceSet
资源集合是来自RFC 3779的IP地址块§2和AS号段§3)受约束于RFC 8630 §2.3
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|------|----------------|--------|-------------|---------------------------|
| ips | IpResourceSet | IP地址集合 | 不能是inherit | RFC 3779 §2和RFC 8630 §2.3 |
| asns | AsnResourceSet | ASN集合 | 不能是inherit | RFC 3779 §3和RFC 8630 §2.3 |
[//]: # ()
[//]: # (### 2.3.3 IpResourceSet)
[//]: # (包括IPv4和IPv6的前缀表示)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----|------------------------|----------|-------------|--------------|)
[//]: # (| v4 | PrefixSet<Ipv4Prefix> | IPv4前缀集合 | | RFC 3779 §2 |)
[//]: # (| v6 | PrefixSet<Ipv6Prefix> | IPv6前缀集合 | | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.4 AsnResourceSet)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-------|--------------------|-------|-------------|-------------|)
[//]: # (| range | RangeSet<AsnBlock> | ASN集合 | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.5 Ipv4Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|-----|-----|---------|-------------|)
[//]: # (| addr | u32 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-32 | RFC 3779 §2 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.6 Ipv6Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|------|-----|---------|-------------|)
[//]: # (| addr | u128 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-128 | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.7 AsnBlock)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----------|----------|-------|---------|--------------|)
[//]: # (| asn | Asn | ASN | | RFC 3779 §3 |)
[//]: # (| asnRange | AsnRange | ASN范围 | | RFC 3779 §3 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.8 Asn)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-----|---------|-------------|)
[//]: # (| asn | u32 | ASN | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.8 AsnRange)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-------|---------|--------------|)
[//]: # (| min | Asn | 最小ASN | | RFC 3779 §3 |)
[//]: # (| max | Asn | 最大ASN | | RFC 3779 §3 |)
# 2.4 TA校验流程RFC 8630 §3
1. 从TAL的URI列表中获取证书对象。顺序访问若前面失效再访问后面的
2. 验证证书格式必须是当前、有效的自签名RPKI证书。
3. 验证公钥匹配。TAL中的SubjectPublicKeyInfo与下载证书的公钥一致。
4. 其他检查。
5. 更新本地存储库缓存。

View File

@ -0,0 +1,88 @@
# 02. TATrust Anchor自签名证书
## 2.1 对象定位
在 RP 侧“信任锚Trust Anchor, TA”以一个**自签名 CA 资源证书**体现,其可获取位置与期望公钥由 TAL 提供。RFC 8630 §2.3。
本文件描述两个紧密相关的数据对象:
1) `TaCertificate`TA 自签名资源证书本体X.509 DER
2) `TrustAnchor`:语义组合对象(`TAL` + `TaCertificate` 的绑定语义)
## 2.2 原始载体与编码
- 载体X.509 证书(通常以 `.cer` 存放于仓库,但文件扩展名不作为语义依据)。
- 编码DER。TA 证书必须符合 RPKI 资源证书 profile。RFC 8630 §2.3RFC 6487 §4。
### 2.2.1 X.509 Certificate 的 ASN.1 定义RFC 5280 §4.1TA 与 RC 共享)
TA 证书与普通资源证书RC在编码层面都是 X.509 `Certificate`DER。其 ASN.1 定义如下RFC 5280 §4.1。
```asn1
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING }
```
其中 `tbsCertificate.extensions`v3 扩展)是 RPKI 语义的主要承载处IP/AS 资源扩展、SIA/AIA/CRLDP 等。RFC 5280 §4.1RPKI 对字段/扩展存在性与关键性约束见 RFC 6487 §4。
> 说明:更完整的 RC 编码层结构(包括 Extension 外层“extnValue 二次 DER 解码”的套娃方式)在 `03_resource_certificate_rc.md``00_common_types.md` 中给出。
## 2.3 TA 证书的 RPKI 语义约束(在 RC profile 基础上额外强调)
### 2.3.1 自签名与 profile
- TA URI 指向的对象 **MUST** 是一个**自签名 CA 证书**,并且 **MUST** 符合 RPKI 证书 profile。RFC 8630 §2.3RFC 6487 §4。
- 自签名证书在 RC profile 下的通用差异(例如 CRLDP/AIA 的省略规则、AKI 的规则)见 RFC 6487。RFC 6487 §4.8.3RFC 6487 §4.8.6RFC 6487 §4.8.7。
### 2.3.2 INRIP/AS 资源扩展)在 TA 上的额外约束
- TA 的 INR 扩展IP/AS 资源扩展RFC 3779**MUST** 是非空资源集合。RFC 8630 §2.3。
- TA 的 INR 扩展 **MUST NOT** 使用 `inherit` 形式。RFC 8630 §2.3。
- 说明:一般 RC profile 允许 `inherit`。RFC 6487 §4.8.10RFC 6487 §4.8.11RFC 3779 §2.2.3.5RFC 3779 §3.2.3.3。
### 2.3.3 TAL ↔ TA 公钥绑定
- 用于验证 TA 的公钥(来自 TAL 中的 SPKI**MUST** 与 TA 证书中的 `subjectPublicKeyInfo` 相同。RFC 8630 §2.3。
### 2.3.4 TA 稳定性语义(实现需建模为“约束/假设”,但不属于验证结果态)
- TA 公钥与 TAL 中公钥必须保持稳定(用于 RP 侧长期信任锚。RFC 8630 §2.3。
### 2.3.5 TA 与 CRL/Manifest 的关系(语义)
- RFC 8630 指出TA 为自签名证书,没有对应 CRL且不会被 manifest 列出TA 的获取/轮换由 TAL 控制。RFC 8630 §2.3。
> 注:这条更偏“发布/运维语义”,但对数据对象建模有影响:`TrustAnchor` 组合对象不应依赖 CRL/MFT 的存在。
## 2.4 抽象数据模型(接口)
### 2.4.1 `TaCertificate`
> 该对象在字段层面复用 `RC(CA)` 的语义模型(见 `03_resource_certificate_rc.md`),但增加 TA 特有约束。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | TA 证书 DER | X.509 DER证书 profile 约束见 RC 文档 | RFC 8630 §2.3RFC 6487 §4 |
| `rc_ca` | `ResourceCaCertificate` | 以 RC(CA) 语义解析出的字段集合 | 必须满足“自签名 CA”分支约束且 INR 必须非空且不允许 inherit | RFC 8630 §2.3RFC 6487 §4RFC 3779 §2/§3 |
### 2.4.2 `TrustAnchor`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `tal` | `Tal` | TAL 文件语义对象 | 见 `01_tal.md` | RFC 8630 §2.2 |
| `ta_certificate` | `TaCertificate` | TA 证书语义对象 | TA URI 指向的对象 | RFC 8630 §2.3 |
| `tal_spki_der` | `DerBytes` | 从 TAL 解析出的 SPKI DER | `tal.subject_public_key_info_der` | RFC 8630 §2.2 |
| `ta_spki_der` | `DerBytes` | 从 TA 证书抽取的 SPKI DER | `ta_certificate``subjectPublicKeyInfo` | RFC 8630 §2.3RFC 5280 §4.1.2.7 |
**绑定约束(字段级)**
- `tal_spki_der` 必须与 `ta_spki_der` 完全相等(字节层面的 DER 等价。RFC 8630 §2.3。
## 2.5 字段级约束清单(实现对照)
- TA URI 指向的对象必须是自签名 CA 证书,且符合 RPKI 证书 profile。RFC 8630 §2.3RFC 6487 §4。
- TA 的 INR 扩展必须非空,且不得使用 inherit。RFC 8630 §2.3。
- TAL 中 SPKI 必须与 TA 证书的 `subjectPublicKeyInfo` 匹配。RFC 8630 §2.3。
- TA 不依赖 CRL/MFT无对应 CRL且不被 manifest 列出。RFC 8630 §2.3。

View File

@ -1,314 +0,0 @@
# 03. RC (Resource Certifications)
## 3.1 对象定位
RC是资源证书包括CA和EE
## 3.2 原始载体与编码
- 载体X.509 certificates.
- 编码DER遵循 RFC 5280 的 Certificate 结构与字段语义,但受 RPKI profile 限制RFC 6487 §4
### 3.2.1 基本语法RFC 5280 §4RFC 6487
RC是遵循RFC5280定义的X.509Certificate语法(RFC 5280 §4)并且符合RFC 6487 §4的约束。只选取RFC 6487 §4章节列出来的字段。Unless specifically noted as being OPTIONAL, all the fields listed
here MUST be present, and any other fields MUST NOT appear in a
conforming resource certificate.
```
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING
}
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version MUST be v3,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
subject Name,
validity Validity,
subjectPublicKeyInfo SubjectPublicKeyInfo,
extensions [3] EXPLICIT Extensions OPTIONAL
-- If present, version MUST be v3
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
```
> 其中`Name` "a valid X.501 distinguished name"(RFC 6487 §4.4)
### 3.2.2 证书扩展字段 RFC 6487 §4.8)
RC的证书扩展字段按照RFC 6487 §4.8的规定,有以下几个扩展:
- Basic Constraints
- Subject Key Identifier
- Authority Key Identifier
- Key Usage
- Extended Key Usage(CA证书以及验证RPKI对象的EE证书不能出现该字段。非RPKI对象的EE可以出现EKU但必须为non-critical)
- CRL Distribution Points
- Authority Information Access
- Subject Information Access
- SIA for CA Certificates
- SIA for EE Certificates
- Certificate Policies
- IP Resources
- AS Resources
```
# Basic Constraints
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
BasicConstraints ::= SEQUENCE {
cA BOOLEAN DEFAULT FALSE }
# Subject Key Identifier
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
SubjectKeyIdentifier ::= KeyIdentifier
KeyIdentifier ::= OCTET STRING
# Authority Key Identifier
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
AuthorityKeyIdentifier ::= SEQUENCE {
keyIdentifier [0] KeyIdentifier OPTIONAL }
# Key Usage
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
KeyUsage ::= BIT STRING {
digitalSignature (0),
nonRepudiation (1), -- recent editions of X.509 have
-- renamed this bit to contentCommitment
keyEncipherment (2),
dataEncipherment (3),
keyAgreement (4),
keyCertSign (5),
cRLSign (6),
encipherOnly (7),
decipherOnly (8) }
# Extended Key Usage
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
KeyPurposeId ::= OBJECT IDENTIFIER
# CRL Distribution Points
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
DistributionPoint ::= SEQUENCE {
distributionPoint [0] DistributionPointName OPTIONAL }
DistributionPointName ::= CHOICE {
fullName [0] GeneralNames }
## Authority Information Access
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
AuthorityInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
# AccessDescription
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
# CA 证书发布位置
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
# OCSP 服务地址
id-ad-ocsp OBJECT IDENTIFIER ::= { id-ad 1 }
# Subject Information Access
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
SubjectInfoAccessSyntax ::= SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
## Subject Information Access for CA (RFC 6487 §4.8.8.1)
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 }
必须存在一个accessMethod=id-ad-caRepositoryaccessLocation=rsyncURI。
必须存在一个accessMethod=id-ad-repiManifest, accessLocation=rsync URI指向该CA的mft对象。
## Subject Information Access for EE (RFC 6487 §4.8.8.2)
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 }
必须存在一个accessMethod=id-ad-signedObject, accessLocation=rsyncURI
不允许其他的accessMethod
# Certificate Policies
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
PolicyInformation ::= SEQUENCE {
policyIdentifier CertPolicyId,
policyQualifiers SEQUENCE SIZE (1..MAX) OF PolicyQualifierInfo OPTIONAL }
CertPolicyId ::= OBJECT IDENTIFIER
PolicyQualifierInfo ::= SEQUENCE {
policyQualifierId PolicyQualifierId,
qualifier ANY DEFINED BY policyQualifierId }
-- policyQualifierIds for Internet policy qualifiers
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
Qualifier ::= CHOICE {
cPSuri CPSuri,
userNotice UserNotice }
CPSuri ::= IA5String
UserNotice ::= SEQUENCE {
noticeRef NoticeReference OPTIONAL,
explicitText DisplayText OPTIONAL }
NoticeReference ::= SEQUENCE {
organization DisplayText,
noticeNumbers SEQUENCE OF INTEGER }
DisplayText ::= CHOICE {
ia5String IA5String (SIZE (1..200)),
visibleString VisibleString (SIZE (1..200)),
bmpString BMPString (SIZE (1..200)),
utf8String UTF8String (SIZE (1..200)) }
# IP Resources
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
addressFamily OCTET STRING (SIZE (2..3)),
ipAddressChoice IPAddressChoice }
IPAddressChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
addressesOrRanges SEQUENCE OF IPAddressOrRange }
IPAddressOrRange ::= CHOICE {
addressPrefix IPAddress,
addressRange IPAddressRange }
IPAddressRange ::= SEQUENCE {
min IPAddress,
max IPAddress }
IPAddress ::= BIT STRING
# AS Resources
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
ASIdentifiers ::= SEQUENCE {
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
ASIdentifierChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
asIdsOrRanges SEQUENCE OF ASIdOrRange }
ASIdOrRange ::= CHOICE {
id ASId,
range ASRange }
ASRange ::= SEQUENCE {
min ASId,
max ASId }
ASId ::= INTEGER
```
# 3.3 抽象数据结构
采用X509 Certificate + Resource + 约束校验的方式组合
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|----------|---------------------|----------|---------|---------------|
| cert_der | Vec<u8> | 证书原始数据 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
# 3.4 约束规则
## 3.4.1 Cert约束校验规则
RFC 6487中规定的证书的字段参见[3.2.1 ](#321-基本语法rfc-5280-4rfc-6487-)
-
| 字段 | 语义 | 约束/解析规则 | RFC 引用 |
|-----------|-------|----------------------------------------------|--------------|
| version | 证书版本 | 必须是v3(值为2 | RFC6487 §4.1 |
| serial | 证书编号 | 同一个CA签发的证书编号必须唯一 | RFC6487 §4.2 |
| validity | 证书有效期 | notBefore时间不能早于证书的生成时间。若时间段大于上级证书的有效期也是有效的 | RFC6487 §4.6 |
## 3.4.2 Cert Extentions中字段的约束校验规则
RFC 6487中规定的扩展字段参见[3.2.2 ](#322-证书扩展字段-rfc-6487-48)
| 字段 | critical | 语义 | 约束/解析规则 | RFC 引用 |
|----------------------------|----------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|
| basicConstraints | Y | 证书类型 | CA证书cA=TRUE; EE证书cA=FALSE | RFC6487 §4.8.1 |
| subjectKeyIdentifier | N | 证书公钥 | SKI = SHA-1(DER-encoded SPKI bit string) | RFC6487 §4.8.2 |
| authorityKeyIdentifier | N | 父证书的公钥 | 字段只包含keyIdentifier不能包含authorityCertIssuer和authorityCertSerialNumber除了自签名CA外其余证书必须出现。自签名CA若出现该字段则等于SKI | RFC6487 §4.8.3 |
| keyUsage | Y | 证书公钥的用途权限 | CA证书keyCertSign = TRUE, cRLSign = TRUE 其他都是FALSE。EE证书digitalSignature = TRUE 其他都是FALSE | RFC6487 §4.8.4 |
| extendedKeyUsage | N | 扩展证书公钥的用途权限 | CA证书不能出现EKU验证 RPKI 对象的 EE 证书不能出现EKU非 RPKI 对象的 EE可以出现EKU但必须为non-critical. | RFC6487 §4.8.5 |
| cRLDistributionPoints | N | CRL的发布点位置 | 字段distributionPoint不能包含reasons、cRLIssuer。其中distributionPoint字段包含fullName不能包含nameRelativeToCRLIssuer。fullName的格式必须是URI。自签名证书禁止出现该字段。非自签名证书必须出现。一个CA只能有一个CRL。一个CRLDP只能包含一个distributionPoint。但一个distributionPoint字段中可以包含多于1个的URI但必须包含rsync URI且必须是最新的。 | RFC6487 §4.8.6 |
| authorityInformationAccess | N | 签发者的发布点位置 | 除了自签名的CA必须出现。自签名CA禁止出现。推荐的URI访问方式是rsync并且rsyncURI的话必须指定accessMethod=id-ad-caIssuers | RFC6487 §4.8.7 |
| subjectInformationAccess | N | 发布点位置 | CA证书必须存在。必须存在一个accessMethod=id-ad-caRepositoryaccessLocation=rsyncURI。必须存在一个accessMethod=id-ad-repiManifest,accessLocation=rsync URI指向该CA的mft对象。 EE证书必须存在。必须存在一个accessMethod=id-ad-signedObject,accessLocation=rsyncURI。不允许其他的accessMethod | RFC6487 §4.8.8 |
| certificatePolicies | Y | 证书策略 | 必须存在并且只能存在一种策略RFC 6484 — RPKI Certificate Policy (CP) | RFC6487 §4.8.9 |
| iPResources | Y | IP地址集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources或者两者都包含。 | RFC6487 §4.8.10 |
| aSResources | Y | ASN集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources或者两者都包含。 | RFC6487 §4.8.11 |

Some files were not shown because too many files have changed in this diff Show More