Structured observability for the iroh/SWIM layer: Aggregator, typed Event/Snapshot types, Sink (NoopSink default), ProbeScheduler, process stats, and host/iroh/swim introspection, plus the swactor-diag-collector, -postproc, and -iroh-relay binaries that assemble and render per-run bundles. Generalizes the pipeline-parallel-inference example to N stages and adds the topology-planner spec. Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
684 lines
22 KiB
Rust
684 lines
22 KiB
Rust
//! Gate for S11: post-processor binary + module.
|
|
//!
|
|
//! Drives the **whole renderer pipeline** against a deterministic
|
|
//! N=3 fixture bundle that we build at test start by feeding the
|
|
//! collector's storage layer the same shape of records a real run
|
|
//! would emit. The committed file `expected-summary.md` is the
|
|
//! golden for the renderer — if you change the renderer output,
|
|
//! update the golden; that's intentional.
|
|
//!
|
|
//! Coverage:
|
|
//!
|
|
//! 1. Fixture bundle is assembled successfully (tarball produced).
|
|
//! 2. `Bundle::parse_path` reads it back with the right shape:
|
|
//! three nodes, identity blocks present, events sorted, etc.
|
|
//! 3. `Outputs::from_bundle` produces a non-empty `summary.md`, a
|
|
//! reachability TSV with the matrix shape we expect, and a
|
|
//! timeline TSV per ordered pair.
|
|
//! 4. `summary.md` matches the committed golden byte-for-byte. This
|
|
//! is the **regression check**.
|
|
//! 5. The compiled `swactor-diag-postproc` binary runs against the
|
|
//! bundle and writes the same files.
|
|
//! 6. `render_diff` produces a sensible message when two bundles
|
|
//! differ on the first-Dead pair.
|
|
|
|
#![cfg(feature = "collector")]
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use distribution::diagnostics::collector::{CollectorState, bundle::assemble};
|
|
use distribution::diagnostics::postproc::{Bundle, Outputs, render_diff};
|
|
|
|
const RUN_ID: &str = "fixture-run-1";
|
|
const ORCH_HEX: &str = "1010101010101010101010101010101010101010101010101010101010101010";
|
|
const STAGE0_HEX: &str = "2020202020202020202020202020202020202020202020202020202020202020";
|
|
const STAGE1_HEX: &str = "3030303030303030303030303030303030303030303030303030303030303030";
|
|
|
|
#[test]
|
|
fn parses_bundle_and_renders_outputs_matching_golden() {
|
|
let env = TestEnv::new("parses");
|
|
let bundle_path = build_fixture_bundle(&env, RUN_ID, FixtureKind::Standard);
|
|
|
|
// 1. Parse.
|
|
let bundle = Bundle::parse_path(&bundle_path).expect("parse bundle");
|
|
assert_eq!(bundle.run_id, RUN_ID);
|
|
assert_eq!(bundle.manifest.nodes.len(), 3);
|
|
for label in ["orchestrator", "stage-0", "stage-1"] {
|
|
let node = bundle
|
|
.nodes
|
|
.get(label)
|
|
.unwrap_or_else(|| panic!("missing node {label}"));
|
|
assert!(
|
|
node.identity.is_some(),
|
|
"node {label} missing identity (boot.json)"
|
|
);
|
|
assert!(
|
|
!node.snapshots.is_empty(),
|
|
"node {label} has no snapshots"
|
|
);
|
|
// orchestrator drives the timeline events; stages have some too.
|
|
assert!(
|
|
!node.events.is_empty(),
|
|
"node {label} has no events"
|
|
);
|
|
// Events should be chronologically sorted.
|
|
for w in node.events.windows(2) {
|
|
assert!(
|
|
(w[0].wall_ms, w[0].monotonic_seq) <= (w[1].wall_ms, w[1].monotonic_seq),
|
|
"node {label} events not chronologically sorted"
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Render every output.
|
|
let outputs = Outputs::from_bundle(&bundle);
|
|
|
|
// 3. Reachability TSV: header line + at least one data row per
|
|
// snapshot. Column count = 2 (time, observer) + N (one per
|
|
// peer label).
|
|
let reach_lines: Vec<&str> = outputs.reachability_tsv.lines().collect();
|
|
assert!(!reach_lines.is_empty(), "reachability tsv empty");
|
|
let header = reach_lines[0];
|
|
let header_cols: Vec<&str> = header.split('\t').collect();
|
|
assert_eq!(header_cols[0], "time_ms");
|
|
assert_eq!(header_cols[1], "observer");
|
|
assert_eq!(header_cols.len(), 2 + 3, "expected 2 + N=3 columns");
|
|
let total_snapshots: usize = bundle.nodes.values().map(|n| n.snapshots.len()).sum();
|
|
assert_eq!(
|
|
reach_lines.len() - 1,
|
|
total_snapshots,
|
|
"one data row per snapshot"
|
|
);
|
|
for row in &reach_lines[1..] {
|
|
let cols: Vec<&str> = row.split('\t').collect();
|
|
assert_eq!(cols.len(), header_cols.len(), "ragged row: {row}");
|
|
}
|
|
|
|
// 4. Timeline TSVs: one per ordered pair of labels, N*(N-1) = 6.
|
|
assert_eq!(outputs.timelines.len(), 6);
|
|
let names: Vec<&str> = outputs.timelines.iter().map(|(n, _)| n.as_str()).collect();
|
|
for required in [
|
|
"timeline-orchestrator-to-stage-1.tsv",
|
|
"timeline-stage-1-to-orchestrator.tsv",
|
|
] {
|
|
assert!(
|
|
names.contains(&required),
|
|
"missing timeline {required}; got {names:?}"
|
|
);
|
|
}
|
|
// The orchestrator→stage-1 timeline must mention the
|
|
// SwimTransition -> Dead row — that's the storyline.
|
|
let orch_to_s1 = outputs
|
|
.timelines
|
|
.iter()
|
|
.find(|(n, _)| n == "timeline-orchestrator-to-stage-1.tsv")
|
|
.map(|(_, b)| b.as_str())
|
|
.unwrap();
|
|
assert!(
|
|
orch_to_s1.contains("SwimTransition"),
|
|
"orchestrator→stage-1 timeline missing SwimTransition rows: {orch_to_s1}"
|
|
);
|
|
assert!(
|
|
orch_to_s1.contains("Dead"),
|
|
"orchestrator→stage-1 timeline missing Dead transition"
|
|
);
|
|
|
|
// 5. summary.md regression check against committed golden.
|
|
let golden_path = fixtures_dir().join("expected-summary.md");
|
|
if std::env::var_os("UPDATE_SUMMARY_GOLDEN").is_some() {
|
|
std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir fixtures");
|
|
std::fs::write(&golden_path, &outputs.summary_md).expect("write golden");
|
|
}
|
|
let golden = std::fs::read_to_string(&golden_path)
|
|
.unwrap_or_else(|_| panic!("missing fixture: {}", golden_path.display()));
|
|
assert_eq!(
|
|
outputs.summary_md, golden,
|
|
"summary.md drifted from golden at {}. Re-run with UPDATE_SUMMARY_GOLDEN=1 if the change is intentional.",
|
|
golden_path.display()
|
|
);
|
|
|
|
// 6. Outputs::write_all_to writes the expected file set.
|
|
let out_dir = env.tmp_root.join("rendered");
|
|
let written = outputs.write_all_to(&out_dir).expect("write outputs");
|
|
assert!(out_dir.join("summary.md").exists());
|
|
assert!(out_dir.join("reachability.tsv").exists());
|
|
assert!(
|
|
written
|
|
.iter()
|
|
.any(|p| p.ends_with("timeline-orchestrator-to-stage-1.tsv")),
|
|
"did not write orchestrator→stage-1 timeline; got {written:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn binary_renders_bundle_to_default_output_dir() {
|
|
let env = TestEnv::new("binary");
|
|
let bundle_path = build_fixture_bundle(&env, RUN_ID, FixtureKind::Standard);
|
|
|
|
let exe = env!("CARGO_BIN_EXE_swactor-diag-postproc");
|
|
let status = Command::new(exe)
|
|
.arg(&bundle_path)
|
|
.status()
|
|
.expect("spawn swactor-diag-postproc");
|
|
assert!(status.success(), "postproc binary failed: {status:?}");
|
|
|
|
// Default OUT_DIR is `<bundle stem>.out/` sibling.
|
|
let out_dir = bundle_path
|
|
.parent()
|
|
.unwrap()
|
|
.join(format!("{RUN_ID}.out"));
|
|
let summary = out_dir.join("summary.md");
|
|
let body = std::fs::read_to_string(&summary).expect("read produced summary.md");
|
|
assert!(!body.is_empty());
|
|
assert!(body.contains("# Diagnostics summary"));
|
|
assert!(out_dir.join("reachability.tsv").exists());
|
|
assert!(out_dir.join("timeline-orchestrator-to-stage-1.tsv").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn diff_highlights_when_first_dead_pair_changes() {
|
|
let env_a = TestEnv::new("diff-a");
|
|
let env_b = TestEnv::new("diff-b");
|
|
let bundle_a = build_fixture_bundle(&env_a, "run-a", FixtureKind::Standard);
|
|
// The "AltVictim" fixture flips who goes Dead — stage-0 is the
|
|
// first to die instead of stage-1. Diff should call that out.
|
|
let bundle_b = build_fixture_bundle(&env_b, "run-b", FixtureKind::AltVictim);
|
|
|
|
let a = Bundle::parse_path(&bundle_a).expect("parse a");
|
|
let b = Bundle::parse_path(&bundle_b).expect("parse b");
|
|
let diff = render_diff(&a, &b);
|
|
assert!(
|
|
diff.contains("first_dead pair changed"),
|
|
"diff did not mention pair change:\n{diff}"
|
|
);
|
|
assert!(diff.contains("run-a"));
|
|
assert!(diff.contains("run-b"));
|
|
}
|
|
|
|
// ---------- Fixture construction ----------
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum FixtureKind {
|
|
/// stage-1 is the first peer to go Dead (from orchestrator's view).
|
|
Standard,
|
|
/// stage-0 is the first peer to go Dead — used by the diff test.
|
|
AltVictim,
|
|
}
|
|
|
|
fn build_fixture_bundle(env: &TestEnv, run_id: &str, kind: FixtureKind) -> PathBuf {
|
|
let state = Arc::new(CollectorState::new(&env.collector_root));
|
|
let dead_peer_hex = match kind {
|
|
FixtureKind::Standard => STAGE1_HEX,
|
|
FixtureKind::AltVictim => STAGE0_HEX,
|
|
};
|
|
|
|
// Boot records — collector treats these as the identity payload
|
|
// for the per-node label.
|
|
persist_boot(&state, run_id, ORCH_HEX, identity_value(ORCH_HEX, "orchestrator", None, None));
|
|
persist_boot(
|
|
&state,
|
|
run_id,
|
|
STAGE0_HEX,
|
|
identity_value(STAGE0_HEX, "stage", Some(0), Some(2)),
|
|
);
|
|
persist_boot(
|
|
&state,
|
|
run_id,
|
|
STAGE1_HEX,
|
|
identity_value(STAGE1_HEX, "stage", Some(1), Some(2)),
|
|
);
|
|
|
|
// Orchestrator events.
|
|
let orch_events = orchestrator_events(dead_peer_hex);
|
|
persist_events(&state, run_id, ORCH_HEX, &orch_events);
|
|
|
|
// stage-0 events.
|
|
let stage0_events = stage_events(STAGE0_HEX, dead_peer_hex == STAGE0_HEX);
|
|
persist_events(&state, run_id, STAGE0_HEX, &stage0_events);
|
|
|
|
// stage-1 events.
|
|
let stage1_events = stage_events(STAGE1_HEX, dead_peer_hex == STAGE1_HEX);
|
|
persist_events(&state, run_id, STAGE1_HEX, &stage1_events);
|
|
|
|
// Snapshots. Each node gets two: pre-incident, post-incident.
|
|
persist_snapshot(
|
|
&state,
|
|
run_id,
|
|
ORCH_HEX,
|
|
orchestrator_snapshot(run_id, 1, 2500, dead_peer_hex, true),
|
|
);
|
|
persist_snapshot(
|
|
&state,
|
|
run_id,
|
|
ORCH_HEX,
|
|
orchestrator_snapshot(run_id, 2, 5200, dead_peer_hex, false),
|
|
);
|
|
persist_snapshot(
|
|
&state,
|
|
run_id,
|
|
STAGE0_HEX,
|
|
stage_snapshot(run_id, STAGE0_HEX, "stage", 0, 1, 2200),
|
|
);
|
|
persist_snapshot(
|
|
&state,
|
|
run_id,
|
|
STAGE1_HEX,
|
|
stage_snapshot(run_id, STAGE1_HEX, "stage", 1, 1, 2200),
|
|
);
|
|
|
|
// Finalize from orchestrator.
|
|
persist_finalize(&state, run_id, ORCH_HEX, json!({"exit_reason": "ok"}));
|
|
|
|
assemble(&state, run_id).expect("assemble bundle")
|
|
}
|
|
|
|
fn persist_boot(state: &CollectorState, run_id: &str, node_hex: &str, body: Value) {
|
|
state
|
|
.persist(
|
|
run_id,
|
|
node_hex,
|
|
distribution::diagnostics::collector::RecordKind::Boot,
|
|
0,
|
|
&body,
|
|
)
|
|
.expect("persist boot");
|
|
}
|
|
|
|
fn persist_events(state: &CollectorState, run_id: &str, node_hex: &str, batch: &[Value]) {
|
|
let body = Value::Array(batch.to_vec());
|
|
state
|
|
.persist(
|
|
run_id,
|
|
node_hex,
|
|
distribution::diagnostics::collector::RecordKind::Events,
|
|
0,
|
|
&body,
|
|
)
|
|
.expect("persist events");
|
|
}
|
|
|
|
fn persist_snapshot(state: &CollectorState, run_id: &str, node_hex: &str, body: Value) {
|
|
state
|
|
.persist(
|
|
run_id,
|
|
node_hex,
|
|
distribution::diagnostics::collector::RecordKind::Snapshot,
|
|
0,
|
|
&body,
|
|
)
|
|
.expect("persist snapshot");
|
|
}
|
|
|
|
fn persist_finalize(state: &CollectorState, run_id: &str, node_hex: &str, body: Value) {
|
|
state
|
|
.persist(
|
|
run_id,
|
|
node_hex,
|
|
distribution::diagnostics::collector::RecordKind::Finalize,
|
|
0,
|
|
&body,
|
|
)
|
|
.expect("persist finalize");
|
|
}
|
|
|
|
fn identity_value(
|
|
node_hex: &str,
|
|
role: &str,
|
|
stage_index: Option<u32>,
|
|
stage_count: Option<u32>,
|
|
) -> Value {
|
|
let short: String = node_hex.chars().take(8).collect();
|
|
json!({
|
|
"node_id_hex": node_hex,
|
|
"node_id_short": short,
|
|
"role": role,
|
|
"stage_index": stage_index,
|
|
"stage_count": stage_count,
|
|
"run_id": RUN_ID,
|
|
"vastai_contract_id": null,
|
|
"host_ip_public": null,
|
|
"host_country": null,
|
|
"datacenter_id": null,
|
|
"hostname": null,
|
|
"container_id": null,
|
|
"process_start_unix_ms": 0u64,
|
|
"boot_sequence": 0u32,
|
|
"binary_version": null,
|
|
"git_sha": null,
|
|
"iroh_version": null,
|
|
"home_relay_url_at_boot": null,
|
|
})
|
|
}
|
|
|
|
// Build a NodeId array as serde sees it (32 numeric bytes).
|
|
fn node_id_array(hex: &str) -> Value {
|
|
let bytes = hex_decode(hex);
|
|
Value::Array(bytes.iter().map(|b| Value::from(*b as u64)).collect())
|
|
}
|
|
|
|
fn hex_decode(hex: &str) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(hex.len() / 2);
|
|
for chunk in hex.as_bytes().chunks_exact(2) {
|
|
let hi = nibble(chunk[0]);
|
|
let lo = nibble(chunk[1]);
|
|
out.push((hi << 4) | lo);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn nibble(b: u8) -> u8 {
|
|
match b {
|
|
b'0'..=b'9' => b - b'0',
|
|
b'a'..=b'f' => b - b'a' + 10,
|
|
b'A'..=b'F' => b - b'A' + 10,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
fn orchestrator_events(dead_peer_hex: &str) -> Vec<Value> {
|
|
let mut out = Vec::new();
|
|
let mut seq: u64 = 0;
|
|
// Dial + transition into Alive for both stages.
|
|
out.push(event(&mut seq, ORCH_HEX, 1000, "DialStarted", json!({
|
|
"peer": node_id_array(STAGE0_HEX),
|
|
"attempt": 1u32,
|
|
"timeout_ms": 500u64,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 1012, "DialOutcome", json!({
|
|
"peer": node_id_array(STAGE0_HEX),
|
|
"attempt": 1u32,
|
|
"outcome": "Success",
|
|
"duration_ms": 12u64,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 1015, "SwimTransition", json!({
|
|
"peer": node_id_array(STAGE0_HEX),
|
|
"from": "Unknown",
|
|
"to": "Alive",
|
|
"reason": "joined",
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 1100, "DialStarted", json!({
|
|
"peer": node_id_array(STAGE1_HEX),
|
|
"attempt": 1u32,
|
|
"timeout_ms": 500u64,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 1114, "DialOutcome", json!({
|
|
"peer": node_id_array(STAGE1_HEX),
|
|
"attempt": 1u32,
|
|
"outcome": "Success",
|
|
"duration_ms": 14u64,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 1120, "SwimTransition", json!({
|
|
"peer": node_id_array(STAGE1_HEX),
|
|
"from": "Unknown",
|
|
"to": "Alive",
|
|
"reason": "joined",
|
|
})));
|
|
|
|
// Ping-pong with both.
|
|
out.push(event(&mut seq, ORCH_HEX, 2000, "MessageSent", json!({
|
|
"peer": node_id_array(STAGE0_HEX),
|
|
"kind": "ping", "size": 32u32,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 2030, "MessageReceived", json!({
|
|
"peer": node_id_array(STAGE0_HEX),
|
|
"kind": "pong", "size": 16u32,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 2050, "MessageSent", json!({
|
|
"peer": node_id_array(STAGE1_HEX),
|
|
"kind": "ping", "size": 32u32,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 2080, "MessageReceived", json!({
|
|
"peer": node_id_array(STAGE1_HEX),
|
|
"kind": "pong", "size": 16u32,
|
|
})));
|
|
|
|
// The dying peer goes through Suspect → Dead. Reuse the dial /
|
|
// cache events to support the storyline.
|
|
out.push(event(&mut seq, ORCH_HEX, 3000, "DialStarted", json!({
|
|
"peer": node_id_array(dead_peer_hex),
|
|
"attempt": 2u32,
|
|
"timeout_ms": 1000u64,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 4000, "DialOutcome", json!({
|
|
"peer": node_id_array(dead_peer_hex),
|
|
"attempt": 2u32,
|
|
"outcome": "Timeout",
|
|
"duration_ms": 1000u64,
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 4001, "ConnectionCacheInvalidated", json!({
|
|
"peer": node_id_array(dead_peer_hex),
|
|
"generation": 1u64,
|
|
"reason": "dial-timeout",
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 4100, "SwimTransition", json!({
|
|
"peer": node_id_array(dead_peer_hex),
|
|
"from": "Alive",
|
|
"to": "Suspect",
|
|
"reason": "missed-acks",
|
|
})));
|
|
out.push(event(&mut seq, ORCH_HEX, 5100, "SwimTransition", json!({
|
|
"peer": node_id_array(dead_peer_hex),
|
|
"from": "Suspect",
|
|
"to": "Dead",
|
|
"reason": "suspicion-timeout",
|
|
})));
|
|
out
|
|
}
|
|
|
|
fn stage_events(self_hex: &str, is_dead_peer: bool) -> Vec<Value> {
|
|
let mut out = Vec::new();
|
|
let mut seq: u64 = 0;
|
|
// Both stages see the orchestrator come Alive and exchange
|
|
// pings, regardless of which one ends up Dead.
|
|
out.push(event(&mut seq, self_hex, 1020, "SwimTransition", json!({
|
|
"peer": node_id_array(ORCH_HEX),
|
|
"from": "Unknown",
|
|
"to": "Alive",
|
|
"reason": "joined",
|
|
})));
|
|
out.push(event(&mut seq, self_hex, 2010, "MessageReceived", json!({
|
|
"peer": node_id_array(ORCH_HEX),
|
|
"kind": "ping", "size": 32u32,
|
|
})));
|
|
if !is_dead_peer {
|
|
// Healthy stage answers the ping; the dying one goes quiet.
|
|
out.push(event(&mut seq, self_hex, 2020, "MessageSent", json!({
|
|
"peer": node_id_array(ORCH_HEX),
|
|
"kind": "pong", "size": 16u32,
|
|
})));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn orchestrator_snapshot(
|
|
run_id: &str,
|
|
snap_seq: u64,
|
|
wall_ms: u64,
|
|
dead_peer_hex: &str,
|
|
pre_incident: bool,
|
|
) -> Value {
|
|
let other_alive_hex = if dead_peer_hex == STAGE1_HEX { STAGE0_HEX } else { STAGE1_HEX };
|
|
let dead_opinion = if pre_incident { "Alive" } else { "Dead" };
|
|
let dead_conn = if pre_incident { "Direct" } else { "None" };
|
|
json!({
|
|
"identity": identity_value(ORCH_HEX, "orchestrator", None, None),
|
|
"run_id": run_id,
|
|
"snapshot_id": format!("orch-{snap_seq}"),
|
|
"wall_ms": wall_ms,
|
|
"monotonic_seq": (snap_seq * 100u64),
|
|
"trigger": {"kind": if pre_incident { "Periodic" } else { "OnDemand" }},
|
|
"body": {
|
|
"reachability": [
|
|
reach_entry(other_alive_hex, "Alive"),
|
|
reach_entry(dead_peer_hex, dead_opinion),
|
|
],
|
|
"events": [],
|
|
"iroh": {
|
|
"home_relay_url": "https://relay.example.test/",
|
|
"peers": [
|
|
iroh_peer(other_alive_hex, "Direct"),
|
|
iroh_peer(dead_peer_hex, dead_conn),
|
|
],
|
|
"metrics": [],
|
|
"connection_cache": [],
|
|
"api_gaps": [],
|
|
"scraped_at_ms": wall_ms
|
|
},
|
|
"probes": {
|
|
"probes": [
|
|
{
|
|
"target": "collector-udp-echo",
|
|
"kind": "udp_echo",
|
|
"resolved_addr": "127.0.0.1:9081",
|
|
"last_attempted_at_ms": wall_ms,
|
|
"last_outcome": "ok",
|
|
"last_rtt_ms": if pre_incident { 5u64 } else { 7u64 },
|
|
"attempts": (if pre_incident { 1u64 } else { 3u64 }),
|
|
"successes": (if pre_incident { 1u64 } else { 3u64 }),
|
|
}
|
|
],
|
|
"scraped_at_ms": wall_ms
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
fn stage_snapshot(
|
|
run_id: &str,
|
|
self_hex: &str,
|
|
role: &str,
|
|
stage_index: u32,
|
|
snap_seq: u64,
|
|
wall_ms: u64,
|
|
) -> Value {
|
|
let (peer_a, peer_b) = if self_hex == STAGE0_HEX {
|
|
(ORCH_HEX, STAGE1_HEX)
|
|
} else {
|
|
(ORCH_HEX, STAGE0_HEX)
|
|
};
|
|
json!({
|
|
"identity": identity_value(self_hex, role, Some(stage_index), Some(2)),
|
|
"run_id": run_id,
|
|
"snapshot_id": format!("stage-{stage_index}-{snap_seq}"),
|
|
"wall_ms": wall_ms,
|
|
"monotonic_seq": (snap_seq * 100u64),
|
|
"trigger": {"kind": "Periodic"},
|
|
"body": {
|
|
"reachability": [
|
|
reach_entry(peer_a, "Alive"),
|
|
reach_entry(peer_b, "Alive"),
|
|
],
|
|
"events": [],
|
|
"iroh": {
|
|
"home_relay_url": "https://relay.example.test/",
|
|
"peers": [
|
|
iroh_peer(peer_a, "Direct"),
|
|
iroh_peer(peer_b, "Direct"),
|
|
],
|
|
"metrics": [],
|
|
"connection_cache": [],
|
|
"api_gaps": [],
|
|
"scraped_at_ms": wall_ms
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn reach_entry(peer_hex: &str, opinion: &str) -> Value {
|
|
json!({
|
|
"peer_node_id_hex": peer_hex,
|
|
"last_inbound_packet_at_ms": null,
|
|
"last_inbound_via": null,
|
|
"last_outbound_success_at_ms": null,
|
|
"last_outbound_via": null,
|
|
"last_dial_started_at_ms": null,
|
|
"last_dial_outcome": null,
|
|
"last_dial_duration_ms": null,
|
|
"current_swim_opinion": opinion,
|
|
"current_swim_opinion_since_ms": null,
|
|
"swim_transition_history": [],
|
|
"metadata_version_seen": null,
|
|
"metadata_relay_url_seen": null,
|
|
})
|
|
}
|
|
|
|
fn iroh_peer(peer_hex: &str, conn_type: &str) -> Value {
|
|
json!({
|
|
"peer_node_id_hex": peer_hex,
|
|
"conn_type": conn_type,
|
|
"direct_addresses": [],
|
|
"relay_urls": [],
|
|
})
|
|
}
|
|
|
|
fn event(seq: &mut u64, node_hex: &str, wall_ms: u64, ty: &str, mut payload: Value) -> Value {
|
|
*seq += 1;
|
|
let header = json!({
|
|
"node_id": node_id_array(node_hex),
|
|
"monotonic_seq": *seq,
|
|
"wall_ms": wall_ms,
|
|
"type": ty,
|
|
});
|
|
if let (Value::Object(mut h), Value::Object(p)) = (header, payload.take_or_object()) {
|
|
for (k, v) in p {
|
|
h.insert(k, v);
|
|
}
|
|
Value::Object(h)
|
|
} else {
|
|
unreachable!("event payload must be an object")
|
|
}
|
|
}
|
|
|
|
trait TakeOrObject {
|
|
fn take_or_object(&mut self) -> Value;
|
|
}
|
|
|
|
impl TakeOrObject for Value {
|
|
fn take_or_object(&mut self) -> Value {
|
|
std::mem::replace(self, Value::Object(serde_json::Map::new()))
|
|
}
|
|
}
|
|
|
|
// ---------- Test scaffolding ----------
|
|
|
|
fn fixtures_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/diag-bundle-n3")
|
|
}
|
|
|
|
struct TestEnv {
|
|
tmp_root: PathBuf,
|
|
collector_root: PathBuf,
|
|
}
|
|
|
|
impl TestEnv {
|
|
fn new(label: &str) -> Self {
|
|
let pid = std::process::id();
|
|
let nano = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.subsec_nanos())
|
|
.unwrap_or(0);
|
|
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let tmp_root = std::env::temp_dir().join(format!(
|
|
"swactor-diag-postproc-{pid}-{label}-{nano}-{n}"
|
|
));
|
|
let collector_root = tmp_root.join("collector");
|
|
std::fs::create_dir_all(&collector_root).expect("tempdir");
|
|
Self {
|
|
tmp_root,
|
|
collector_root,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for TestEnv {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.tmp_root);
|
|
}
|
|
}
|
|
|