rpki/src/tools/rpki_inter_rp_metrics.rs

1382 lines
45 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::ccr::{CcrStateDigestComparison, compare_state_digests, decode_state_digest_summary};
use serde::Serialize;
use serde_json::{Value, json};
const RPS: [&str; 3] = ["ours-rp", "routinator", "rpki-client"];
const CCR_STATES: [&str; 5] = ["mfts", "vrps", "vaps", "tas", "rks"];
#[derive(Clone, Debug, PartialEq, Eq)]
struct Args {
ours_run_root: PathBuf,
peer_root: PathBuf,
listen: String,
poll_secs: u64,
instance: String,
once: bool,
out_metrics: Option<PathBuf>,
out_status: Option<PathBuf>,
}
fn usage() -> &'static str {
"Usage: rpki_inter_rp_metrics --ours-run-root <path> --peer-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>]"
}
pub fn main_entry() -> Result<(), String> {
real_main()
}
fn real_main() -> Result<(), String> {
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
if args.once {
let snapshot = scan(&args)?;
let metrics = render_metrics(&snapshot);
let status = render_status_json(&snapshot)?;
if let Some(path) = args.out_metrics.as_ref() {
write_file(path, metrics.as_bytes())?;
} else {
print!("{metrics}");
}
if let Some(path) = args.out_status.as_ref() {
write_file(path, status.as_bytes())?;
}
return Ok(());
}
let shared = Arc::new(RwLock::new(scan(&args)?));
let scanner = Arc::clone(&shared);
let scan_args = args.clone();
thread::spawn(move || {
let poll_secs = scan_args.poll_secs.max(1);
loop {
thread::sleep(Duration::from_secs(poll_secs));
match scan(&scan_args) {
Ok(next) => *scanner.write().expect("metrics lock poisoned") = next,
Err(err) => {
let mut previous = scanner.write().expect("metrics lock poisoned");
previous.service.last_reload_success = false;
previous.service.last_scan_timestamp_seconds = unix_now_seconds();
previous
.service
.parse_errors
.push(format!("scan failed: {err}"));
}
}
}
});
serve_http(&args.listen, shared)
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut ours_run_root = None;
let mut peer_root = None;
let mut listen = "127.0.0.1:9557".to_string();
let mut poll_secs = 30u64;
let mut instance = "inter-rp".to_string();
let mut once = false;
let mut out_metrics = None;
let mut out_status = None;
let mut index = 1usize;
while index < argv.len() {
match argv[index].as_str() {
"--ours-run-root" => {
index += 1;
ours_run_root = Some(PathBuf::from(value_at(argv, index, "--ours-run-root")?));
}
"--peer-root" => {
index += 1;
peer_root = Some(PathBuf::from(value_at(argv, index, "--peer-root")?));
}
"--listen" => {
index += 1;
listen = value_at(argv, index, "--listen")?.to_string();
}
"--poll-secs" => {
index += 1;
let value = value_at(argv, index, "--poll-secs")?;
poll_secs = value
.parse::<u64>()
.map_err(|_| format!("invalid --poll-secs: {value}"))?;
}
"--instance" => {
index += 1;
instance = value_at(argv, index, "--instance")?.to_string();
}
"--once" => once = true,
"--out-metrics" => {
index += 1;
out_metrics = Some(PathBuf::from(value_at(argv, index, "--out-metrics")?));
}
"--out-status" => {
index += 1;
out_status = Some(PathBuf::from(value_at(argv, index, "--out-status")?));
}
"-h" | "--help" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
index += 1;
}
Ok(Args {
ours_run_root: ours_run_root
.ok_or_else(|| format!("--ours-run-root is required\n{}", usage()))?,
peer_root: peer_root.ok_or_else(|| format!("--peer-root is required\n{}", usage()))?,
listen,
poll_secs,
instance,
once,
out_metrics,
out_status,
})
}
fn value_at<'a>(argv: &'a [String], index: usize, flag: &str) -> Result<&'a str, String> {
argv.get(index)
.map(|s| s.as_str())
.ok_or_else(|| format!("{flag} requires a value"))
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Snapshot {
instance: String,
service: ServiceMetrics,
sync: SyncMetrics,
samples: BTreeMap<String, RpSample>,
ccr_compare: Option<CcrCompareMetrics>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct ServiceMetrics {
last_scan_timestamp_seconds: f64,
last_scan_duration_seconds: f64,
last_reload_success: bool,
parse_errors: Vec<String>,
ours_run_root: String,
peer_root: String,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncMetrics {
present: bool,
success: bool,
last_sync_timestamp_seconds: Option<f64>,
age_seconds: Option<f64>,
remote_host: Option<String>,
message: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RpSample {
rp: String,
present: bool,
source_dir: Option<String>,
run_id: Option<String>,
run_seq: Option<u64>,
success: bool,
wall_seconds: Option<f64>,
finish_timestamp_seconds: Option<f64>,
artifact_age_seconds: Option<f64>,
max_rss_bytes: BTreeMap<String, u64>,
vrps: Option<u64>,
vaps: Option<u64>,
ccr_path: Option<String>,
ccr: Option<CcrSampleMetrics>,
parse_errors: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrSampleMetrics {
version: u32,
hash_alg_oid: String,
state_present: BTreeMap<String, bool>,
state_hashes: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrCompareMetrics {
available: bool,
match_all: bool,
states: BTreeMap<String, CcrStateMetrics>,
error: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrStateMetrics {
left_present: bool,
right_present: bool,
matches: bool,
left_hash_hex: Option<String>,
right_hash_hex: Option<String>,
}
fn scan(args: &Args) -> Result<Snapshot, String> {
let start = Instant::now();
let now = unix_now_seconds();
let mut parse_errors = Vec::new();
let mut samples = BTreeMap::new();
let ours = scan_ours(&args.ours_run_root, now);
parse_errors.extend(
ours.parse_errors
.iter()
.map(|err| format!("ours-rp: {err}")),
);
samples.insert("ours-rp".to_string(), ours);
for rp in ["routinator", "rpki-client"] {
let peer = scan_peer(&args.peer_root, rp, now);
parse_errors.extend(peer.parse_errors.iter().map(|err| format!("{rp}: {err}")));
samples.insert(rp.to_string(), peer);
}
let sync = scan_sync_status(&args.peer_root, now);
if let Some(message) = sync.message.as_ref().filter(|_| !sync.success) {
parse_errors.push(format!("sync: {message}"));
}
let ccr_compare = build_ccr_compare(samples.get("ours-rp"), samples.get("rpki-client"));
if let Some(compare) = ccr_compare
.as_ref()
.and_then(|compare| compare.error.as_ref())
{
parse_errors.push(format!("ccr compare: {compare}"));
}
Ok(Snapshot {
instance: args.instance.clone(),
service: ServiceMetrics {
last_scan_timestamp_seconds: now,
last_scan_duration_seconds: start.elapsed().as_secs_f64(),
last_reload_success: parse_errors.is_empty(),
parse_errors,
ours_run_root: args.ours_run_root.display().to_string(),
peer_root: args.peer_root.display().to_string(),
},
sync,
samples,
ccr_compare,
})
}
fn scan_ours(run_root: &Path, now: f64) -> RpSample {
let mut sample = RpSample {
rp: "ours-rp".to_string(),
..RpSample::default()
};
let runs_root = if run_root.join("runs").is_dir() {
run_root.join("runs")
} else {
run_root.to_path_buf()
};
let Some(run_dir) = latest_run_with_summary(&runs_root) else {
sample.parse_errors.push(format!(
"no run-summary.json found under {}",
runs_root.display()
));
return sample;
};
sample.present = true;
sample.source_dir = Some(run_dir.display().to_string());
let summary_path = run_dir.join("run-summary.json");
let value = match read_json(&summary_path) {
Ok(value) => value,
Err(err) => {
sample.parse_errors.push(err);
return sample;
}
};
sample.run_id = json_str(&value, &["runId"])
.map(str::to_string)
.or_else(|| {
run_dir
.file_name()
.and_then(|v| v.to_str())
.map(str::to_string)
});
sample.run_seq = json_u64(&value, &["runSeq"]).or_else(|| run_index_from_path(&run_dir));
let status = json_str(&value, &["status"]).unwrap_or("unknown");
sample.success = status == "success" && json_i64(&value, &["exitCode"]).unwrap_or(0) == 0;
sample.wall_seconds = json_f64(&value, &["wallMs"]).map(|v| v / 1000.0);
sample.finish_timestamp_seconds =
json_str(&value, &["finishedAtRfc3339Utc"]).and_then(parse_rfc3339_to_unix);
sample.artifact_age_seconds = sample
.finish_timestamp_seconds
.map(|finished| (now - finished).max(0.0));
if let Some(max_rss_kb) = json_u64(&value, &["processMetrics", "maxRssKb"]) {
sample
.max_rss_bytes
.insert("parent".to_string(), max_rss_kb.saturating_mul(1024));
sample.max_rss_bytes.insert(
"aggregate_peak".to_string(),
max_rss_kb.saturating_mul(1024),
);
}
sample.vrps =
count_vrp_csv_unique_keys_opt(&run_dir.join("vrps.csv"), &mut sample.parse_errors)
.or_else(|| json_u64(&value, &["reportCounts", "vrps"]));
sample.vaps =
count_vap_csv_unique_keys_opt(&run_dir.join("vaps.csv"), &mut sample.parse_errors)
.or_else(|| json_u64(&value, &["reportCounts", "aspas"]));
let ccr_path = run_dir.join("result.ccr");
load_ccr_sample(&ccr_path, &mut sample);
sample
}
fn scan_peer(peer_root: &Path, rp: &str, now: f64) -> RpSample {
let mut sample = RpSample {
rp: rp.to_string(),
..RpSample::default()
};
let rp_root = peer_root.join(rp);
let latest = rp_root.join("latest");
if !latest.exists() {
sample
.parse_errors
.push(format!("missing latest directory: {}", latest.display()));
return sample;
}
sample.present = true;
sample.source_dir = Some(latest.display().to_string());
let meta_path = ["run-meta.json", "meta.json"]
.into_iter()
.map(|name| latest.join(name))
.find(|path| path.exists());
let mut meta = Value::Null;
if let Some(path) = meta_path.as_ref() {
match read_json(path) {
Ok(value) => meta = value,
Err(err) => sample.parse_errors.push(err),
}
} else {
sample
.parse_errors
.push(format!("missing run-meta.json under {}", latest.display()));
}
sample.run_id = json_str(&meta, &["runId"])
.map(str::to_string)
.or_else(|| json_str(&meta, &["run_id"]).map(str::to_string));
sample.run_seq = json_u64(&meta, &["runSeq"]).or_else(|| json_u64(&meta, &["run_seq"]));
sample.success = json_bool(&meta, &["success"]).unwrap_or(false);
sample.wall_seconds = json_f64(&meta, &["wallSeconds"])
.or_else(|| json_f64(&meta, &["wall_seconds"]))
.or_else(|| json_f64(&meta, &["wallMs"]).map(|v| v / 1000.0))
.or_else(|| json_f64(&meta, &["wall_ms"]).map(|v| v / 1000.0));
sample.finish_timestamp_seconds = json_str(&meta, &["finishedAtRfc3339Utc"])
.or_else(|| json_str(&meta, &["finished_at_rfc3339_utc"]))
.and_then(parse_rfc3339_to_unix);
sample.artifact_age_seconds = sample
.finish_timestamp_seconds
.map(|finished| (now - finished).max(0.0));
read_rss_metric(&meta, &mut sample);
let vrps_path = artifact_path(&latest, &meta, "vrpsCsv", "vrps.csv");
let vaps_path = artifact_path(&latest, &meta, "vapsCsv", "vaps.csv");
let ccr_path = artifact_path(&latest, &meta, "ccr", "result.ccr");
load_ccr_sample(&ccr_path, &mut sample);
sample.vrps = count_vrp_csv_unique_keys_opt(&vrps_path, &mut sample.parse_errors)
.or_else(|| json_u64(&meta, &["counts", "vrps"]));
sample.vaps = count_vap_csv_unique_keys_opt(&vaps_path, &mut sample.parse_errors)
.or_else(|| json_u64(&meta, &["counts", "vaps"]))
.or_else(|| json_u64(&meta, &["counts", "aspas"]));
sample
}
fn read_rss_metric(meta: &Value, sample: &mut RpSample) {
let fields = [
("parent", &["maxRssKb", "parent"][..]),
("child_max", &["maxRssKb", "childMax"][..]),
("aggregate_peak", &["maxRssKb", "aggregatePeak"][..]),
];
for (name, path) in fields {
if let Some(value) = json_u64(meta, path) {
sample
.max_rss_bytes
.insert(name.to_string(), value.saturating_mul(1024));
}
}
if sample.max_rss_bytes.is_empty() {
if let Some(value) = json_u64(meta, &["maxRssKb"]) {
sample
.max_rss_bytes
.insert("parent".to_string(), value.saturating_mul(1024));
sample
.max_rss_bytes
.insert("aggregate_peak".to_string(), value.saturating_mul(1024));
}
}
}
fn artifact_path(run_dir: &Path, meta: &Value, key: &str, default_name: &str) -> PathBuf {
json_str(meta, &["artifacts", key])
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.map(|path| {
if path.is_absolute() {
path
} else {
run_dir.join(path)
}
})
.unwrap_or_else(|| run_dir.join(default_name))
}
fn load_ccr_sample(ccr_path: &Path, sample: &mut RpSample) {
if !ccr_path.exists() {
return;
}
sample.ccr_path = Some(ccr_path.display().to_string());
let der = match fs::read(ccr_path) {
Ok(der) => der,
Err(err) => {
sample
.parse_errors
.push(format!("read CCR failed: {}: {err}", ccr_path.display()));
return;
}
};
match decode_state_digest_summary(&der) {
Ok(summary) => sample.ccr = Some(ccr_summary_to_metrics(summary)),
Err(err) => sample.parse_errors.push(format!(
"decode CCR digest failed: {}: {err}",
ccr_path.display()
)),
}
}
fn ccr_summary_to_metrics(summary: crate::ccr::CcrStateDigestSummary) -> CcrSampleMetrics {
let mut state_present = BTreeMap::new();
let mut state_hashes = BTreeMap::new();
for (name, hash) in [
("mfts", summary.mfts),
("vrps", summary.vrps),
("vaps", summary.vaps),
("tas", summary.tas),
("rks", summary.rks),
] {
state_present.insert(name.to_string(), hash.is_some());
if let Some(hash) = hash {
state_hashes.insert(name.to_string(), hex::encode(hash));
}
}
CcrSampleMetrics {
version: summary.version,
hash_alg_oid: summary.hash_alg_oid,
state_present,
state_hashes,
}
}
fn scan_sync_status(peer_root: &Path, now: f64) -> SyncMetrics {
let path = peer_root.join("sync-status.json");
if !path.exists() {
return SyncMetrics {
present: false,
success: false,
message: Some(format!("missing {}", path.display())),
..SyncMetrics::default()
};
}
let value = match read_json(&path) {
Ok(value) => value,
Err(err) => {
return SyncMetrics {
present: true,
success: false,
message: Some(err),
..SyncMetrics::default()
};
}
};
let timestamp = json_str(&value, &["lastSyncAtRfc3339Utc"])
.or_else(|| json_str(&value, &["lastSyncTimestampRfc3339Utc"]))
.or_else(|| json_str(&value, &["updatedAtRfc3339Utc"]))
.and_then(parse_rfc3339_to_unix);
SyncMetrics {
present: true,
success: json_bool(&value, &["success"])
.or_else(|| json_bool(&value, &["lastSyncSuccess"]))
.unwrap_or(false),
last_sync_timestamp_seconds: timestamp,
age_seconds: timestamp.map(|ts| (now - ts).max(0.0)),
remote_host: json_str(&value, &["remoteHost"]).map(str::to_string),
message: json_str(&value, &["message"])
.or_else(|| json_str(&value, &["error"]))
.map(str::to_string),
}
}
fn build_ccr_compare(
ours: Option<&RpSample>,
rpki_client: Option<&RpSample>,
) -> Option<CcrCompareMetrics> {
let left = ours?.ccr_path.as_ref()?;
let right = rpki_client?.ccr_path.as_ref()?;
let left = match fs::read(left) {
Ok(value) => value,
Err(err) => {
return Some(CcrCompareMetrics {
available: false,
match_all: false,
states: BTreeMap::new(),
error: Some(format!("read ours CCR failed: {err}")),
});
}
};
let right = match fs::read(right) {
Ok(value) => value,
Err(err) => {
return Some(CcrCompareMetrics {
available: false,
match_all: false,
states: BTreeMap::new(),
error: Some(format!("read rpki-client CCR failed: {err}")),
});
}
};
match compare_state_digests(&left, &right) {
Ok(comparison) => Some(compare_to_metrics(comparison)),
Err(err) => Some(CcrCompareMetrics {
available: false,
match_all: false,
states: BTreeMap::new(),
error: Some(err.to_string()),
}),
}
}
fn compare_to_metrics(comparison: CcrStateDigestComparison) -> CcrCompareMetrics {
let states = comparison
.states
.iter()
.map(|state| {
(
state.name.to_string(),
CcrStateMetrics {
left_present: state.ours_present,
right_present: state.peer_present,
matches: state.matches,
left_hash_hex: state.ours_hash_hex.clone(),
right_hash_hex: state.peer_hash_hex.clone(),
},
)
})
.collect();
CcrCompareMetrics {
available: true,
match_all: comparison.matches(),
states,
error: None,
}
}
fn latest_run_with_summary(runs_root: &Path) -> Option<PathBuf> {
let mut candidates = fs::read_dir(runs_root)
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir() && path.join("run-summary.json").exists())
.filter_map(|path| run_index_from_path(&path).map(|index| (index, path)))
.collect::<Vec<_>>();
candidates.sort_by_key(|(index, _)| *index);
candidates.pop().map(|(_, path)| path)
}
fn count_vrp_csv_unique_keys_opt(path: &Path, errors: &mut Vec<String>) -> Option<u64> {
if !path.exists() {
return None;
}
match count_vrp_csv_unique_keys(path) {
Ok(count) => Some(count),
Err(err) => {
errors.push(err);
None
}
}
}
fn count_vap_csv_unique_keys_opt(path: &Path, errors: &mut Vec<String>) -> Option<u64> {
if !path.exists() {
return None;
}
match count_vap_csv_unique_keys(path) {
Ok(count) => Some(count),
Err(err) => {
errors.push(err);
None
}
}
}
fn count_vrp_csv_unique_keys(path: &Path) -> Result<u64, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("read VRP CSV failed: {}: {e}", path.display()))?;
let mut unique = BTreeSet::new();
for (index, line) in data_csv_lines(&content).enumerate() {
if index == 0 {
continue;
}
let columns = split_csv_simple(line);
if columns.len() < 3 {
return Err(format!(
"invalid VRP CSV row in {}: expected at least 3 columns, got {}",
path.display(),
columns.len()
));
}
unique.insert((
columns[0].to_string(),
columns[1].to_string(),
columns[2].to_string(),
));
}
Ok(unique.len() as u64)
}
fn count_vap_csv_unique_keys(path: &Path) -> Result<u64, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("read VAP CSV failed: {}: {e}", path.display()))?;
let mut unique = BTreeSet::new();
for (index, line) in data_csv_lines(&content).enumerate() {
if index == 0 {
continue;
}
let columns = split_csv_simple(line);
if columns.len() < 2 {
return Err(format!(
"invalid VAP CSV row in {}: expected at least 2 columns, got {}",
path.display(),
columns.len()
));
}
unique.insert((columns[0].to_string(), columns[1].to_string()));
}
Ok(unique.len() as u64)
}
#[cfg(test)]
fn count_csv_rows(path: &Path) -> Result<u64, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("read CSV failed: {}: {e}", path.display()))?;
let count = data_csv_lines(&content).skip(1).count();
Ok(count as u64)
}
fn data_csv_lines(content: &str) -> impl Iterator<Item = &str> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| !line.starts_with('#'))
}
fn split_csv_simple(line: &str) -> Vec<&str> {
line.split(',').map(str::trim).collect()
}
fn render_metrics(snapshot: &Snapshot) -> String {
let mut out = String::new();
let mut writer = PromWriter::new(&mut out);
let instance = snapshot.instance.as_str();
writer.gauge(
"inter_rp_service_up",
"Inter-RP metrics service is up",
&[label("instance", instance)],
1.0,
);
writer.gauge(
"inter_rp_service_last_scan_timestamp_seconds",
"Last inter-RP scan timestamp",
&[label("instance", instance)],
snapshot.service.last_scan_timestamp_seconds,
);
writer.gauge(
"inter_rp_service_last_scan_duration_seconds",
"Last inter-RP scan duration",
&[label("instance", instance)],
snapshot.service.last_scan_duration_seconds,
);
writer.gauge(
"inter_rp_service_last_reload_success",
"Last inter-RP reload success",
&[label("instance", instance)],
bool_value(snapshot.service.last_reload_success),
);
writer.gauge(
"inter_rp_parse_errors",
"Current inter-RP parse error count",
&[label("instance", instance)],
snapshot.service.parse_errors.len() as f64,
);
writer.gauge(
"inter_rp_sync_last_success",
"Last remote200 artifact sync success",
&[label("instance", instance)],
bool_value(snapshot.sync.success),
);
if let Some(ts) = snapshot.sync.last_sync_timestamp_seconds {
writer.gauge(
"inter_rp_sync_last_timestamp_seconds",
"Last remote200 artifact sync timestamp",
&[label("instance", instance)],
ts,
);
}
if let Some(age) = snapshot.sync.age_seconds {
writer.gauge(
"inter_rp_sync_age_seconds",
"Age of latest remote200 artifact sync",
&[label("instance", instance)],
age,
);
}
for rp in RPS {
let sample = snapshot.samples.get(rp);
render_sample_metrics(&mut writer, instance, rp, sample);
}
render_count_diffs(&mut writer, instance, &snapshot.samples);
render_ccr_compare_metrics(&mut writer, instance, snapshot.ccr_compare.as_ref());
out
}
fn render_sample_metrics(
writer: &mut PromWriter<'_>,
instance: &str,
rp: &str,
sample: Option<&RpSample>,
) {
let present = sample.map(|sample| sample.present).unwrap_or(false);
let success = sample.map(|sample| sample.success).unwrap_or(false);
writer.gauge(
"inter_rp_run_present",
"Whether latest RP run artifact is present",
&[label("instance", instance), label("rp", rp)],
bool_value(present),
);
writer.gauge(
"inter_rp_run_success",
"Whether latest RP run succeeded",
&[label("instance", instance), label("rp", rp)],
bool_value(success),
);
if let Some(sample) = sample {
if let Some(seq) = sample.run_seq {
writer.gauge(
"inter_rp_run_seq",
"Latest RP run sequence",
&[label("instance", instance), label("rp", rp)],
seq as f64,
);
}
if let Some(wall) = sample.wall_seconds {
writer.gauge(
"inter_rp_run_wall_seconds",
"Latest RP run wall clock seconds",
&[label("instance", instance), label("rp", rp)],
wall,
);
}
if let Some(finish) = sample.finish_timestamp_seconds {
writer.gauge(
"inter_rp_run_finish_timestamp_seconds",
"Latest RP run finish timestamp",
&[label("instance", instance), label("rp", rp)],
finish,
);
}
if let Some(age) = sample.artifact_age_seconds {
writer.gauge(
"inter_rp_artifact_age_seconds",
"Latest RP artifact age seconds",
&[label("instance", instance), label("rp", rp)],
age,
);
}
for (kind, bytes) in &sample.max_rss_bytes {
writer.gauge(
"inter_rp_run_max_rss_bytes",
"Latest RP run max RSS bytes",
&[
label("instance", instance),
label("rp", rp),
label("kind", kind),
],
*bytes as f64,
);
}
if let Some(vrps) = sample.vrps {
writer.gauge(
"inter_rp_vrps",
"Latest RP VRP count",
&[label("instance", instance), label("rp", rp)],
vrps as f64,
);
}
if let Some(vaps) = sample.vaps {
writer.gauge(
"inter_rp_vaps",
"Latest RP VAP/ASPA count",
&[label("instance", instance), label("rp", rp)],
vaps as f64,
);
}
writer.gauge(
"inter_rp_sample_parse_errors",
"Current parse error count for an RP sample",
&[label("instance", instance), label("rp", rp)],
sample.parse_errors.len() as f64,
);
if let Some(ccr) = sample.ccr.as_ref() {
writer.gauge(
"inter_rp_ccr_version",
"CCR version",
&[label("instance", instance), label("rp", rp)],
ccr.version as f64,
);
for state in CCR_STATES {
writer.gauge(
"inter_rp_ccr_digest_present",
"CCR state digest presence",
&[
label("instance", instance),
label("rp", rp),
label("state", state),
],
bool_value(ccr.state_present.get(state).copied().unwrap_or(false)),
);
}
}
}
}
fn render_count_diffs(
writer: &mut PromWriter<'_>,
instance: &str,
samples: &BTreeMap<String, RpSample>,
) {
for (left_index, left) in RPS.iter().enumerate() {
for right in RPS.iter().skip(left_index + 1) {
let left_sample = samples.get(*left);
let right_sample = samples.get(*right);
if let (Some(left_vrps), Some(right_vrps)) = (
left_sample.and_then(|sample| sample.vrps),
right_sample.and_then(|sample| sample.vrps),
) {
writer.gauge(
"inter_rp_vrps_diff",
"Latest VRP count difference between two RPs; value is left minus right",
&[
label("instance", instance),
label("left", left),
label("right", right),
],
left_vrps as f64 - right_vrps as f64,
);
}
if let (Some(left_vaps), Some(right_vaps)) = (
left_sample.and_then(|sample| sample.vaps),
right_sample.and_then(|sample| sample.vaps),
) {
writer.gauge(
"inter_rp_vaps_diff",
"Latest VAP/ASPA count difference between two RPs; value is left minus right",
&[
label("instance", instance),
label("left", left),
label("right", right),
],
left_vaps as f64 - right_vaps as f64,
);
}
}
}
}
fn render_ccr_compare_metrics(
writer: &mut PromWriter<'_>,
instance: &str,
compare: Option<&CcrCompareMetrics>,
) {
let available = compare.map(|compare| compare.available).unwrap_or(false);
let match_all = compare.map(|compare| compare.match_all).unwrap_or(false);
writer.gauge(
"inter_rp_ccr_digest_compare_available",
"Whether ours-rp and rpki-client CCR digest comparison is available",
&[
label("instance", instance),
label("left", "ours-rp"),
label("right", "rpki-client"),
],
bool_value(available),
);
writer.gauge(
"inter_rp_ccr_digest_match",
"Whether CCR state digest matches between ours-rp and rpki-client",
&[
label("instance", instance),
label("left", "ours-rp"),
label("right", "rpki-client"),
label("state", "overall"),
],
bool_value(match_all),
);
if let Some(compare) = compare {
for state in CCR_STATES {
let state_metrics = compare.states.get(state);
writer.gauge(
"inter_rp_ccr_digest_match",
"Whether CCR state digest matches between ours-rp and rpki-client",
&[
label("instance", instance),
label("left", "ours-rp"),
label("right", "rpki-client"),
label("state", state),
],
bool_value(state_metrics.map(|state| state.matches).unwrap_or(false)),
);
}
}
}
fn render_status_json(snapshot: &Snapshot) -> Result<String, String> {
serde_json::to_string_pretty(&json!({
"schemaVersion": 1,
"generatedBy": "rpki_inter_rp_metrics",
"instance": snapshot.instance,
"service": snapshot.service,
"sync": snapshot.sync,
"samples": snapshot.samples,
"ccrCompare": snapshot.ccr_compare,
}))
.map_err(|e| e.to_string())
}
#[derive(Clone, Debug)]
struct Label<'a> {
key: &'a str,
value: &'a str,
}
fn label<'a>(key: &'a str, value: &'a str) -> Label<'a> {
Label { key, value }
}
struct PromWriter<'a> {
out: &'a mut String,
emitted_headers: BTreeSet<String>,
}
impl<'a> PromWriter<'a> {
fn new(out: &'a mut String) -> Self {
Self {
out,
emitted_headers: BTreeSet::new(),
}
}
fn gauge(&mut self, name: &str, help: &str, labels: &[Label<'_>], value: f64) {
self.header(name, help, "gauge");
self.out.push_str(name);
write_labels(self.out, labels);
self.out.push(' ');
self.out.push_str(&format_prom_value(value));
self.out.push('\n');
}
fn header(&mut self, name: &str, help: &str, metric_type: &str) {
if self.emitted_headers.insert(name.to_string()) {
self.out.push_str("# HELP ");
self.out.push_str(name);
self.out.push(' ');
self.out.push_str(&escape_help(help));
self.out.push('\n');
self.out.push_str("# TYPE ");
self.out.push_str(name);
self.out.push(' ');
self.out.push_str(metric_type);
self.out.push('\n');
}
}
}
fn write_labels(out: &mut String, labels: &[Label<'_>]) {
if labels.is_empty() {
return;
}
out.push('{');
for (index, label) in labels.iter().enumerate() {
if index > 0 {
out.push(',');
}
out.push_str(label.key);
out.push_str("=\"");
out.push_str(&escape_label(label.value));
out.push('"');
}
out.push('}');
}
fn serve_http(listen: &str, shared: Arc<RwLock<Snapshot>>) -> Result<(), String> {
let listener = TcpListener::bind(listen).map_err(|e| format!("bind failed: {listen}: {e}"))?;
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
let snapshot = shared.read().expect("metrics lock poisoned").clone();
if let Err(err) = handle_http_stream(&mut stream, &snapshot) {
eprintln!("http request failed: {err}");
}
}
Err(err) => eprintln!("accept failed: {err}"),
}
}
Ok(())
}
fn handle_http_stream(stream: &mut TcpStream, snapshot: &Snapshot) -> Result<(), String> {
let mut buf = [0u8; 4096];
let len = stream.read(&mut buf).map_err(|e| e.to_string())?;
let req = String::from_utf8_lossy(&buf[..len]);
let path = req
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/");
match path {
"/metrics" => write_http_response(
stream,
"200 OK",
"text/plain; version=0.0.4",
&render_metrics(snapshot),
),
"/status" => write_http_response(
stream,
"200 OK",
"application/json",
&render_status_json(snapshot)?,
),
"/healthz" => write_http_response(stream, "200 OK", "text/plain", "ok\n"),
_ => write_http_response(stream, "404 Not Found", "text/plain", "not found\n"),
}
}
fn write_http_response(
stream: &mut TcpStream,
status: &str,
content_type: &str,
body: &str,
) -> Result<(), String> {
let header = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream
.write_all(header.as_bytes())
.and_then(|_| stream.write_all(body.as_bytes()))
.map_err(|e| e.to_string())
}
fn read_json(path: &Path) -> Result<Value, String> {
let bytes = fs::read(path).map_err(|e| format!("read json failed: {}: {e}", path.display()))?;
serde_json::from_slice(&bytes)
.map_err(|e| format!("parse json failed: {}: {e}", path.display()))
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
}
fs::write(path, bytes).map_err(|e| format!("write file failed: {}: {e}", path.display()))
}
fn run_index_from_path(path: &Path) -> Option<u64> {
path.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_prefix("run_"))
.and_then(|value| value.parse::<u64>().ok())
}
fn json_str<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_str()
}
fn json_u64(value: &Value, path: &[&str]) -> Option<u64> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_u64()
}
fn json_i64(value: &Value, path: &[&str]) -> Option<i64> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_i64()
}
fn json_f64(value: &Value, path: &[&str]) -> Option<f64> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current
.as_f64()
.or_else(|| current.as_i64().map(|value| value as f64))
.or_else(|| current.as_u64().map(|value| value as f64))
}
fn json_bool(value: &Value, path: &[&str]) -> Option<bool> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_bool()
}
fn parse_rfc3339_to_unix(value: &str) -> Option<f64> {
time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
.ok()
.map(|dt| dt.unix_timestamp() as f64)
}
fn unix_now_seconds() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
fn bool_value(value: bool) -> f64 {
if value { 1.0 } else { 0.0 }
}
fn format_prom_value(value: f64) -> String {
if value.is_infinite() && value.is_sign_positive() {
"+Inf".to_string()
} else if value.fract() == 0.0 {
format!("{value:.0}")
} else {
format!("{value:.6}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}
fn escape_label(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('"', "\\\"")
}
fn escape_help(value: &str) -> String {
value.replace('\\', "\\\\").replace('\n', "\\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ccr::{
CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation,
build_aspa_payload_state, build_roa_payload_state, encode_content_info,
};
use crate::data_model::roa::{IpPrefix, RoaAfi};
use crate::validation::objects::{AspaAttestation, Vrp};
use tempfile::TempDir;
#[test]
fn parse_args_accepts_required_paths() {
let args = parse_args(&[
"rpki_inter_rp_metrics".to_string(),
"--ours-run-root".to_string(),
"ours".to_string(),
"--peer-root".to_string(),
"peers".to_string(),
"--once".to_string(),
])
.expect("parse");
assert_eq!(args.ours_run_root, PathBuf::from("ours"));
assert_eq!(args.peer_root, PathBuf::from("peers"));
assert!(args.once);
}
#[test]
fn count_csv_rows_ignores_header_comments_and_blanks() {
let temp = TempDir::new().expect("temp");
let path = temp.path().join("vrps.csv");
fs::write(
&path,
"# generated\nASN,IP Prefix,Max Length,Trust Anchor\nAS1,192.0.2.0/24,24,ta\n\n",
)
.expect("write");
assert_eq!(count_csv_rows(&path).expect("count"), 1);
}
#[test]
fn count_vrp_csv_uses_unique_vrp_keys() {
let temp = TempDir::new().expect("temp");
let path = temp.path().join("vrps.csv");
fs::write(
&path,
"ASN,IP Prefix,Max Length,Trust Anchor,Expires\n\
AS1,192.0.2.0/24,24,ta-a,100\n\
AS1,192.0.2.0/24,24,ta-a,100\n\
AS1,192.0.2.0/24,24,ta-b,200\n\
AS2,198.51.100.0/24,24,ta-a,100\n",
)
.expect("write");
assert_eq!(count_csv_rows(&path).expect("raw rows"), 4);
assert_eq!(count_vrp_csv_unique_keys(&path).expect("unique vrps"), 2);
}
#[test]
fn count_vap_csv_uses_unique_vap_keys() {
let temp = TempDir::new().expect("temp");
let path = temp.path().join("vaps.csv");
fs::write(
&path,
"Customer ASN,Providers,Trust Anchor\n\
AS64496,AS64497;AS64498,arin\n\
AS64496,AS64497;AS64498,ripe\n\
AS64496,AS64497,arin\n",
)
.expect("write");
assert_eq!(count_csv_rows(&path).expect("raw rows"), 3);
assert_eq!(count_vap_csv_unique_keys(&path).expect("unique vaps"), 2);
}
#[test]
fn scan_fixture_renders_core_metrics_and_ccr_match() {
let temp = TempDir::new().expect("temp");
let ours_run = temp.path().join("ours/runs/run_0001");
fs::create_dir_all(&ours_run).expect("mkdir");
fs::write(
ours_run.join("run-summary.json"),
r#"{
"runId":"run_0001",
"runSeq":1,
"status":"success",
"exitCode":0,
"wallMs":10000,
"finishedAtRfc3339Utc":"2026-06-09T00:00:00Z",
"processMetrics":{"maxRssKb":1000},
"reportCounts":{"vrps":1,"aspas":1,"publicationPoints":1}
}"#,
)
.expect("write summary");
let ccr = sample_ccr(64496);
fs::write(ours_run.join("result.ccr"), &ccr).expect("write ccr");
let peer_root = temp.path().join("peers");
let routinator = peer_root.join("routinator/latest");
fs::create_dir_all(&routinator).expect("mkdir");
fs::write(
routinator.join("run-meta.json"),
r#"{"runId":"run_0001","runSeq":1,"success":true,"wallMs":9000,"finishedAtRfc3339Utc":"2026-06-09T00:00:01Z","maxRssKb":{"aggregatePeak":900}}"#,
)
.expect("write meta");
fs::write(
routinator.join("vrps.csv"),
"ASN,IP Prefix,Max Length,Trust Anchor\nAS64496,192.0.2.0/24,24,ta\n",
)
.expect("write vrps");
fs::write(
routinator.join("vaps.csv"),
"Customer ASN,Providers,Trust Anchor\nAS64496,AS64497,ta\n",
)
.expect("write vaps");
let rpki_client = peer_root.join("rpki-client/latest");
fs::create_dir_all(&rpki_client).expect("mkdir");
fs::write(
rpki_client.join("run-meta.json"),
r#"{"runId":"run_0001","runSeq":1,"success":true,"wallMs":8000,"finishedAtRfc3339Utc":"2026-06-09T00:00:02Z","maxRssKb":{"aggregatePeak":800},"artifacts":{"ccr":"result.ccr"},"counts":{"vrps":1,"vaps":1}}"#,
)
.expect("write meta");
fs::write(rpki_client.join("result.ccr"), &ccr).expect("write ccr");
fs::write(
peer_root.join("sync-status.json"),
r#"{"success":true,"lastSyncAtRfc3339Utc":"2026-06-09T00:00:03Z","remoteHost":"remote200"}"#,
)
.expect("write sync");
let args = Args {
ours_run_root: temp.path().join("ours"),
peer_root,
listen: "127.0.0.1:0".to_string(),
poll_secs: 1,
instance: "test".to_string(),
once: true,
out_metrics: None,
out_status: None,
};
let snapshot = scan(&args).expect("scan");
let metrics = render_metrics(&snapshot);
assert!(metrics.contains("inter_rp_vrps{instance=\"test\",rp=\"ours-rp\"} 1"));
assert!(metrics.contains("inter_rp_vaps{instance=\"test\",rp=\"rpki-client\"} 1"));
assert!(metrics.contains(
"inter_rp_ccr_digest_match{instance=\"test\",left=\"ours-rp\",right=\"rpki-client\",state=\"overall\"} 1"
));
assert!(metrics.contains(
"inter_rp_vrps_diff{instance=\"test\",left=\"routinator\",right=\"rpki-client\"} 0"
));
}
fn sample_ccr(customer_asn: u32) -> Vec<u8> {
let vrps = build_roa_payload_state(&[Vrp {
asn: customer_asn,
prefix: IpPrefix {
afi: RoaAfi::Ipv4,
prefix_len: 24,
addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
max_length: 24,
}])
.expect("build vrps");
let vaps = build_aspa_payload_state(&[AspaAttestation {
customer_as_id: customer_asn,
provider_as_ids: vec![64497],
}])
.expect("build vaps");
let content = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: time::OffsetDateTime::UNIX_EPOCH,
mfts: None,
vrps: Some(vrps),
vaps: Some(vaps),
tas: None,
rks: None,
});
encode_content_info(&content).expect("encode")
}
}