rpki/src/tools/rpki_daemon.rs

1785 lines
62 KiB
Rust

use serde::Serialize;
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
#[derive(Clone, Debug, PartialEq, Eq)]
struct Args {
state_root: PathBuf,
rpki_bin: PathBuf,
interval_secs: u64,
max_runs: Option<u64>,
retain_runs: usize,
status_json: Option<PathBuf>,
summary_jsonl: Option<PathBuf>,
work_db: PathBuf,
repo_bytes_db: Option<PathBuf>,
raw_store_db: Option<PathBuf>,
db_stats_bin: Option<PathBuf>,
db_stats_exact_every: Option<u64>,
time_bin: Option<PathBuf>,
child_args: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct RunContext {
seq: u64,
run_id: String,
run_dir: PathBuf,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum DaemonState {
Starting,
Idle,
Running,
Collecting,
Sleeping,
Exited,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DaemonStatus {
state: DaemonState,
updated_at_rfc3339_utc: String,
runs_completed: u64,
max_runs: Option<u64>,
current_run_seq: Option<u64>,
current_run_id: Option<String>,
last_run_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum RunStatus {
Success,
Failed,
SpawnFailed,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct ArtifactInfo {
path: String,
size_bytes: u64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProcessMetrics {
time_wrapper_used: bool,
time_output_path: Option<String>,
user_seconds: Option<f64>,
system_seconds: Option<f64>,
cpu_percent: Option<f64>,
elapsed_raw: Option<String>,
max_rss_kb: Option<u64>,
exit_status_from_time: Option<i32>,
parse_error: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct ReportCounts {
vrps: usize,
aspas: usize,
publication_points: usize,
rrdp_repos_unique: Option<u64>,
tree_instances_processed: Option<u64>,
tree_instances_failed: Option<u64>,
warnings: usize,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct PathFileStats {
label: String,
path: String,
exists: bool,
is_dir: bool,
total_size_bytes: u64,
file_count: u64,
dir_count: u64,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DbStatsSummary {
mode: String,
db_path: String,
output_path: Option<String>,
stderr_path: Option<String>,
status: String,
exit_code: Option<i32>,
error: Option<String>,
metrics: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RunSummary {
run_seq: u64,
run_id: String,
run_dir: String,
started_at_rfc3339_utc: String,
finished_at_rfc3339_utc: String,
wall_ms: u64,
status: RunStatus,
exit_code: Option<i32>,
exit_status: Option<String>,
error: Option<String>,
rpki_bin: String,
child_args: Vec<String>,
stdout_path: String,
stderr_path: String,
process_metrics: Option<ProcessMetrics>,
stage_timing: Option<serde_json::Value>,
report_counts: Option<ReportCounts>,
repo_sync_stats: Option<serde_json::Value>,
path_stats: Vec<PathFileStats>,
db_stats: Vec<DbStatsSummary>,
retention_deleted_runs: Vec<String>,
artifacts: Vec<ArtifactInfo>,
}
fn usage() -> String {
let bin = "rpki_daemon";
format!(
"\
Usage:
{bin} --state-root <path> --rpki-bin <path> [options] -- <rpki args...>
Options:
--state-root <path> Persistent daemon root containing state/, runs/, status, and JSONL summary
--rpki-bin <path> rpki child binary to execute for each run
--interval-secs <n> Sleep seconds between runs (default: 60)
--max-runs <n> Stop after n runs (default: run forever)
--retain-runs <n> Keep only the latest n run directories (default: 10)
--status-json <path> Override status JSON path (default: <state-root>/daemon-status.json)
--summary-jsonl <path> Override summary JSONL path (default: <state-root>/daemon-runs.jsonl)
--work-db <path> Work DB path for metrics (default: <state-root>/state/work-db)
--repo-bytes-db <path> Repo bytes DB path for file metrics (default: <state-root>/state/repo-bytes.db)
--raw-store-db <path> Raw store DB path for file metrics (optional)
--db-stats-bin <path> db_stats binary path (default: sibling db_stats next to this executable when present)
--db-stats-exact-every <n>
Run db_stats --exact every n runs (default: disabled)
--time-bin <path> GNU time binary for child process metrics (default: /usr/bin/time when present)
--no-time-wrapper Disable GNU time wrapper
--help Show this help
Child argument placeholders:
{{state_root}} Daemon state root
{{run_out}} Current run output directory
{{run_id}} Current run id, e.g. 000001-20260428T090000Z
{{run_seq}} Current run sequence number
"
)
}
fn default_time_bin() -> Option<PathBuf> {
let path = PathBuf::from("/usr/bin/time");
if path.is_file() { Some(path) } else { None }
}
fn default_db_stats_bin() -> Option<PathBuf> {
let mut path = std::env::current_exe().ok()?;
path.set_file_name("db_stats");
if path.is_file() { Some(path) } else { None }
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
if argv.iter().any(|arg| arg == "--help" || arg == "-h") {
return Err(usage());
}
let mut state_root: Option<PathBuf> = None;
let mut rpki_bin: Option<PathBuf> = None;
let mut interval_secs = 60u64;
let mut max_runs = None;
let mut retain_runs = 10usize;
let mut status_json: Option<PathBuf> = None;
let mut summary_jsonl: Option<PathBuf> = None;
let mut work_db: Option<PathBuf> = None;
let mut repo_bytes_db: Option<PathBuf> = None;
let mut raw_store_db: Option<PathBuf> = None;
let mut db_stats_bin: Option<PathBuf> = None;
let mut db_stats_exact_every = None;
let mut time_bin = default_time_bin();
let mut no_time_wrapper = false;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--" => {
let child_args = argv[i + 1..].to_vec();
let state_root =
state_root.ok_or_else(|| format!("--state-root is required\n\n{}", usage()))?;
let work_db = work_db.unwrap_or_else(|| state_root.join("state").join("work-db"));
let repo_bytes_db =
repo_bytes_db.or_else(|| Some(state_root.join("state").join("repo-bytes.db")));
if no_time_wrapper {
time_bin = None;
}
let args = Args {
state_root,
rpki_bin: rpki_bin
.ok_or_else(|| format!("--rpki-bin is required\n\n{}", usage()))?,
interval_secs,
max_runs,
retain_runs,
status_json,
summary_jsonl,
work_db,
repo_bytes_db,
raw_store_db,
db_stats_bin,
db_stats_exact_every,
time_bin,
child_args,
};
return validate_args(args);
}
"--state-root" => {
i += 1;
state_root = Some(PathBuf::from(value_at(argv, i, "--state-root")?));
}
"--rpki-bin" => {
i += 1;
rpki_bin = Some(PathBuf::from(value_at(argv, i, "--rpki-bin")?));
}
"--interval-secs" => {
i += 1;
interval_secs =
parse_u64(value_at(argv, i, "--interval-secs")?, "--interval-secs")?;
}
"--max-runs" => {
i += 1;
let parsed = parse_u64(value_at(argv, i, "--max-runs")?, "--max-runs")?;
if parsed == 0 {
return Err("--max-runs must be > 0".to_string());
}
max_runs = Some(parsed);
}
"--retain-runs" => {
i += 1;
let parsed = parse_usize(value_at(argv, i, "--retain-runs")?, "--retain-runs")?;
if parsed == 0 {
return Err("--retain-runs must be > 0".to_string());
}
retain_runs = parsed;
}
"--status-json" => {
i += 1;
status_json = Some(PathBuf::from(value_at(argv, i, "--status-json")?));
}
"--summary-jsonl" => {
i += 1;
summary_jsonl = Some(PathBuf::from(value_at(argv, i, "--summary-jsonl")?));
}
"--work-db" => {
i += 1;
work_db = Some(PathBuf::from(value_at(argv, i, "--work-db")?));
}
"--repo-bytes-db" => {
i += 1;
repo_bytes_db = Some(PathBuf::from(value_at(argv, i, "--repo-bytes-db")?));
}
"--raw-store-db" => {
i += 1;
raw_store_db = Some(PathBuf::from(value_at(argv, i, "--raw-store-db")?));
}
"--db-stats-bin" => {
i += 1;
db_stats_bin = Some(PathBuf::from(value_at(argv, i, "--db-stats-bin")?));
}
"--db-stats-exact-every" => {
i += 1;
let parsed = parse_u64(
value_at(argv, i, "--db-stats-exact-every")?,
"--db-stats-exact-every",
)?;
if parsed == 0 {
return Err("--db-stats-exact-every must be > 0".to_string());
}
db_stats_exact_every = Some(parsed);
}
"--time-bin" => {
i += 1;
time_bin = Some(PathBuf::from(value_at(argv, i, "--time-bin")?));
}
"--no-time-wrapper" => {
no_time_wrapper = true;
}
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
}
i += 1;
}
Err(format!("missing -- before child rpki args\n\n{}", usage()))
}
fn validate_args(args: Args) -> Result<Args, String> {
if args.child_args.is_empty() {
return Err(format!(
"child rpki args are required after --\n\n{}",
usage()
));
}
Ok(args)
}
fn value_at<'a>(argv: &'a [String], index: usize, flag: &str) -> Result<&'a str, String> {
argv.get(index)
.map(String::as_str)
.ok_or_else(|| format!("{flag} requires a value"))
}
fn parse_u64(raw: &str, flag: &str) -> Result<u64, String> {
raw.parse::<u64>()
.map_err(|_| format!("invalid {flag}: {raw}"))
}
fn parse_usize(raw: &str, flag: &str) -> Result<usize, String> {
raw.parse::<usize>()
.map_err(|_| format!("invalid {flag}: {raw}"))
}
fn status_path(args: &Args) -> PathBuf {
args.status_json
.clone()
.unwrap_or_else(|| args.state_root.join("daemon-status.json"))
}
fn summary_jsonl_path(args: &Args) -> PathBuf {
args.summary_jsonl
.clone()
.unwrap_or_else(|| args.state_root.join("daemon-runs.jsonl"))
}
fn utc_now() -> time::OffsetDateTime {
time::OffsetDateTime::now_utc().to_offset(time::UtcOffset::UTC)
}
fn format_rfc3339(t: time::OffsetDateTime) -> Result<String, String> {
t.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| format!("format RFC3339 failed: {e}"))
}
fn format_compact_utc(t: time::OffsetDateTime) -> String {
format!(
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
t.year(),
u8::from(t.month()),
t.day(),
t.hour(),
t.minute(),
t.second()
)
}
fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
}
let file =
File::create(path).map_err(|e| format!("create json failed: {}: {e}", path.display()))?;
serde_json::to_writer_pretty(file, value)
.map_err(|e| format!("write json failed: {}: {e}", path.display()))
}
fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("open jsonl failed: {}: {e}", path.display()))?;
serde_json::to_writer(&mut file, value)
.map_err(|e| format!("write jsonl failed: {}: {e}", path.display()))?;
file.write_all(b"\n")
.map_err(|e| format!("flush jsonl failed: {}: {e}", path.display()))
}
fn write_status(
args: &Args,
state: DaemonState,
runs_completed: u64,
current: Option<&RunContext>,
last_run_id: Option<String>,
) -> Result<(), String> {
let updated_at_rfc3339_utc = format_rfc3339(utc_now())?;
let status = DaemonStatus {
state,
updated_at_rfc3339_utc,
runs_completed,
max_runs: args.max_runs,
current_run_seq: current.map(|ctx| ctx.seq),
current_run_id: current.map(|ctx| ctx.run_id.clone()),
last_run_id,
};
write_json_pretty(&status_path(args), &status)
}
fn render_child_args(args: &[String], daemon_args: &Args, ctx: &RunContext) -> Vec<String> {
args.iter()
.map(|arg| {
arg.replace("{state_root}", &path_string(&daemon_args.state_root))
.replace("{run_out}", &path_string(&ctx.run_dir))
.replace("{run_id}", &ctx.run_id)
.replace("{run_seq}", &ctx.seq.to_string())
})
.collect()
}
fn render_path_template(path: &Path, daemon_args: &Args, ctx: &RunContext) -> PathBuf {
PathBuf::from(
path_string(path)
.replace("{state_root}", &path_string(&daemon_args.state_root))
.replace("{run_out}", &path_string(&ctx.run_dir))
.replace("{run_id}", &ctx.run_id)
.replace("{run_seq}", &ctx.seq.to_string()),
)
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn make_run_context(args: &Args, seq: u64, now: time::OffsetDateTime) -> RunContext {
let run_id = format!("{seq:06}-{}", format_compact_utc(now));
let run_dir = args.state_root.join("runs").join(&run_id);
RunContext {
seq,
run_id,
run_dir,
}
}
fn collect_artifacts(run_dir: &Path) -> Result<Vec<ArtifactInfo>, String> {
let mut artifacts = Vec::new();
for entry in fs::read_dir(run_dir)
.map_err(|e| format!("read run dir failed: {}: {e}", run_dir.display()))?
{
let entry = entry.map_err(|e| format!("read run dir entry failed: {e}"))?;
if !entry
.file_type()
.map_err(|e| format!("read file type failed: {}: {e}", entry.path().display()))?
.is_file()
{
continue;
}
let metadata = entry
.metadata()
.map_err(|e| format!("read metadata failed: {}: {e}", entry.path().display()))?;
artifacts.push(ArtifactInfo {
path: path_string(&entry.path()),
size_bytes: metadata.len(),
});
}
artifacts.sort_by(|a, b| a.path.cmp(&b.path));
Ok(artifacts)
}
fn collect_process_metrics(time_wrapper_used: bool, time_output_path: &Path) -> ProcessMetrics {
if !time_wrapper_used {
return ProcessMetrics {
time_wrapper_used,
time_output_path: None,
user_seconds: None,
system_seconds: None,
cpu_percent: None,
elapsed_raw: None,
max_rss_kb: None,
exit_status_from_time: None,
parse_error: None,
};
}
let mut metrics = ProcessMetrics {
time_wrapper_used,
time_output_path: Some(path_string(time_output_path)),
user_seconds: None,
system_seconds: None,
cpu_percent: None,
elapsed_raw: None,
max_rss_kb: None,
exit_status_from_time: None,
parse_error: None,
};
let text = match fs::read_to_string(time_output_path) {
Ok(text) => text,
Err(err) => {
metrics.parse_error = Some(format!(
"read process time output failed: {}: {err}",
time_output_path.display()
));
return metrics;
}
};
for line in text.lines() {
let line = line.trim();
if let Some(value) = line.strip_prefix("User time (seconds):") {
metrics.user_seconds = value.trim().parse::<f64>().ok();
} else if let Some(value) = line.strip_prefix("System time (seconds):") {
metrics.system_seconds = value.trim().parse::<f64>().ok();
} else if let Some(value) = line.strip_prefix("Percent of CPU this job got:") {
metrics.cpu_percent = value.trim().trim_end_matches('%').parse::<f64>().ok();
} else if let Some(value) =
line.strip_prefix("Elapsed (wall clock) time (h:mm:ss or m:ss):")
{
metrics.elapsed_raw = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("Maximum resident set size (kbytes):") {
metrics.max_rss_kb = value.trim().parse::<u64>().ok();
} else if let Some(value) = line.strip_prefix("Exit status:") {
metrics.exit_status_from_time = value.trim().parse::<i32>().ok();
}
}
metrics
}
fn run_child_once(args: &Args, ctx: &RunContext) -> Result<RunSummary, String> {
fs::create_dir_all(&ctx.run_dir)
.map_err(|e| format!("create run dir failed: {}: {e}", ctx.run_dir.display()))?;
let started_at = utc_now();
let started_at_rfc3339_utc = format_rfc3339(started_at)?;
let stdout_path = ctx.run_dir.join("stdout.log");
let stderr_path = ctx.run_dir.join("stderr.log");
let stdout = File::create(&stdout_path)
.map_err(|e| format!("create stdout log failed: {}: {e}", stdout_path.display()))?;
let stderr = File::create(&stderr_path)
.map_err(|e| format!("create stderr log failed: {}: {e}", stderr_path.display()))?;
let child_args = render_child_args(&args.child_args, args, ctx);
let time_output_path = ctx.run_dir.join("process-time.txt");
let mut command = if let Some(time_bin) = args.time_bin.as_ref() {
let mut command = Command::new(time_bin);
command
.arg("-v")
.arg("-o")
.arg(&time_output_path)
.arg("--")
.arg(&args.rpki_bin)
.args(&child_args);
command
} else {
let mut command = Command::new(&args.rpki_bin);
command.args(&child_args);
command
};
command
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr));
let (status, exit_code, exit_status, error) = match command.status() {
Ok(status) if status.success() => (
RunStatus::Success,
status.code(),
Some(status.to_string()),
None,
),
Ok(status) => (
RunStatus::Failed,
status.code(),
Some(status.to_string()),
None,
),
Err(err) => (
RunStatus::SpawnFailed,
None,
None,
Some(format!("spawn child failed: {err}")),
),
};
let finished_at = utc_now();
let finished_at_rfc3339_utc = format_rfc3339(finished_at)?;
let wall_ms = (finished_at - started_at).whole_milliseconds().max(0) as u64;
let process_metrics = Some(collect_process_metrics(
args.time_bin.is_some(),
&time_output_path,
));
let artifacts = collect_artifacts(&ctx.run_dir)?;
let summary = RunSummary {
run_seq: ctx.seq,
run_id: ctx.run_id.clone(),
run_dir: path_string(&ctx.run_dir),
started_at_rfc3339_utc,
finished_at_rfc3339_utc,
wall_ms,
status,
exit_code,
exit_status,
error,
rpki_bin: path_string(&args.rpki_bin),
child_args,
stdout_path: path_string(&stdout_path),
stderr_path: path_string(&stderr_path),
process_metrics,
stage_timing: None,
report_counts: None,
repo_sync_stats: None,
path_stats: Vec::new(),
db_stats: Vec::new(),
retention_deleted_runs: Vec::new(),
artifacts,
};
Ok(summary)
}
fn apply_retention(runs_root: &Path, retain_runs: usize) -> Result<Vec<PathBuf>, String> {
if !runs_root.exists() {
return Ok(Vec::new());
}
let mut dirs = Vec::new();
for entry in fs::read_dir(runs_root)
.map_err(|e| format!("read runs dir failed: {}: {e}", runs_root.display()))?
{
let entry = entry.map_err(|e| format!("read runs dir entry failed: {e}"))?;
if entry
.file_type()
.map_err(|e| format!("read file type failed: {}: {e}", entry.path().display()))?
.is_dir()
{
dirs.push(entry.path());
}
}
dirs.sort();
let remove_count = dirs.len().saturating_sub(retain_runs);
let mut removed = Vec::new();
for dir in dirs.into_iter().take(remove_count) {
fs::remove_dir_all(&dir)
.map_err(|e| format!("remove old run dir failed: {}: {e}", dir.display()))?;
removed.push(dir);
}
Ok(removed)
}
fn find_named_file(root: &Path, name: &str) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = fs::read_dir(&dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let file_type = entry.file_type().ok()?;
if file_type.is_file() && entry.file_name().to_string_lossy() == name {
return Some(path);
}
if file_type.is_dir() {
stack.push(path);
}
}
}
None
}
fn read_json_value_if_exists(path: &Path) -> Option<serde_json::Value> {
let bytes = fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn json_array_len(value: &serde_json::Value, key: &str) -> usize {
value
.get(key)
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.unwrap_or(0)
}
fn parse_stdout_summary(run_dir: &Path) -> Option<ReportCounts> {
let stdout_path = run_dir.join("stdout.log");
let text = fs::read_to_string(stdout_path).ok()?;
let mut vrps = None;
let mut aspas = None;
let mut publication_points = None;
let mut rrdp_repos_unique = None;
let mut tree_instances_processed = None;
let mut tree_instances_failed = None;
let mut warnings = None;
for line in text.lines() {
if let Some(value) = line.strip_prefix("vrps=") {
vrps = value.trim().parse::<usize>().ok();
} else if let Some(value) = line.strip_prefix("aspas=") {
aspas = value.trim().parse::<usize>().ok();
} else if let Some(value) = line.strip_prefix("audit_publication_points=") {
publication_points = value.trim().parse::<usize>().ok();
} else if let Some(value) = line.strip_prefix("rrdp_repos_unique=") {
rrdp_repos_unique = value.trim().parse::<u64>().ok();
} else if let Some(value) = line.strip_prefix("warnings_total=") {
warnings = value.trim().parse::<usize>().ok();
} else if let Some(rest) = line.strip_prefix("publication_points_processed=") {
for token in rest.split_whitespace() {
if let Some(value) = token.strip_prefix("publication_points_failed=") {
tree_instances_failed = value.parse::<u64>().ok();
} else if tree_instances_processed.is_none() {
tree_instances_processed = token.parse::<u64>().ok();
}
}
}
}
Some(ReportCounts {
vrps: vrps?,
aspas: aspas?,
publication_points: publication_points?,
rrdp_repos_unique,
tree_instances_processed,
tree_instances_failed,
warnings: warnings.unwrap_or(0),
})
}
fn parse_report_counts_fallback(report: &serde_json::Value) -> ReportCounts {
let tree = report.get("tree");
let tree_warnings = tree
.and_then(|tree| tree.get("warnings"))
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let pp_warnings = report
.get("publication_points")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.map(|pp| {
pp.get("warnings")
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.unwrap_or(0)
})
.sum()
})
.unwrap_or(0);
ReportCounts {
vrps: json_array_len(report, "vrps"),
aspas: json_array_len(report, "aspas"),
publication_points: json_array_len(report, "publication_points"),
rrdp_repos_unique: None,
tree_instances_processed: tree
.and_then(|tree| tree.get("instances_processed"))
.and_then(serde_json::Value::as_u64),
tree_instances_failed: tree
.and_then(|tree| tree.get("instances_failed"))
.and_then(serde_json::Value::as_u64),
warnings: tree_warnings + pp_warnings,
}
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn extract_json_object_field(path: &Path, field_name: &str) -> Option<serde_json::Value> {
let bytes = fs::read(path).ok()?;
let needle = format!("\"{field_name}\":");
let pos = find_subslice(&bytes, needle.as_bytes())?;
let mut i = pos + needle.len();
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if bytes.get(i).copied()? != b'{' {
return None;
}
let start = i;
let mut depth = 0u32;
let mut in_string = false;
let mut escaped = false;
for (offset, &b) in bytes[start..].iter().enumerate() {
if in_string {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'{' => depth = depth.saturating_add(1),
b'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
let end = start + offset + 1;
return serde_json::from_slice(&bytes[start..end]).ok();
}
}
_ => {}
}
}
None
}
fn parse_report_metadata(run_dir: &Path) -> (Option<ReportCounts>, Option<serde_json::Value>) {
let Some(report_path) = find_named_file(run_dir, "report.json") else {
return (parse_stdout_summary(run_dir), None);
};
let counts = parse_stdout_summary(run_dir).or_else(|| {
read_json_value_if_exists(&report_path).map(|report| parse_report_counts_fallback(&report))
});
let repo_sync_stats = extract_json_object_field(&report_path, "repo_sync_stats");
(counts, repo_sync_stats)
}
fn collect_path_file_stats(label: &str, path: &Path) -> PathFileStats {
let mut stats = PathFileStats {
label: label.to_string(),
path: path_string(path),
exists: path.exists(),
is_dir: path.is_dir(),
total_size_bytes: 0,
file_count: 0,
dir_count: 0,
};
if !stats.exists {
return stats;
}
if path.is_file() {
if let Ok(metadata) = path.metadata() {
stats.total_size_bytes = metadata.len();
stats.file_count = 1;
}
return stats;
}
let mut stack = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stats.dir_count = stats.dir_count.saturating_add(1);
stack.push(entry.path());
} else if file_type.is_file() {
stats.file_count = stats.file_count.saturating_add(1);
if let Ok(metadata) = entry.metadata() {
stats.total_size_bytes = stats.total_size_bytes.saturating_add(metadata.len());
}
}
}
}
stats
}
fn collect_state_path_stats(args: &Args, ctx: &RunContext) -> Vec<PathFileStats> {
let mut stats = Vec::new();
stats.push(collect_path_file_stats(
"work_db",
&render_path_template(&args.work_db, args, ctx),
));
if let Some(path) = args.repo_bytes_db.as_ref() {
stats.push(collect_path_file_stats(
"repo_bytes_db",
&render_path_template(path, args, ctx),
));
}
if let Some(path) = args.raw_store_db.as_ref() {
stats.push(collect_path_file_stats(
"raw_store_db",
&render_path_template(path, args, ctx),
));
}
stats
}
fn parse_key_value_metrics(text: &str) -> BTreeMap<String, String> {
let mut metrics = BTreeMap::new();
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
metrics.insert(key.trim().to_string(), value.trim().to_string());
}
metrics
}
fn run_db_stats_command(
db_stats_bin: &Path,
db_path: &Path,
run_dir: &Path,
mode: &str,
) -> DbStatsSummary {
let output_path = run_dir.join(format!("db-stats-{mode}.txt"));
let stderr_path = run_dir.join(format!("db-stats-{mode}.stderr.txt"));
let mut summary = DbStatsSummary {
mode: mode.to_string(),
db_path: path_string(db_path),
output_path: Some(path_string(&output_path)),
stderr_path: None,
status: "success".to_string(),
exit_code: None,
error: None,
metrics: BTreeMap::new(),
};
if !db_path.exists() {
summary.status = "skipped".to_string();
summary.error = Some(format!("db path does not exist: {}", db_path.display()));
summary.output_path = None;
return summary;
}
let mut command = Command::new(db_stats_bin);
command.arg("--db").arg(db_path);
if mode == "exact" {
command.arg("--exact");
}
match command.output() {
Ok(output) => {
summary.exit_code = output.status.code();
if !output.status.success() {
summary.status = "failed".to_string();
}
let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
if let Err(err) = fs::write(&output_path, stdout_text.as_bytes()) {
summary.status = "failed".to_string();
summary.error = Some(format!(
"write db_stats output failed: {}: {err}",
output_path.display()
));
}
summary.metrics = parse_key_value_metrics(&stdout_text);
if !output.stderr.is_empty() {
if fs::write(&stderr_path, &output.stderr).is_ok() {
summary.stderr_path = Some(path_string(&stderr_path));
}
}
if !output.status.success() && summary.error.is_none() {
summary.error = Some(String::from_utf8_lossy(&output.stderr).into_owned());
}
}
Err(err) => {
summary.status = "spawn_failed".to_string();
summary.exit_code = None;
summary.output_path = None;
summary.error = Some(format!("spawn db_stats failed: {err}"));
}
}
summary
}
fn collect_db_stats(args: &Args, ctx: &RunContext) -> Vec<DbStatsSummary> {
let work_db = render_path_template(&args.work_db, args, ctx);
let Some(db_stats_bin) = args
.db_stats_bin
.as_ref()
.cloned()
.or_else(default_db_stats_bin)
else {
return vec![DbStatsSummary {
mode: "estimate".to_string(),
db_path: path_string(&work_db),
output_path: None,
stderr_path: None,
status: "skipped".to_string(),
exit_code: None,
error: Some(
"db_stats binary not configured and sibling db_stats was not found".to_string(),
),
metrics: BTreeMap::new(),
}];
};
let mut stats = Vec::new();
stats.push(run_db_stats_command(
&db_stats_bin,
&work_db,
&ctx.run_dir,
"estimate",
));
if args
.db_stats_exact_every
.is_some_and(|every| ctx.seq % every == 0)
{
stats.push(run_db_stats_command(
&db_stats_bin,
&work_db,
&ctx.run_dir,
"exact",
));
}
stats
}
fn collect_post_run_metrics(args: &Args, ctx: &RunContext, summary: &mut RunSummary) {
if let Some(path) = find_named_file(&ctx.run_dir, "stage-timing.json") {
summary.stage_timing = read_json_value_if_exists(&path);
}
let (report_counts, repo_sync_stats) = parse_report_metadata(&ctx.run_dir);
summary.report_counts = report_counts;
summary.repo_sync_stats = repo_sync_stats;
summary.path_stats = collect_state_path_stats(args, ctx);
summary.db_stats = collect_db_stats(args, ctx);
summary.artifacts = collect_artifacts(&ctx.run_dir).unwrap_or_default();
}
fn run_daemon(args: &Args) -> Result<(), String> {
fs::create_dir_all(args.state_root.join("state")).map_err(|e| {
format!(
"create daemon state dir failed: {}: {e}",
args.state_root.join("state").display()
)
})?;
fs::create_dir_all(args.state_root.join("runs")).map_err(|e| {
format!(
"create daemon runs dir failed: {}: {e}",
args.state_root.join("runs").display()
)
})?;
let mut runs_completed = 0u64;
let mut next_seq = 1u64;
let mut last_run_id = None;
write_status(
args,
DaemonState::Starting,
runs_completed,
None,
last_run_id.clone(),
)?;
loop {
if args.max_runs.is_some_and(|max| runs_completed >= max) {
break;
}
write_status(
args,
DaemonState::Idle,
runs_completed,
None,
last_run_id.clone(),
)?;
let ctx = make_run_context(args, next_seq, utc_now());
write_status(
args,
DaemonState::Running,
runs_completed,
Some(&ctx),
last_run_id.clone(),
)?;
let mut summary = run_child_once(args, &ctx)?;
write_status(
args,
DaemonState::Collecting,
runs_completed,
Some(&ctx),
last_run_id.clone(),
)?;
collect_post_run_metrics(args, &ctx, &mut summary);
let removed = apply_retention(&args.state_root.join("runs"), args.retain_runs)?;
summary.retention_deleted_runs = removed.iter().map(|p| path_string(p)).collect();
summary.artifacts = collect_artifacts(&ctx.run_dir).unwrap_or_default();
write_json_pretty(&ctx.run_dir.join("run-summary.json"), &summary)?;
append_json_line(&summary_jsonl_path(args), &summary)?;
runs_completed += 1;
next_seq += 1;
last_run_id = Some(ctx.run_id);
if args.max_runs.is_some_and(|max| runs_completed >= max) {
break;
}
write_status(
args,
DaemonState::Sleeping,
runs_completed,
None,
last_run_id.clone(),
)?;
if args.interval_secs > 0 {
std::thread::sleep(Duration::from_secs(args.interval_secs));
}
}
write_status(args, DaemonState::Exited, runs_completed, None, last_run_id)
}
pub fn main_entry() -> i32 {
let argv: Vec<String> = std::env::args().collect();
match parse_args(&argv) {
Ok(args) => match run_daemon(&args) {
Ok(()) => 0,
Err(err) => {
eprintln!("{err}");
2
}
},
Err(err) => {
if argv.iter().any(|a| a == "--help" || a == "-h") {
println!("{err}");
return 0;
}
eprintln!("{err}");
2
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_args(state_root: PathBuf) -> Args {
Args {
work_db: state_root.join("state/work-db"),
repo_bytes_db: Some(state_root.join("state/repo-bytes.db")),
state_root,
rpki_bin: PathBuf::from("/bin/true"),
interval_secs: 0,
max_runs: Some(1),
retain_runs: 10,
status_json: None,
summary_jsonl: None,
raw_store_db: None,
db_stats_bin: None,
db_stats_exact_every: None,
time_bin: None,
child_args: vec!["--version".to_string()],
}
}
#[test]
fn parse_args_accepts_required_flags_and_child_args() {
let argv = vec![
"rpki_daemon".to_string(),
"--state-root".to_string(),
"/tmp/daemon".to_string(),
"--rpki-bin".to_string(),
"/bin/echo".to_string(),
"--interval-secs".to_string(),
"0".to_string(),
"--max-runs".to_string(),
"2".to_string(),
"--retain-runs".to_string(),
"3".to_string(),
"--".to_string(),
"--db".to_string(),
"{state_root}/state/work-db".to_string(),
"--report-json".to_string(),
"{run_out}/report.json".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert_eq!(args.state_root, PathBuf::from("/tmp/daemon"));
assert_eq!(args.rpki_bin, PathBuf::from("/bin/echo"));
assert_eq!(args.interval_secs, 0);
assert_eq!(args.max_runs, Some(2));
assert_eq!(args.retain_runs, 3);
assert_eq!(args.work_db, PathBuf::from("/tmp/daemon/state/work-db"));
assert_eq!(
args.repo_bytes_db,
Some(PathBuf::from("/tmp/daemon/state/repo-bytes.db"))
);
assert_eq!(
args.child_args,
vec![
"--db",
"{state_root}/state/work-db",
"--report-json",
"{run_out}/report.json"
]
);
}
#[test]
fn usage_and_parse_args_cover_optional_flags_and_errors() {
let help = usage();
assert!(help.contains("--db-stats-exact-every"));
assert!(help.contains("{run_seq}"));
let argv = vec![
"rpki_daemon".to_string(),
"--state-root".to_string(),
"/tmp/daemon".to_string(),
"--rpki-bin".to_string(),
"/bin/echo".to_string(),
"--status-json".to_string(),
"/tmp/status.json".to_string(),
"--summary-jsonl".to_string(),
"/tmp/runs.jsonl".to_string(),
"--work-db".to_string(),
"{state_root}/work".to_string(),
"--repo-bytes-db".to_string(),
"{state_root}/repo-bytes".to_string(),
"--raw-store-db".to_string(),
"{state_root}/raw".to_string(),
"--db-stats-bin".to_string(),
"/bin/echo".to_string(),
"--db-stats-exact-every".to_string(),
"2".to_string(),
"--time-bin".to_string(),
"/usr/bin/time".to_string(),
"--no-time-wrapper".to_string(),
"--".to_string(),
"child".to_string(),
];
let args = parse_args(&argv).expect("optional args");
assert_eq!(args.status_json, Some(PathBuf::from("/tmp/status.json")));
assert_eq!(args.summary_jsonl, Some(PathBuf::from("/tmp/runs.jsonl")));
assert_eq!(args.work_db, PathBuf::from("{state_root}/work"));
assert_eq!(
args.repo_bytes_db,
Some(PathBuf::from("{state_root}/repo-bytes"))
);
assert_eq!(args.raw_store_db, Some(PathBuf::from("{state_root}/raw")));
assert_eq!(args.db_stats_bin, Some(PathBuf::from("/bin/echo")));
assert_eq!(args.db_stats_exact_every, Some(2));
assert_eq!(args.time_bin, None);
for (argv, expected) in [
(vec!["rpki_daemon", "--help"], "Usage:"),
(
vec![
"rpki_daemon",
"--state-root",
"/tmp/x",
"--rpki-bin",
"/bin/echo",
"--max-runs",
"0",
"--",
"child",
],
"--max-runs must be > 0",
),
(
vec![
"rpki_daemon",
"--state-root",
"/tmp/x",
"--rpki-bin",
"/bin/echo",
"--retain-runs",
"0",
"--",
"child",
],
"--retain-runs must be > 0",
),
(
vec![
"rpki_daemon",
"--state-root",
"/tmp/x",
"--rpki-bin",
"/bin/echo",
"--db-stats-exact-every",
"0",
"--",
"child",
],
"--db-stats-exact-every must be > 0",
),
(vec!["rpki_daemon", "--unknown"], "unknown argument"),
(
vec!["rpki_daemon", "--state-root"],
"--state-root requires a value",
),
(vec!["rpki_daemon"], "missing -- before child rpki args"),
(
vec![
"rpki_daemon",
"--state-root",
"/tmp/x",
"--rpki-bin",
"/bin/echo",
"--",
],
"child rpki args are required",
),
] {
let owned: Vec<String> = argv.into_iter().map(str::to_string).collect();
let err = parse_args(&owned).expect_err("parse should fail");
assert!(err.contains(expected), "{err}");
}
}
#[test]
fn render_child_args_replaces_placeholders() {
let args = Args {
state_root: PathBuf::from("/tmp/root"),
rpki_bin: PathBuf::from("/bin/echo"),
interval_secs: 0,
max_runs: Some(1),
retain_runs: 10,
status_json: None,
summary_jsonl: None,
work_db: PathBuf::from("/tmp/root/state/work-db"),
repo_bytes_db: Some(PathBuf::from("/tmp/root/state/repo-bytes.db")),
raw_store_db: None,
db_stats_bin: None,
db_stats_exact_every: None,
time_bin: None,
child_args: vec![
"{state_root}/state/work-db".to_string(),
"{run_out}/result.ccr".to_string(),
"{run_id}".to_string(),
"{run_seq}".to_string(),
],
};
let ctx = RunContext {
seq: 7,
run_id: "000007-20260428T090000Z".to_string(),
run_dir: PathBuf::from("/tmp/root/runs/000007-20260428T090000Z"),
};
assert_eq!(
render_child_args(&args.child_args, &args, &ctx),
vec![
"/tmp/root/state/work-db",
"/tmp/root/runs/000007-20260428T090000Z/result.ccr",
"000007-20260428T090000Z",
"7",
]
);
}
#[test]
fn path_json_and_report_helpers_cover_fallbacks_and_nested_stats() {
let td = tempfile::tempdir().expect("tempdir");
let state_root = td.path().join("daemon");
let mut args = test_args(state_root.clone());
args.work_db = PathBuf::from("{state_root}/state/work-db");
args.repo_bytes_db = Some(PathBuf::from("{state_root}/state/repo-bytes.db"));
args.raw_store_db = Some(PathBuf::from("{state_root}/state/raw-store.db"));
let now = time::Date::from_calendar_date(2026, time::Month::April, 28)
.expect("date")
.with_hms(9, 0, 0)
.expect("time")
.assume_utc();
let ctx = make_run_context(&args, 42, now);
assert_eq!(ctx.run_id, "000042-20260428T090000Z");
let rendered = render_path_template(Path::new("{run_out}/{run_id}/{run_seq}"), &args, &ctx);
assert!(rendered.ends_with("000042-20260428T090000Z/000042-20260428T090000Z/42"));
let nested_json = td.path().join("nested/out/status.json");
write_json_pretty(&nested_json, &serde_json::json!({"ok": true})).expect("write json");
append_json_line(
&td.path().join("nested/out/runs.jsonl"),
&serde_json::json!({"n": 1}),
)
.expect("append jsonl");
fs::create_dir_all(ctx.run_dir.join("subdir")).expect("subdir");
fs::write(ctx.run_dir.join("a.txt"), "aaa").expect("file");
fs::write(ctx.run_dir.join("subdir/ignored.txt"), "bbb").expect("nested file");
let artifacts = collect_artifacts(&ctx.run_dir).expect("artifacts");
assert_eq!(artifacts.len(), 1);
assert!(artifacts[0].path.ends_with("a.txt"));
fs::create_dir_all(state_root.join("state/work-db/nested")).expect("work db");
fs::write(state_root.join("state/work-db/file.sst"), "abc").expect("sst");
fs::write(state_root.join("state/work-db/nested/inner.sst"), "def").expect("inner");
fs::write(state_root.join("state/raw-store.db"), "raw").expect("raw file");
let file_stats =
collect_path_file_stats("raw_store_db", &state_root.join("state/raw-store.db"));
assert!(file_stats.exists);
assert!(!file_stats.is_dir);
assert_eq!(file_stats.file_count, 1);
let missing_stats = collect_path_file_stats("missing", &state_root.join("missing"));
assert!(!missing_stats.exists);
let state_stats = collect_state_path_stats(&args, &ctx);
assert!(
state_stats
.iter()
.any(|s| s.label == "raw_store_db" && s.exists)
);
assert!(
state_stats
.iter()
.any(|s| s.label == "work_db" && s.file_count == 2)
);
let report_path = td.path().join("report.json");
fs::write(
&report_path,
r#"{"repo_sync_stats": { "nested": {"text": "a\"b"} }, "after": 1}"#,
)
.expect("report");
assert_eq!(
extract_json_object_field(&report_path, "repo_sync_stats").expect("repo stats")["nested"]
["text"],
"a\"b"
);
fs::write(&report_path, r#"{"repo_sync_stats": []}"#).expect("report");
assert!(extract_json_object_field(&report_path, "repo_sync_stats").is_none());
assert!(extract_json_object_field(&report_path, "missing").is_none());
let counts = parse_report_counts_fallback(&serde_json::json!({
"vrps": [{}, {}],
"aspas": [{}],
"publication_points": [{"warnings": [{}, {}]}, {"warnings": [{}]}],
"tree": {"instances_processed": 2, "instances_failed": 1, "warnings": [{}]}
}));
assert_eq!(counts.vrps, 2);
assert_eq!(counts.aspas, 1);
assert_eq!(counts.publication_points, 2);
assert_eq!(counts.warnings, 4);
assert_eq!(counts.tree_instances_processed, Some(2));
assert_eq!(counts.tree_instances_failed, Some(1));
}
#[test]
fn retention_removes_oldest_run_directories() {
let td = tempfile::tempdir().expect("tempdir");
let runs = td.path().join("runs");
fs::create_dir_all(&runs).expect("runs dir");
for name in [
"000001-20260428T000001Z",
"000002-20260428T000002Z",
"000003-20260428T000003Z",
] {
fs::create_dir_all(runs.join(name)).expect("run dir");
}
let removed = apply_retention(&runs, 2).expect("retention");
assert_eq!(removed.len(), 1);
assert!(!runs.join("000001-20260428T000001Z").exists());
assert!(runs.join("000002-20260428T000002Z").exists());
assert!(runs.join("000003-20260428T000003Z").exists());
}
#[test]
fn retention_empty_root_and_parse_metrics_error_paths_are_reported() {
let td = tempfile::tempdir().expect("tempdir");
let missing_runs = td.path().join("missing-runs");
assert!(
apply_retention(&missing_runs, 2)
.expect("empty retention")
.is_empty()
);
let disabled = collect_process_metrics(false, &td.path().join("missing-time.txt"));
assert!(!disabled.time_wrapper_used);
assert!(disabled.time_output_path.is_none());
let missing = collect_process_metrics(true, &td.path().join("missing-time.txt"));
assert!(missing.time_wrapper_used);
assert!(
missing
.parse_error
.expect("parse error")
.contains("read process time output failed")
);
}
#[test]
fn process_metrics_parses_gnu_time_elapsed_line() {
let td = tempfile::tempdir().expect("tempdir");
let path = td.path().join("time.txt");
fs::write(
&path,
"User time (seconds): 1.25\nSystem time (seconds): 0.50\nPercent of CPU this job got: 175%\nElapsed (wall clock) time (h:mm:ss or m:ss): 0:01.00\nMaximum resident set size (kbytes): 12345\nExit status: 0\n",
)
.expect("write time");
let metrics = collect_process_metrics(true, &path);
assert_eq!(metrics.user_seconds, Some(1.25));
assert_eq!(metrics.system_seconds, Some(0.50));
assert_eq!(metrics.cpu_percent, Some(175.0));
assert_eq!(metrics.elapsed_raw.as_deref(), Some("0:01.00"));
assert_eq!(metrics.max_rss_kb, Some(12345));
assert_eq!(metrics.exit_status_from_time, Some(0));
}
#[test]
#[cfg(unix)]
fn child_and_db_stats_error_paths_are_reported() {
use std::os::unix::fs::PermissionsExt;
let td = tempfile::tempdir().expect("tempdir");
let run_dir = td.path().join("run");
fs::create_dir_all(&run_dir).expect("run dir");
let missing_db = td.path().join("missing-db");
let skipped =
run_db_stats_command(Path::new("/bin/echo"), &missing_db, &run_dir, "estimate");
assert_eq!(skipped.status, "skipped");
assert!(skipped.output_path.is_none());
let db_path = td.path().join("db");
fs::create_dir_all(&db_path).expect("db");
let failing_bin = td.path().join("failing_db_stats.sh");
fs::write(
&failing_bin,
"#!/bin/sh\necho partial=1\necho db-stats failed >&2\nexit 7\n",
)
.expect("script");
let mut permissions = fs::metadata(&failing_bin).expect("metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&failing_bin, permissions).expect("chmod");
let failed = run_db_stats_command(&failing_bin, &db_path, &run_dir, "exact");
assert_eq!(failed.status, "failed");
assert_eq!(failed.exit_code, Some(7));
assert_eq!(failed.metrics.get("partial").map(String::as_str), Some("1"));
assert!(failed.stderr_path.is_some());
assert!(failed.error.expect("stderr").contains("db-stats failed"));
let spawn_failed = run_db_stats_command(
Path::new("/definitely/not/db_stats"),
&db_path,
&run_dir,
"estimate",
);
assert_eq!(spawn_failed.status, "spawn_failed");
assert!(
spawn_failed
.error
.expect("spawn err")
.contains("spawn db_stats failed")
);
let metrics = parse_key_value_metrics("alpha=1\nnot-a-pair\nbeta = two\n");
assert_eq!(metrics.get("alpha").map(String::as_str), Some("1"));
assert_eq!(metrics.get("beta").map(String::as_str), Some("two"));
let mut args = test_args(td.path().join("daemon"));
args.rpki_bin = PathBuf::from("/bin/sh");
args.time_bin = Some(PathBuf::from("/usr/bin/time"));
args.child_args = vec![
"-c".to_string(),
"echo child-out; echo child-err >&2; exit 7".to_string(),
];
let ctx = RunContext {
seq: 1,
run_id: "000001-20260428T000000Z".to_string(),
run_dir: args.state_root.join("runs/000001-20260428T000000Z"),
};
let summary = run_child_once(&args, &ctx).expect("run failing child");
assert_eq!(summary.status, RunStatus::Failed);
assert_eq!(summary.exit_code, Some(7));
let process_metrics = summary.process_metrics.expect("process metrics");
assert!(process_metrics.time_wrapper_used);
assert_eq!(process_metrics.exit_status_from_time, Some(7));
let mut spawn_args = test_args(td.path().join("spawn"));
spawn_args.rpki_bin = PathBuf::from("/definitely/not/rpki");
spawn_args.child_args = vec!["--help".to_string()];
let spawn_ctx = RunContext {
seq: 1,
run_id: "000001-20260428T000001Z".to_string(),
run_dir: spawn_args.state_root.join("runs/000001-20260428T000001Z"),
};
let spawn_summary = run_child_once(&spawn_args, &spawn_ctx).expect("spawn summary");
assert_eq!(spawn_summary.status, RunStatus::SpawnFailed);
assert!(
spawn_summary
.error
.expect("spawn error")
.contains("spawn child failed")
);
}
#[test]
fn report_metadata_prefers_stdout_summary_and_extracts_repo_sync_stats() {
let td = tempfile::tempdir().expect("tempdir");
fs::write(
td.path().join("stdout.log"),
"RPKI stage2 serial run summary\npublication_points_processed=7 publication_points_failed=1\nrrdp_repos_unique=3\nvrps=11\naspas=2\naudit_publication_points=7\nwarnings_total=5\n",
)
.expect("stdout");
fs::write(
td.path().join("report.json"),
"{\"large\":[{\"ignored\":\"{}\"}],\"repo_sync_stats\":{\"by_phase\":{\"rrdp_ok\":{\"count\":7}},\"by_terminal_state\":{},\"publication_points_total\":7}}",
)
.expect("report");
let (counts, repo_sync_stats) = parse_report_metadata(td.path());
let counts = counts.expect("counts");
assert_eq!(counts.vrps, 11);
assert_eq!(counts.aspas, 2);
assert_eq!(counts.publication_points, 7);
assert_eq!(counts.rrdp_repos_unique, Some(3));
assert_eq!(counts.tree_instances_processed, Some(7));
assert_eq!(counts.tree_instances_failed, Some(1));
assert_eq!(counts.warnings, 5);
assert_eq!(
repo_sync_stats.expect("repo sync")["by_phase"]["rrdp_ok"]["count"].as_u64(),
Some(7)
);
}
#[test]
fn daemon_exits_immediately_when_max_runs_already_reached() {
let td = tempfile::tempdir().expect("tempdir");
let mut args = test_args(td.path().join("daemon"));
args.max_runs = Some(0);
run_daemon(&args).expect("run daemon");
let status_text =
fs::read_to_string(args.state_root.join("daemon-status.json")).expect("status");
assert!(status_text.contains("\"state\": \"exited\""));
assert!(status_text.contains("\"runsCompleted\": 0"));
assert!(args.state_root.join("runs").exists());
}
#[test]
fn daemon_runs_fake_child_twice_and_writes_summaries() {
let td = tempfile::tempdir().expect("tempdir");
let args = Args {
state_root: td.path().join("daemon"),
rpki_bin: PathBuf::from("/bin/sh"),
interval_secs: 0,
max_runs: Some(2),
retain_runs: 10,
status_json: None,
summary_jsonl: None,
work_db: td.path().join("daemon/state/work-db"),
repo_bytes_db: Some(td.path().join("daemon/state/repo-bytes.db")),
raw_store_db: None,
db_stats_bin: None,
db_stats_exact_every: None,
time_bin: None,
child_args: vec![
"-c".to_string(),
"echo stdout-{run_seq}; echo stderr-{run_seq} >&2; echo marker > {run_out}/marker.txt".to_string(),
],
};
run_daemon(&args).expect("run daemon");
let status_text =
fs::read_to_string(args.state_root.join("daemon-status.json")).expect("status");
assert!(status_text.contains("\"state\": \"exited\""));
assert!(status_text.contains("\"runsCompleted\": 2"));
let jsonl =
fs::read_to_string(args.state_root.join("daemon-runs.jsonl")).expect("summary jsonl");
assert_eq!(jsonl.lines().count(), 2);
let run_dirs = fs::read_dir(args.state_root.join("runs"))
.expect("runs dir")
.collect::<Result<Vec<_>, _>>()
.expect("entries");
assert_eq!(run_dirs.len(), 2);
for entry in run_dirs {
assert!(entry.path().join("run-summary.json").exists());
assert!(entry.path().join("marker.txt").exists());
}
}
#[test]
fn daemon_sleep_and_retention_deletion_are_recorded() {
let td = tempfile::tempdir().expect("tempdir");
let mut args = test_args(td.path().join("daemon"));
args.rpki_bin = PathBuf::from("/bin/sh");
args.interval_secs = 1;
args.max_runs = Some(2);
args.retain_runs = 1;
args.child_args = vec![
"-c".to_string(),
"echo run-{run_seq}; printf marker > {run_out}/marker.txt".to_string(),
];
run_daemon(&args).expect("run daemon");
let jsonl = fs::read_to_string(args.state_root.join("daemon-runs.jsonl")).expect("jsonl");
assert_eq!(jsonl.lines().count(), 2);
let last: serde_json::Value =
serde_json::from_str(jsonl.lines().last().expect("last line")).expect("summary");
assert_eq!(
last["retentionDeletedRuns"]
.as_array()
.expect("deleted")
.len(),
1
);
let run_dirs = fs::read_dir(args.state_root.join("runs"))
.expect("runs")
.collect::<Result<Vec<_>, _>>()
.expect("entries");
assert_eq!(run_dirs.len(), 1);
}
#[test]
#[cfg(unix)]
fn daemon_collects_stage_report_db_and_file_metrics() {
use std::os::unix::fs::PermissionsExt;
let td = tempfile::tempdir().expect("tempdir");
let db_stats_bin = td.path().join("fake_db_stats.sh");
fs::write(
&db_stats_bin,
"#!/bin/sh\nif [ \"$3\" = \"--exact\" ]; then echo mode=exact; else echo mode=estimate; fi\necho total=42\necho db.files.total_size_bytes=123\n",
)
.expect("fake db_stats");
let mut permissions = fs::metadata(&db_stats_bin).expect("metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&db_stats_bin, permissions).expect("chmod");
let args = Args {
state_root: td.path().join("daemon"),
rpki_bin: PathBuf::from("/bin/sh"),
interval_secs: 0,
max_runs: Some(1),
retain_runs: 10,
status_json: None,
summary_jsonl: None,
work_db: PathBuf::from("{state_root}/state/work-db"),
repo_bytes_db: Some(PathBuf::from("{state_root}/state/repo-bytes.db")),
raw_store_db: None,
db_stats_bin: Some(db_stats_bin),
db_stats_exact_every: Some(1),
time_bin: None,
child_args: vec![
"-c".to_string(),
"mkdir -p {state_root}/state/work-db {state_root}/state/repo-bytes.db; \
printf x > {state_root}/state/work-db/000001.sst; \
printf '{\"validation_ms\":7,\"download_event_count\":2}' > {run_out}/stage-timing.json; \
printf '{\"tree\":{\"instances_processed\":3,\"instances_failed\":1,\"warnings\":[{}]},\"publication_points\":[{},{}],\"vrps\":[{},{}],\"aspas\":[{}],\"repo_sync_stats\":{\"by_phase\":{\"snapshot\":{\"count\":1}}}}' > {run_out}/report.json"
.to_string(),
],
};
run_daemon(&args).expect("run daemon");
let run_summary = find_named_file(&args.state_root.join("runs"), "run-summary.json")
.expect("run summary");
let summary: serde_json::Value =
serde_json::from_slice(&fs::read(run_summary).expect("read run summary"))
.expect("parse run summary");
assert_eq!(summary["stageTiming"]["validation_ms"].as_u64(), Some(7));
assert_eq!(summary["reportCounts"]["vrps"].as_u64(), Some(2));
assert_eq!(summary["reportCounts"]["aspas"].as_u64(), Some(1));
assert_eq!(
summary["reportCounts"]["publicationPoints"].as_u64(),
Some(2)
);
assert_eq!(
summary["repoSyncStats"]["by_phase"]["snapshot"]["count"].as_u64(),
Some(1)
);
assert_eq!(summary["dbStats"].as_array().expect("db stats").len(), 2);
assert!(
summary["pathStats"]
.as_array()
.expect("path stats")
.iter()
.any(|item| item["label"] == "work_db" && item["exists"] == true)
);
}
}