use std::{
path::PathBuf,
time::{Duration, Instant},
};
use anyhow::{bail, Context, Result};
use aranya_client::{
client::{Client, PublicKeyBundle, TeamId},
Addr,
};
use aranya_crypto::dangerous::spideroak_crypto::{hash::Hash, rust::Sha256};
use aranya_daemon::DaemonHandle;
use aranya_daemon_api::SEED_IKM_SIZE;
use spideroak_base58::ToBase58 as _;
use tempfile::TempDir;
mod convergence;
mod init;
mod metrics;
mod ring;
mod team;
mod topology;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeIndex(pub usize);
impl NodeIndex {
pub fn value(self) -> usize {
self.0
}
}
impl std::fmt::Display for NodeIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value())
}
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum SyncMode {
Poll {
interval: Duration,
},
Hello {
debounce: Duration,
subscription_duration: Duration,
},
}
impl Default for SyncMode {
fn default() -> Self {
Self::Hello {
debounce: Duration::from_millis(100),
subscription_duration: Duration::from_secs(600),
}
}
}
impl SyncMode {
#[allow(dead_code)]
pub fn poll_default() -> Self {
Self::Poll {
interval: Duration::from_secs(1),
}
}
}
#[derive(Clone, Debug)]
pub struct TestConfig {
pub test_name: String,
pub node_count: usize,
pub sync_mode: SyncMode,
pub max_duration: Duration,
pub poll_interval: Duration,
pub init_timeout: Duration,
pub init_batch_size: usize,
pub topology: Topology,
}
impl Default for TestConfig {
fn default() -> Self {
Self {
test_name: String::from("default"),
node_count: 100,
sync_mode: SyncMode::default(),
max_duration: Duration::from_secs(600),
poll_interval: Duration::from_millis(250),
init_timeout: Duration::from_secs(60),
init_batch_size: 10,
topology: Topology::Ring,
}
}
}
impl TestConfig {
pub fn builder() -> TestConfigBuilder {
TestConfigBuilder::default()
}
pub fn validate(&self) -> Result<()> {
if self.node_count < 3 {
bail!("Ring requires at least 3 nodes, got {}", self.node_count);
}
Ok(())
}
}
#[derive(Clone, Debug, Default)]
pub struct TestConfigBuilder {
test_name: Option<String>,
node_count: Option<usize>,
sync_mode: Option<SyncMode>,
max_duration: Option<Duration>,
poll_interval: Option<Duration>,
init_timeout: Option<Duration>,
init_batch_size: Option<usize>,
topology: Option<Topology>,
}
#[allow(dead_code)]
impl TestConfigBuilder {
pub fn test_name(mut self, name: impl Into<String>) -> Self {
self.test_name = Some(name.into());
self
}
pub fn node_count(mut self, count: usize) -> Self {
self.node_count = Some(count);
self
}
pub fn sync_mode(mut self, mode: SyncMode) -> Self {
self.sync_mode = Some(mode);
self
}
pub fn max_duration(mut self, duration: Duration) -> Self {
self.max_duration = Some(duration);
self
}
pub fn poll_interval(mut self, interval: Duration) -> Self {
self.poll_interval = Some(interval);
self
}
pub fn init_timeout(mut self, timeout: Duration) -> Self {
self.init_timeout = Some(timeout);
self
}
pub fn init_batch_size(mut self, size: usize) -> Self {
self.init_batch_size = Some(size);
self
}
pub fn topology(mut self, topology: Topology) -> Self {
self.topology = Some(topology);
self
}
pub fn build(self) -> Result<TestConfig> {
let default = TestConfig::default();
let config = TestConfig {
test_name: self.test_name.unwrap_or(default.test_name),
node_count: self.node_count.unwrap_or(default.node_count),
sync_mode: self.sync_mode.unwrap_or(default.sync_mode),
max_duration: self.max_duration.unwrap_or(default.max_duration),
poll_interval: self.poll_interval.unwrap_or(default.poll_interval),
init_timeout: self.init_timeout.unwrap_or(default.init_timeout),
init_batch_size: self.init_batch_size.unwrap_or(default.init_batch_size),
topology: self.topology.unwrap_or(default.topology),
};
config.validate()?;
Ok(config)
}
}
pub struct NodeCtx {
pub index: usize,
pub client: Client,
pub pk: PublicKeyBundle,
#[expect(unused, reason = "manages daemon lifecycle")]
daemon: DaemonHandle,
pub peers: Vec<NodeIndex>,
#[expect(unused, reason = "for debugging")]
work_dir: PathBuf,
}
impl NodeCtx {
pub async fn aranya_local_addr(&self) -> Result<Addr> {
Ok(self.client.local_addr().await?)
}
fn get_shm_path(path: String) -> String {
if cfg!(target_os = "macos") && path.len() > 31 {
let d = Sha256::hash(path.as_bytes());
let t: [u8; 16] = d[..16].try_into().expect("expected shm path");
return format!("/{}\0", t.to_base58());
}
path
}
}
#[derive(Clone, Debug, Default)]
pub struct ConvergenceStatus {
pub has_label: bool,
pub convergence_time: Option<Instant>,
}
#[derive(Clone, Debug)]
pub struct ConvergenceTimestamps {
pub command_issued: Instant,
pub first_convergence: Option<Instant>,
pub full_convergence: Option<Instant>,
}
impl Default for ConvergenceTimestamps {
fn default() -> Self {
Self {
command_issued: Instant::now(),
first_convergence: None,
full_convergence: None,
}
}
}
pub struct ConvergenceTracker {
pub convergence_label: Option<String>,
pub node_status: Vec<ConvergenceStatus>,
pub timestamps: ConvergenceTimestamps,
pub source_node: NodeIndex,
pub test_name: String,
}
impl ConvergenceTracker {
pub fn new(node_count: usize, test_name: String) -> Self {
Self {
convergence_label: None,
node_status: vec![ConvergenceStatus::default(); node_count],
timestamps: ConvergenceTimestamps::default(),
source_node: NodeIndex(0),
test_name,
}
}
pub fn set_convergence_label(&mut self, name: String) {
self.convergence_label = Some(name);
}
pub fn mark_converged(&mut self, node_index: NodeIndex) {
if !self.node_status[node_index.value()].has_label {
self.node_status[node_index.value()].has_label = true;
self.node_status[node_index.value()].convergence_time = Some(Instant::now());
if self.timestamps.first_convergence.is_none() && node_index != self.source_node {
self.timestamps.first_convergence = Some(Instant::now());
}
}
}
pub fn all_converged(&self) -> bool {
self.node_status.iter().all(|s| s.has_label)
}
pub fn get_unconverged_nodes(&self) -> Vec<NodeIndex> {
self.node_status
.iter()
.enumerate()
.filter(|(_, s)| !s.has_label)
.map(|(i, _)| NodeIndex(i))
.collect()
}
}
pub use topology::{dual_ring_bridge_topology, Topology};
pub struct TestCtx {
pub nodes: Vec<NodeCtx>,
pub topology: Option<Vec<Topology>>,
pub sync_mode: SyncMode,
pub config: TestConfig,
pub team_id: Option<TeamId>,
pub tracker: ConvergenceTracker,
seed_ikm: [u8; SEED_IKM_SIZE],
_work_dir: TempDir,
}
impl TestCtx {
#[allow(dead_code)]
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub async fn add_sync_peer(&mut self, from: NodeIndex, to: NodeIndex) -> Result<()> {
let team_id = self.team_id.context("Team not created")?;
let to_addr = self.nodes[to.value()].aranya_local_addr().await?;
let peer_config = match &self.sync_mode {
SyncMode::Poll { interval } => aranya_client::SyncPeerConfig::builder()
.interval(*interval)
.build()
.context("unable to build sync peer config")?,
SyncMode::Hello { .. } => aranya_client::SyncPeerConfig::builder()
.sync_on_hello(true)
.build()
.context("unable to build sync peer config")?,
};
self.nodes[from.value()]
.client
.team(team_id)
.add_sync_peer(to_addr, peer_config)
.await
.with_context(|| format!("node {} unable to add sync peer {}", from, to))?;
if let SyncMode::Hello {
debounce,
subscription_duration,
} = &self.sync_mode
{
let hello_cfg = aranya_client::HelloSubscriptionConfig::builder()
.graph_change_debounce(*debounce)
.expiration(*subscription_duration)
.build()
.context("unable to build hello subscription config")?;
self.nodes[from.value()]
.client
.team(team_id)
.sync_hello_subscribe(to_addr, hello_cfg)
.await
.with_context(|| {
format!(
"node {} unable to subscribe to hello from peer {}",
from, to
)
})?;
}
self.nodes[from.value()].peers.push(to);
Ok(())
}
#[allow(dead_code)]
pub async fn remove_sync_peer(&mut self, from: NodeIndex, to: NodeIndex) -> Result<()> {
let team_id = self.team_id.context("Team not created")?;
let to_addr = self.nodes[to.value()].aranya_local_addr().await?;
self.nodes[from.value()]
.client
.team(team_id)
.remove_sync_peer(to_addr)
.await
.with_context(|| format!("node {} unable to remove sync peer {}", from, to))?;
if matches!(self.sync_mode, SyncMode::Hello { .. }) {
self.nodes[from.value()]
.client
.team(team_id)
.sync_hello_unsubscribe(to_addr)
.await
.with_context(|| {
format!(
"node {} unable to unsubscribe from hello from peer {}",
from, to
)
})?;
}
self.nodes[from.value()].peers.retain(|&p| p != to);
Ok(())
}
}