1785 lines
62 KiB
Rust
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)
|
|
);
|
|
}
|
|
}
|