feat: stability for deployment and distribution #44
19 changed files with 3315 additions and 83 deletions
20
.deploy/deploy.example.toml
Normal file
20
.deploy/deploy.example.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Deploy configuration for `cargo xtask deploy`
|
||||
# Copy to .deploy/deploy.toml (native) or .deploy/docker.toml (Docker)
|
||||
# and fill in your machine details.
|
||||
|
||||
[defaults]
|
||||
dashboard_port = 9090
|
||||
relay_port = 3340
|
||||
# image = "swactor" # Required for --docker mode
|
||||
# container = "swactor" # Required for --docker mode
|
||||
# relay_hosts = ["1.2.3.4"]
|
||||
# swactor_flags = ["--auth"]
|
||||
|
||||
[[machines]]
|
||||
name = "local-node"
|
||||
local = true
|
||||
|
||||
# [[machines]]
|
||||
# name = "remote-node"
|
||||
# ssh = "my-server" # SSH alias from ~/.ssh/config
|
||||
# swactor_flags = ["--storage-path", "/var/lib/swactor/data"]
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -20,7 +20,8 @@ xtask/config.toml
|
|||
|
||||
# Deploy config (machine-specific)
|
||||
deploy.toml
|
||||
.deploy/
|
||||
.deploy/*
|
||||
!.deploy/deploy.example.toml
|
||||
|
||||
# Local dev node state
|
||||
.dev-node/
|
||||
|
|
@ -190,6 +190,16 @@ fn deliver_actions_tagged(
|
|||
}
|
||||
}
|
||||
}
|
||||
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
|
||||
if let Some(idx) = node_ids.iter().position(|id| id == to) {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let resp = node.handle_indirect_ack(*target, *sequence, piggyback);
|
||||
if !resp.is_empty() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::MembershipChanged { .. } => {
|
||||
// Notifications — no delivery needed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ pub(crate) fn build_live_router(state: AppState) -> Router {
|
|||
.route("/api/datastore/shutdown", post(handle_ds_shutdown))
|
||||
.route("/api/peers", get(handle_peers_list))
|
||||
.route("/api/peers/add", post(handle_peers_add))
|
||||
.route("/api/peers/sync", post(handle_peers_sync))
|
||||
.route("/api/peers/remove", post(handle_peers_remove))
|
||||
.route("/actor/{hex}", get(handle_actor_detail));
|
||||
|
||||
|
|
@ -707,6 +708,107 @@ async fn handle_peers_add(State(state): State<AppState>, body: String) -> Respon
|
|||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
|
||||
async fn handle_peers_sync(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let peers = match parsed.get("peers").and_then(|v| v.as_array()) {
|
||||
Some(arr) => arr,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing peers array"),
|
||||
};
|
||||
|
||||
// Parse all peers first, bail on any error
|
||||
let mut parsed_peers: Vec<(distribution::types::NodeId, String)> = Vec::new();
|
||||
for peer in peers {
|
||||
let node_id_str = match peer.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer missing node_id"),
|
||||
};
|
||||
let label = peer
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) {
|
||||
match b.try_into() {
|
||||
Ok(arr) => arr,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid node_id hex length for {node_id_str}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = distribution::identity::base58_decode(node_id_str) {
|
||||
arr
|
||||
} else {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid node_id: {node_id_str}"),
|
||||
);
|
||||
};
|
||||
|
||||
parsed_peers.push((distribution::types::NodeId(bytes), label));
|
||||
}
|
||||
|
||||
// Add all peers in a single lock acquisition
|
||||
{
|
||||
let mut list = auth.lock().unwrap();
|
||||
for (node_id, label) in &parsed_peers {
|
||||
list.add_peer(*node_id, label.clone());
|
||||
}
|
||||
if let Err(e) = list.save() {
|
||||
eprintln!("warning: failed to persist peers.json: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger a SWIM join to the seed peer if specified
|
||||
let join_seed = parsed.get("join_seed").and_then(|v| v.as_str());
|
||||
if let Some(seed_str) = join_seed {
|
||||
let seed_bytes: Option<[u8; 32]> =
|
||||
if let Some(b) = distribution::identity::hex_decode(seed_str) {
|
||||
b.try_into().ok()
|
||||
} else {
|
||||
distribution::identity::base58_decode(seed_str)
|
||||
};
|
||||
|
||||
if let Some(bytes) = seed_bytes {
|
||||
// Find the relay_url for the seed from the peers array
|
||||
let relay_url = peers.iter().find_map(|p| {
|
||||
let nid = p.get("node_id").and_then(|v| v.as_str())?;
|
||||
// Match by checking if this peer's node_id resolves to the same bytes
|
||||
let peer_bytes: [u8; 32] =
|
||||
if let Some(b) = distribution::identity::hex_decode(nid) {
|
||||
b.try_into().ok()?
|
||||
} else {
|
||||
distribution::identity::base58_decode(nid)?
|
||||
};
|
||||
if peer_bytes == bytes {
|
||||
p.get("relay_url").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(tx) = state.join_sender.lock().unwrap().as_ref() {
|
||||
let _ = tx.send((bytes, relay_url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let added = parsed_peers.len();
|
||||
json_response(format!(r#"{{"ok":true,"added":{added}}}"#))
|
||||
}
|
||||
|
||||
async fn handle_peers_remove(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
|
|
|
|||
|
|
@ -337,6 +337,21 @@ impl NodeDriver {
|
|||
self.send_wire_with_hints::<JoinResponse>(&msg, dest, &hints)
|
||||
}
|
||||
|
||||
NodeAction::ForwardAck {
|
||||
to,
|
||||
target,
|
||||
sequence,
|
||||
piggyback,
|
||||
} => {
|
||||
let dest = self.resolve_addr(to)?;
|
||||
let msg = IndirectAck {
|
||||
target: *target,
|
||||
sequence: *sequence,
|
||||
piggyback: piggyback.clone(),
|
||||
};
|
||||
self.send_wire_with_hints::<IndirectAck>(&msg, dest, &[sender_hint])
|
||||
}
|
||||
|
||||
NodeAction::MembershipChanged { .. } => {
|
||||
// Internal notification — no network I/O.
|
||||
Ok(())
|
||||
|
|
@ -441,6 +456,14 @@ impl NodeDriver {
|
|||
}
|
||||
},
|
||||
|
||||
"swactor_dist::IndirectAck" => match decode::<IndirectAck>(&envelope.payload) {
|
||||
Ok(msg) => self.node.handle_indirect_ack(msg.target, msg.sequence, &msg.piggyback),
|
||||
Err(e) => {
|
||||
eprintln!("driver: decode IndirectAck: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
|
||||
other => {
|
||||
eprintln!("driver: unknown message type: {other}");
|
||||
Vec::new()
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ pub struct IrohDriver {
|
|||
pending_joins: Arc<Mutex<Vec<JoinResult>>>,
|
||||
/// Connections accepted by the background accept loop.
|
||||
accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>>,
|
||||
/// Relay URLs learned from join seeds, used for reconnection.
|
||||
peer_relay_urls: HashMap<NodeId, iroh::RelayUrl>,
|
||||
/// Embedded relay server (if started).
|
||||
#[cfg(feature = "relay")]
|
||||
relay_server: Option<iroh_relay::server::Server>,
|
||||
|
|
@ -187,6 +189,7 @@ impl IrohDriver {
|
|||
peer_auth: config.peer_auth,
|
||||
pending_joins: Arc::new(Mutex::new(Vec::new())),
|
||||
accepted_conns,
|
||||
peer_relay_urls: HashMap::new(),
|
||||
#[cfg(feature = "relay")]
|
||||
relay_server,
|
||||
relay_url,
|
||||
|
|
@ -258,6 +261,11 @@ impl IrohDriver {
|
|||
/// Results are collected in the next `recv()` call.
|
||||
pub fn join(&mut self, seeds: &[EndpointAddr]) {
|
||||
for seed_addr in seeds {
|
||||
// Store relay URL for future reconnection
|
||||
let seed_node_id = NodeId(*seed_addr.id.as_bytes());
|
||||
if let Some(relay) = seed_addr.relay_urls().next() {
|
||||
self.peer_relay_urls.insert(seed_node_id, relay.clone());
|
||||
}
|
||||
self.spawn_join_request(seed_addr.clone());
|
||||
}
|
||||
}
|
||||
|
|
@ -356,16 +364,16 @@ impl IrohDriver {
|
|||
eprintln!("iroh driver: collecting {} pending join connection(s)", pending.len());
|
||||
}
|
||||
for result in pending.drain(..) {
|
||||
self.connections.entry(result.node_id).or_insert(result.conn);
|
||||
self.connections.insert(result.node_id, result.conn);
|
||||
}
|
||||
}
|
||||
|
||||
let (incoming, new_conns) = self.rt.block_on(async {
|
||||
self.receive_pending().await
|
||||
});
|
||||
// Cache connections accepted from remote peers
|
||||
// Cache connections accepted from remote peers (replace stale ones)
|
||||
for (node_id, conn) in new_conns {
|
||||
self.connections.entry(node_id).or_insert(conn);
|
||||
self.connections.insert(node_id, conn);
|
||||
}
|
||||
for (tag, payload, from_key) in incoming {
|
||||
let from = NodeId(*from_key.as_bytes());
|
||||
|
|
@ -434,6 +442,11 @@ impl IrohDriver {
|
|||
self.send_message(to, &msg)
|
||||
}
|
||||
|
||||
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
|
||||
let msg = IndirectAck { target: *target, sequence: *sequence, piggyback: piggyback.clone() };
|
||||
self.send_message(to, &msg)
|
||||
}
|
||||
|
||||
NodeAction::MembershipChanged { .. } => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
@ -495,9 +508,16 @@ impl IrohDriver {
|
|||
}
|
||||
|
||||
let endpoint = self.endpoint.clone();
|
||||
let conn = self.rt.block_on(async {
|
||||
endpoint.connect(key, ALPN).await
|
||||
})?;
|
||||
let conn = if let Some(relay) = self.peer_relay_urls.get(&node_id) {
|
||||
let addr = EndpointAddr::new(key).with_relay_url(relay.clone());
|
||||
self.rt.block_on(async {
|
||||
endpoint.connect(addr, ALPN).await
|
||||
})?
|
||||
} else {
|
||||
self.rt.block_on(async {
|
||||
endpoint.connect(key, ALPN).await
|
||||
})?
|
||||
};
|
||||
|
||||
self.connections.insert(node_id, conn.clone());
|
||||
Ok(conn)
|
||||
|
|
@ -624,6 +644,14 @@ impl IrohDriver {
|
|||
}
|
||||
}
|
||||
|
||||
"swactor_dist::IndirectAck" => match serde_json::from_slice::<IndirectAck>(payload) {
|
||||
Ok(msg) => self.node.handle_indirect_ack(msg.target, msg.sequence, &msg.piggyback),
|
||||
Err(e) => {
|
||||
eprintln!("iroh driver: decode IndirectAck: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
|
||||
other => {
|
||||
eprintln!("iroh driver: unknown message type: {other}");
|
||||
Vec::new()
|
||||
|
|
|
|||
|
|
@ -61,6 +61,24 @@ impl NetworkMessage for PingReq {
|
|||
}
|
||||
}
|
||||
|
||||
/// SWIM indirect ack — "the target you asked me to ping is alive"
|
||||
///
|
||||
/// Sent by a relay node back to the original prober after the relay
|
||||
/// receives an ack from the indirect-ping target.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndirectAck {
|
||||
pub target: NodeId,
|
||||
pub sequence: u64,
|
||||
#[serde(default)]
|
||||
pub piggyback: Vec<u8>,
|
||||
}
|
||||
|
||||
impl NetworkMessage for IndirectAck {
|
||||
fn type_tag() -> &'static str {
|
||||
"swactor_dist::IndirectAck"
|
||||
}
|
||||
}
|
||||
|
||||
/// SWIM join request — "I want to join the cluster"
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JoinRequest {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,16 @@ impl DistributedNode {
|
|||
self.inject_piggyback(actions)
|
||||
}
|
||||
|
||||
pub fn handle_indirect_ack(&mut self, target: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
|
||||
let (membership_bytes, registry_entries, metadata_entries) =
|
||||
unpack_combined_piggyback(piggyback);
|
||||
let actions = self.swim.handle_indirect_ack(target, sequence, &membership_bytes);
|
||||
self.process_membership_changes(&actions);
|
||||
self.merge_registry_entries(registry_entries);
|
||||
self.merge_metadata_entries(metadata_entries);
|
||||
self.inject_piggyback(actions)
|
||||
}
|
||||
|
||||
pub fn handle_join_request(&mut self, from: NodeId) -> Vec<NodeAction> {
|
||||
let actions = self.swim.handle_join_request(from);
|
||||
self.maybe_update_routing_table(from);
|
||||
|
|
@ -382,6 +392,12 @@ impl DistributedNode {
|
|||
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
|
||||
NodeAction::SendPingReq { relay, target, sequence, piggyback: combined }
|
||||
}
|
||||
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
|
||||
let registry_entries = self.registry.take_pending(8);
|
||||
let metadata_entries = self.metadata.take_pending(4);
|
||||
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
|
||||
NodeAction::ForwardAck { to, target, sequence, piggyback: combined }
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! This is the top-level SWIM state machine that a `DistributedNode` will drive.
|
||||
//! It produces `SwimAction`s that the caller translates into real network I/O.
|
||||
|
||||
use crate::identity::hex_encode;
|
||||
use crate::messages::MembershipUpdate;
|
||||
use crate::types::{MemberState, NodeId, NodeRecord};
|
||||
|
||||
|
|
@ -26,6 +27,8 @@ pub enum NodeAction {
|
|||
},
|
||||
/// Send a SWIM ack.
|
||||
SendAck { to: NodeId, sequence: u64, piggyback: Vec<u8> },
|
||||
/// Forward an indirect ack back to the original prober.
|
||||
ForwardAck { to: NodeId, target: NodeId, sequence: u64, piggyback: Vec<u8> },
|
||||
/// Send a join response with the current member list.
|
||||
SendJoinResponse { to: NodeId, members: Vec<NodeRecord> },
|
||||
/// Notification: a node state changed (for wiring into Kademlia).
|
||||
|
|
@ -40,6 +43,9 @@ pub struct SwimNode {
|
|||
dissemination: DisseminationQueue,
|
||||
/// Maximum piggybacked updates per message.
|
||||
max_piggyback: usize,
|
||||
/// PingReqs we forwarded: (requester, target, sequence).
|
||||
/// When we receive an ack matching (target, sequence), forward it to requester.
|
||||
pending_relays: Vec<(NodeId, NodeId, u64)>,
|
||||
}
|
||||
|
||||
impl SwimNode {
|
||||
|
|
@ -49,6 +55,7 @@ impl SwimNode {
|
|||
probe: SwimProbe::new(config),
|
||||
dissemination: DisseminationQueue::new(3), // Λ = 3
|
||||
max_piggyback: 8,
|
||||
pending_relays: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,19 +103,35 @@ impl SwimNode {
|
|||
&mut self.members,
|
||||
);
|
||||
actions.extend(self.translate_probe_actions(probe_actions));
|
||||
|
||||
// Check if this ack completes a pending relay (indirect ping path)
|
||||
if let Some(pos) = self.pending_relays.iter().position(|(_, t, s)| *t == from && *s == sequence) {
|
||||
let (requester, target, seq) = self.pending_relays.remove(pos);
|
||||
let pb = self.dissemination.pack_piggyback(self.max_piggyback);
|
||||
actions.push(NodeAction::ForwardAck {
|
||||
to: requester, target, sequence: seq, piggyback: pb,
|
||||
});
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
/// Handle a received indirect ping request.
|
||||
pub fn handle_ping_req(
|
||||
&mut self,
|
||||
_from: NodeId,
|
||||
from: NodeId,
|
||||
target: NodeId,
|
||||
sequence: u64,
|
||||
piggyback: &[u8],
|
||||
) -> Vec<NodeAction> {
|
||||
let mut actions = self.apply_piggyback(piggyback);
|
||||
|
||||
// Record the pending relay so we can forward the ack back
|
||||
if self.pending_relays.len() >= 16 {
|
||||
self.pending_relays.remove(0);
|
||||
}
|
||||
self.pending_relays.push((from, target, sequence));
|
||||
|
||||
// Forward a ping to the target on behalf of the requester
|
||||
let pb = self.dissemination.pack_piggyback(self.max_piggyback);
|
||||
actions.push(NodeAction::SendPing {
|
||||
|
|
@ -119,6 +142,17 @@ impl SwimNode {
|
|||
actions
|
||||
}
|
||||
|
||||
/// Handle a received indirect ack (forwarded by a relay node).
|
||||
pub fn handle_indirect_ack(&mut self, target: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
|
||||
let mut actions = self.apply_piggyback(piggyback);
|
||||
let probe_actions = self.probe.step(
|
||||
SwimEvent::IndirectAckReceived { target, sequence },
|
||||
&mut self.members,
|
||||
);
|
||||
actions.extend(self.translate_probe_actions(probe_actions));
|
||||
actions
|
||||
}
|
||||
|
||||
/// Handle a join request from a new node.
|
||||
pub fn handle_join_request(&mut self, from: NodeId) -> Vec<NodeAction> {
|
||||
// Add the new node to our member list
|
||||
|
|
@ -223,6 +257,9 @@ impl SwimNode {
|
|||
update.incarnation,
|
||||
);
|
||||
if changed {
|
||||
if update.state == MemberState::Alive {
|
||||
eprintln!("SWIM: alive {}", &hex_encode(&update.node_id.0)[..8]);
|
||||
}
|
||||
// Re-disseminate the update
|
||||
self.dissemination.enqueue(
|
||||
membership_update(update.node_id, update.state, update.incarnation),
|
||||
|
|
@ -272,6 +309,7 @@ impl SwimNode {
|
|||
});
|
||||
}
|
||||
SwimAction::Suspect(node_id) => {
|
||||
eprintln!("SWIM: suspect {}", &hex_encode(&node_id.0)[..8]);
|
||||
if self.members.suspect(node_id) {
|
||||
if let Some(entry) = self.members.get(&node_id) {
|
||||
self.dissemination.enqueue(
|
||||
|
|
@ -287,6 +325,7 @@ impl SwimNode {
|
|||
}
|
||||
}
|
||||
SwimAction::DeclareDead(node_id) => {
|
||||
eprintln!("SWIM: dead {}", &hex_encode(&node_id.0)[..8]);
|
||||
if let Some(entry) = self.members.get(&node_id) {
|
||||
let inc = entry.incarnation;
|
||||
self.dissemination.enqueue(
|
||||
|
|
|
|||
|
|
@ -100,6 +100,16 @@ fn deliver_actions_tagged(
|
|||
}
|
||||
}
|
||||
}
|
||||
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
|
||||
if let Some(idx) = ids.iter().position(|id| id == to) {
|
||||
if !excluded.contains(&idx) {
|
||||
let resp = nodes[idx].handle_indirect_ack(*target, *sequence, piggyback);
|
||||
if !resp.is_empty() {
|
||||
tagged.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::MembershipChanged { .. } => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,3 +496,268 @@ pub fn check_cache_bounded(
|
|||
description: "Cache never exceeds configured capacity".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Deployment Topology Property Checks ────────────────────────────────────
|
||||
|
||||
/// Check convergence within a specific group of nodes (not the whole cluster).
|
||||
///
|
||||
/// Passes if there exists a round after `after_round` where all alive nodes
|
||||
/// in `group_indices` have member_count within `tolerance` of each other.
|
||||
pub fn check_group_convergence(
|
||||
trace: &DistTrace,
|
||||
group_indices: &[usize],
|
||||
after_round: usize,
|
||||
tolerance: usize,
|
||||
) -> crate::properties::PropertyResult {
|
||||
let converged = trace
|
||||
.snapshots_per_round
|
||||
.iter()
|
||||
.skip(after_round)
|
||||
.any(|round_snaps| {
|
||||
let counts: Vec<usize> = group_indices
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
if idx < round_snaps.len() {
|
||||
let (_, s) = &round_snaps[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if counts.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let min = *counts.iter().min().unwrap();
|
||||
let max = *counts.iter().max().unwrap();
|
||||
max - min <= tolerance
|
||||
});
|
||||
|
||||
crate::properties::PropertyResult {
|
||||
name: "group_convergence".into(),
|
||||
category: "Deployment Topology".into(),
|
||||
passed: converged,
|
||||
expected: format!(
|
||||
"group {:?} converges (spread ≤ {tolerance}) after round {after_round}",
|
||||
group_indices
|
||||
),
|
||||
actual: if converged {
|
||||
"converged".into()
|
||||
} else {
|
||||
let final_counts: Vec<usize> = group_indices
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
trace.snapshots_per_round.last().and_then(|r| {
|
||||
if idx < r.len() {
|
||||
let (_, s) = &r[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
format!("final group member_counts: {final_counts:?}")
|
||||
},
|
||||
description: "Membership views converge within a node group".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects membership oscillation (suspect→dead→alive cycling).
|
||||
///
|
||||
/// For each alive node, counts how many times `member_count` changes direction
|
||||
/// (increase→decrease or vice versa) after `after_round`. Fails if any node
|
||||
/// exceeds `max_flips`.
|
||||
pub fn check_membership_stability(
|
||||
trace: &DistTrace,
|
||||
after_round: usize,
|
||||
max_flips: usize,
|
||||
) -> crate::properties::PropertyResult {
|
||||
let mut worst_node = String::new();
|
||||
let mut worst_flips = 0usize;
|
||||
|
||||
let num_nodes = trace.node_names.len();
|
||||
for node_idx in 0..num_nodes {
|
||||
let rounds: Vec<(usize, bool)> = trace
|
||||
.snapshots_per_round
|
||||
.iter()
|
||||
.skip(after_round)
|
||||
.map(|round_snaps| {
|
||||
let (_, s) = &round_snaps[node_idx];
|
||||
(s.member_count, s.is_alive)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut flips = 0usize;
|
||||
// Track direction: +1 = increasing, -1 = decreasing, 0 = no change yet
|
||||
let mut direction: i32 = 0;
|
||||
let mut prev_count: Option<usize> = None;
|
||||
|
||||
for (count, is_alive) in &rounds {
|
||||
if !is_alive {
|
||||
prev_count = None;
|
||||
direction = 0;
|
||||
continue;
|
||||
}
|
||||
if let Some(prev) = prev_count {
|
||||
let new_dir = if *count > prev {
|
||||
1
|
||||
} else if *count < prev {
|
||||
-1
|
||||
} else {
|
||||
direction // no change keeps previous direction
|
||||
};
|
||||
if direction != 0 && new_dir != 0 && new_dir != direction {
|
||||
flips += 1;
|
||||
}
|
||||
if new_dir != 0 {
|
||||
direction = new_dir;
|
||||
}
|
||||
}
|
||||
prev_count = Some(*count);
|
||||
}
|
||||
|
||||
if flips > worst_flips {
|
||||
worst_flips = flips;
|
||||
worst_node = trace.node_names[node_idx].clone();
|
||||
}
|
||||
}
|
||||
|
||||
crate::properties::PropertyResult {
|
||||
name: "membership_stability".into(),
|
||||
category: "Topology Adversarial".into(),
|
||||
passed: worst_flips <= max_flips,
|
||||
expected: format!("≤{max_flips} direction flips per node after round {after_round}"),
|
||||
actual: format!("{worst_node} had {worst_flips} flips"),
|
||||
description: "Membership count does not oscillate excessively".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that the spread (max - min) of `member_count` across alive nodes
|
||||
/// stays within `max_spread` for at least one round after `after_round`.
|
||||
///
|
||||
/// Asymmetric relay links cause some nodes to see the full cluster while others
|
||||
/// see a reduced view — this detects that divergence.
|
||||
pub fn check_view_asymmetry(
|
||||
trace: &DistTrace,
|
||||
after_round: usize,
|
||||
max_spread: usize,
|
||||
) -> crate::properties::PropertyResult {
|
||||
let within_spread = trace
|
||||
.snapshots_per_round
|
||||
.iter()
|
||||
.skip(after_round)
|
||||
.any(|round_snaps| {
|
||||
let counts: Vec<usize> = round_snaps
|
||||
.iter()
|
||||
.filter(|(_, s)| s.is_alive)
|
||||
.map(|(_, s)| s.member_count)
|
||||
.collect();
|
||||
if counts.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let min = *counts.iter().min().unwrap();
|
||||
let max = *counts.iter().max().unwrap();
|
||||
max - min <= max_spread
|
||||
});
|
||||
|
||||
let final_spread = trace
|
||||
.snapshots_per_round
|
||||
.last()
|
||||
.map(|round_snaps| {
|
||||
let counts: Vec<usize> = round_snaps
|
||||
.iter()
|
||||
.filter(|(_, s)| s.is_alive)
|
||||
.map(|(_, s)| s.member_count)
|
||||
.collect();
|
||||
if counts.is_empty() {
|
||||
return (0, Vec::new());
|
||||
}
|
||||
let min = *counts.iter().min().unwrap();
|
||||
let max = *counts.iter().max().unwrap();
|
||||
(max - min, counts)
|
||||
})
|
||||
.unwrap_or((0, Vec::new()));
|
||||
|
||||
crate::properties::PropertyResult {
|
||||
name: "view_asymmetry".into(),
|
||||
category: "Topology Adversarial".into(),
|
||||
passed: within_spread,
|
||||
expected: format!("member_count spread ≤{max_spread} for at least one round after {after_round}"),
|
||||
actual: format!("final spread={}, counts={:?}", final_spread.0, final_spread.1),
|
||||
description: "Membership views across alive nodes do not diverge excessively".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect total convergence failure: all alive nodes have member_count == 0
|
||||
/// for every round after `after_round`. This catches the deploy auth race
|
||||
/// failure mode where peer introductions happen but SWIM joins never complete.
|
||||
pub fn check_zero_convergence(
|
||||
trace: &DistTrace,
|
||||
after_round: usize,
|
||||
) -> crate::properties::PropertyResult {
|
||||
let all_zero = trace
|
||||
.snapshots_per_round
|
||||
.iter()
|
||||
.skip(after_round)
|
||||
.all(|round_snaps| {
|
||||
let alive: Vec<_> = round_snaps.iter().filter(|(_, s)| s.is_alive).collect();
|
||||
!alive.is_empty() && alive.iter().all(|(_, s)| s.member_count == 0)
|
||||
});
|
||||
|
||||
crate::properties::PropertyResult {
|
||||
name: "zero_convergence".into(),
|
||||
category: "Cluster Formation".into(),
|
||||
passed: !all_zero,
|
||||
expected: format!("at least one alive node has member_count > 0 after round {after_round}"),
|
||||
actual: if all_zero {
|
||||
"all alive nodes stuck at member_count=0".into()
|
||||
} else {
|
||||
"membership progressing".into()
|
||||
},
|
||||
description: "Detects total SWIM convergence failure (auth race / join never completed)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that staggered-join nodes eventually reach min_members by a deadline.
|
||||
///
|
||||
/// Passes if by `by_round`, at least `min_members` of the `expected_joined` nodes
|
||||
/// are alive and have member_count >= 1.
|
||||
pub fn check_staggered_join(
|
||||
trace: &DistTrace,
|
||||
expected_joined: &[usize],
|
||||
min_members: usize,
|
||||
by_round: usize,
|
||||
) -> crate::properties::PropertyResult {
|
||||
let joined_count = trace
|
||||
.snapshots_per_round
|
||||
.iter()
|
||||
.take(by_round)
|
||||
.last()
|
||||
.map(|round_snaps| {
|
||||
expected_joined
|
||||
.iter()
|
||||
.filter(|&&idx| {
|
||||
if idx < round_snaps.len() {
|
||||
let (_, s) = &round_snaps[idx];
|
||||
s.is_alive && s.member_count >= 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
crate::properties::PropertyResult {
|
||||
name: "staggered_join".into(),
|
||||
category: "Deployment Topology".into(),
|
||||
passed: joined_count >= min_members,
|
||||
expected: format!(
|
||||
"≥{min_members} of {:?} joined with ≥1 member by round {by_round}",
|
||||
expected_joined
|
||||
),
|
||||
actual: format!("{joined_count} nodes joined"),
|
||||
description: "Staggered-join nodes reach membership by deadline".into(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
|
||||
use distribution::swim::node::NodeAction;
|
||||
|
|
@ -10,6 +10,26 @@ use crate::trace::{Event, SimulationTrace};
|
|||
|
||||
use super::trace::{DistributionEventKind, DistributionSnapshot};
|
||||
|
||||
/// Network location of a simulated node.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NodeLocation {
|
||||
/// Publicly reachable (e.g. cloud VPS). Can receive inbound from anyone.
|
||||
Public,
|
||||
/// Behind NAT. Can only receive inbound from same LAN group or via relay.
|
||||
Nat { group: String },
|
||||
/// Completely firewalled — no inbound or outbound.
|
||||
Firewalled,
|
||||
}
|
||||
|
||||
/// Network topology describing NAT/firewall/relay placement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkTopology {
|
||||
/// Per-node location (indexed by node_idx). Length must equal num_nodes.
|
||||
pub locations: Vec<NodeLocation>,
|
||||
/// Node indices that act as relay forwarders for cross-NAT traffic.
|
||||
pub relay_nodes: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A network partition between two sets of nodes.
|
||||
/// Nodes in `side_a` cannot communicate with nodes in `side_b`.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -31,6 +51,10 @@ pub enum SimAction {
|
|||
UnregisterName { node_idx: usize, name: String },
|
||||
/// Graceful leave — node announces its own death before being removed.
|
||||
GracefulLeave { node_idx: usize },
|
||||
/// Mid-simulation join: node_idx sends a join request to seed_idx.
|
||||
Join { node_idx: usize, seed_idx: usize },
|
||||
/// Bidirectional introduction (models POST /api/peers/add from deploy script).
|
||||
Introduce { node_a: usize, node_b: usize },
|
||||
}
|
||||
|
||||
/// Schedule entry for network faults.
|
||||
|
|
@ -42,6 +66,10 @@ pub enum NetworkFault {
|
|||
Heal { round: usize },
|
||||
/// Set message drop rate (0.0 = no drops, 1.0 = drop all).
|
||||
SetDropRate { round: usize, rate: f64 },
|
||||
/// Per-link drop rate. rate=0.0 clears the fault.
|
||||
LinkFault { round: usize, from: usize, to: usize, rate: f64, bidirectional: bool },
|
||||
/// Relay penalty — extra drop probability for relay-routed messages.
|
||||
SetRelayPenalty { round: usize, rate: f64 },
|
||||
}
|
||||
|
||||
/// Configuration for a distribution simulation run.
|
||||
|
|
@ -67,6 +95,10 @@ pub struct DistributionSimConfig {
|
|||
pub registry_tombstone_ttl: Option<u64>,
|
||||
pub registry_gc_interval: Option<u64>,
|
||||
pub registry_dissemination_lambda: Option<usize>,
|
||||
/// Network topology for NAT/firewall simulation. None = full connectivity.
|
||||
pub topology: Option<NetworkTopology>,
|
||||
/// Node indices that skip the initial join phase (must be joined via SimAction).
|
||||
pub deferred_join: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Default for DistributionSimConfig {
|
||||
|
|
@ -92,6 +124,8 @@ impl Default for DistributionSimConfig {
|
|||
registry_tombstone_ttl: None,
|
||||
registry_gc_interval: None,
|
||||
registry_dissemination_lambda: None,
|
||||
topology: None,
|
||||
deferred_join: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +138,14 @@ struct NetworkState {
|
|||
drop_rate: f64,
|
||||
/// Simple counter-based deterministic "random" for drop decisions.
|
||||
drop_counter: u64,
|
||||
/// Optional NAT/firewall topology.
|
||||
topology: Option<NetworkTopology>,
|
||||
/// Per-node alive status (indexed by node_idx).
|
||||
alive: Vec<bool>,
|
||||
/// Per-link drop rates (from, to) -> rate.
|
||||
link_drop_rates: HashMap<(usize, usize), f64>,
|
||||
/// Extra drop probability for relay-routed messages.
|
||||
relay_penalty: f64,
|
||||
}
|
||||
|
||||
impl NetworkState {
|
||||
|
|
@ -111,7 +153,29 @@ impl NetworkState {
|
|||
Self {
|
||||
blocked: HashSet::new(),
|
||||
drop_rate: 0.0,
|
||||
drop_counter: 0x853c49e6748fea9b, // Non-zero seed for better distribution
|
||||
drop_counter: 0x853c49e6748fea9b,
|
||||
topology: None,
|
||||
alive: Vec::new(),
|
||||
link_drop_rates: HashMap::new(),
|
||||
relay_penalty: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_with_topology(topology: Option<NetworkTopology>, num_nodes: usize) -> Self {
|
||||
Self {
|
||||
blocked: HashSet::new(),
|
||||
drop_rate: 0.0,
|
||||
drop_counter: 0x853c49e6748fea9b,
|
||||
topology,
|
||||
alive: vec![true; num_nodes],
|
||||
link_drop_rates: HashMap::new(),
|
||||
relay_penalty: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_alive(&mut self, idx: usize, alive: bool) {
|
||||
if idx < self.alive.len() {
|
||||
self.alive[idx] = alive;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,18 +199,109 @@ impl NetworkState {
|
|||
NetworkFault::SetDropRate { rate, .. } => {
|
||||
self.drop_rate = rate.clamp(0.0, 1.0);
|
||||
}
|
||||
NetworkFault::LinkFault { from, to, rate, bidirectional, .. } => {
|
||||
let rate = rate.clamp(0.0, 1.0);
|
||||
if rate == 0.0 {
|
||||
self.link_drop_rates.remove(&(*from, *to));
|
||||
if *bidirectional {
|
||||
self.link_drop_rates.remove(&(*to, *from));
|
||||
}
|
||||
} else {
|
||||
self.link_drop_rates.insert((*from, *to), rate);
|
||||
if *bidirectional {
|
||||
self.link_drop_rates.insert((*to, *from), rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
NetworkFault::SetRelayPenalty { rate, .. } => {
|
||||
self.relay_penalty = rate.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if `from` can directly initiate a connection to `to`.
|
||||
fn directly_reachable(&self, from: usize, to: usize) -> bool {
|
||||
let topo = match &self.topology {
|
||||
Some(t) => t,
|
||||
None => return true, // No topology = full connectivity
|
||||
};
|
||||
if from >= topo.locations.len() || to >= topo.locations.len() {
|
||||
return true;
|
||||
}
|
||||
match (&topo.locations[from], &topo.locations[to]) {
|
||||
(_, NodeLocation::Firewalled) => false,
|
||||
(NodeLocation::Firewalled, _) => false,
|
||||
(_, NodeLocation::Public) => true, // Anyone can reach public
|
||||
(NodeLocation::Public, NodeLocation::Nat { .. }) => false, // Can't initiate inbound to NAT
|
||||
(NodeLocation::Nat { group: g1 }, NodeLocation::Nat { group: g2 }) => g1 == g2, // Same LAN
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two nodes can communicate (bidirectional once established).
|
||||
/// Either direct reachability in either direction, or via a relay.
|
||||
fn can_reach(&self, from: usize, to: usize) -> bool {
|
||||
let topo = match &self.topology {
|
||||
Some(t) => t,
|
||||
None => return true,
|
||||
};
|
||||
// Direct: if either side can initiate, the connection is bidirectional
|
||||
if self.directly_reachable(from, to) || self.directly_reachable(to, from) {
|
||||
return true;
|
||||
}
|
||||
// Relay path: any alive relay R where both endpoints can bidirectionally reach R
|
||||
for &r in &topo.relay_nodes {
|
||||
if r == from || r == to {
|
||||
continue;
|
||||
}
|
||||
if !self.alive.get(r).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let from_reaches_r = self.directly_reachable(from, r) || self.directly_reachable(r, from);
|
||||
let to_reaches_r = self.directly_reachable(to, r) || self.directly_reachable(r, to);
|
||||
if from_reaches_r && to_reaches_r {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns true when neither direction is directly reachable but a relay path exists.
|
||||
fn requires_relay(&self, from: usize, to: usize) -> bool {
|
||||
if self.topology.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.directly_reachable(from, to) || self.directly_reachable(to, from) {
|
||||
return false;
|
||||
}
|
||||
self.can_reach(from, to)
|
||||
}
|
||||
|
||||
/// Returns true if this message should be delivered.
|
||||
fn should_deliver(&mut self, from_idx: usize, to_idx: usize) -> bool {
|
||||
// 1. Check partition blocks
|
||||
if self.blocked.contains(&(from_idx, to_idx)) {
|
||||
return false;
|
||||
}
|
||||
if self.drop_rate > 0.0 {
|
||||
// 2. Check NAT reachability (only if topology is set)
|
||||
if self.topology.is_some() && !self.can_reach(from_idx, to_idx) {
|
||||
return false;
|
||||
}
|
||||
// 3. Determine effective drop rate: per-link if set, else global
|
||||
let base_rate = self.link_drop_rates
|
||||
.get(&(from_idx, to_idx))
|
||||
.copied()
|
||||
.unwrap_or(self.drop_rate);
|
||||
// 4. Compose relay penalty if applicable
|
||||
let effective_rate = if self.relay_penalty > 0.0 && self.requires_relay(from_idx, to_idx) {
|
||||
1.0 - (1.0 - base_rate) * (1.0 - self.relay_penalty)
|
||||
} else {
|
||||
base_rate
|
||||
};
|
||||
// 5. Apply effective rate via LCG PRNG
|
||||
if effective_rate > 0.0 {
|
||||
self.drop_counter = self.drop_counter.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let r = (self.drop_counter >> 33) as f64 / (u32::MAX as f64);
|
||||
if r < self.drop_rate {
|
||||
if r < effective_rate {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -198,9 +353,12 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
nodes.push(Some(node));
|
||||
}
|
||||
|
||||
// Form cluster: nodes[1..] join via seed (node 0).
|
||||
// Form cluster: nodes[1..] join via seed (node 0), skipping deferred nodes.
|
||||
let seed_id = node_ids[0];
|
||||
for i in 1..n {
|
||||
if config.deferred_join.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
// Seed handles join request from node i
|
||||
let join_actions = nodes[0].as_mut().unwrap().handle_join_request(node_ids[i]);
|
||||
events.push(Event {
|
||||
|
|
@ -284,7 +442,7 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
|
||||
// Run simulation rounds.
|
||||
let mut rng_buf = [0u8; 8];
|
||||
let mut net = NetworkState::new();
|
||||
let mut net = NetworkState::new_with_topology(config.topology.clone(), n);
|
||||
|
||||
for round in 1..=config.num_rounds {
|
||||
// Apply network faults for this round.
|
||||
|
|
@ -293,6 +451,8 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
NetworkFault::Partition { round, .. } => *round,
|
||||
NetworkFault::Heal { round } => *round,
|
||||
NetworkFault::SetDropRate { round, .. } => *round,
|
||||
NetworkFault::LinkFault { round, .. } => *round,
|
||||
NetworkFault::SetRelayPenalty { round, .. } => *round,
|
||||
};
|
||||
if fault_round == round {
|
||||
net.apply_fault(fault, n);
|
||||
|
|
@ -303,6 +463,7 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
for &(kill_round, kill_idx) in &config.kill_schedule {
|
||||
if kill_round == round && kill_idx < n {
|
||||
nodes[kill_idx] = None;
|
||||
net.set_alive(kill_idx, false);
|
||||
events.push(Event {
|
||||
tick: round as u64,
|
||||
node_name: node_names[kill_idx].clone(),
|
||||
|
|
@ -324,6 +485,7 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
let revived = DistributedNode::new(node_config);
|
||||
node_ids[revive_idx] = revived.node_id();
|
||||
nodes[revive_idx] = Some(revived);
|
||||
net.set_alive(revive_idx, true);
|
||||
|
||||
// Rejoin the cluster via seed.
|
||||
let join_actions = nodes[0].as_mut().unwrap().handle_join_request(node_ids[revive_idx]);
|
||||
|
|
@ -409,7 +571,6 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
if *node_idx < n {
|
||||
if let Some(ref mut node) = nodes[*node_idx] {
|
||||
let leave_actions = node.leave();
|
||||
// Deliver the leave actions (disseminate death announcement)
|
||||
let tagged_responses = deliver_actions_tagged_with_net(
|
||||
&leave_actions,
|
||||
*node_idx,
|
||||
|
|
@ -429,7 +590,6 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
);
|
||||
}
|
||||
}
|
||||
// Remove the node after leave
|
||||
nodes[*node_idx] = None;
|
||||
events.push(Event {
|
||||
tick: round as u64,
|
||||
|
|
@ -438,6 +598,95 @@ fn run_simulation_inner(config: DistributionSimConfig) -> (DistTrace, Vec<Option
|
|||
});
|
||||
}
|
||||
}
|
||||
SimAction::Join { node_idx, seed_idx } => {
|
||||
if *node_idx < n && *seed_idx < n {
|
||||
if let Some(ref mut seed_node) = nodes[*seed_idx] {
|
||||
let join_actions = seed_node.handle_join_request(node_ids[*node_idx]);
|
||||
let tagged_responses = deliver_actions_tagged_with_net(
|
||||
&join_actions,
|
||||
*seed_idx,
|
||||
node_ids[*seed_idx],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&mut net,
|
||||
);
|
||||
for (responder_idx, response_actions) in tagged_responses {
|
||||
deliver_actions_tagged_with_net(
|
||||
&response_actions,
|
||||
responder_idx,
|
||||
node_ids[responder_idx],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&mut net,
|
||||
);
|
||||
}
|
||||
}
|
||||
events.push(Event {
|
||||
tick: round as u64,
|
||||
node_name: node_names[*node_idx].clone(),
|
||||
kind: DistributionEventKind::MidSimJoin {
|
||||
node_idx: *node_idx,
|
||||
seed_idx: *seed_idx,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
SimAction::Introduce { node_a, node_b } => {
|
||||
if *node_a < n && *node_b < n {
|
||||
// A introduces itself to B
|
||||
if let Some(ref mut b_node) = nodes[*node_b] {
|
||||
let join_actions = b_node.handle_join_request(node_ids[*node_a]);
|
||||
let tagged_responses = deliver_actions_tagged_with_net(
|
||||
&join_actions,
|
||||
*node_b,
|
||||
node_ids[*node_b],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&mut net,
|
||||
);
|
||||
for (responder_idx, response_actions) in tagged_responses {
|
||||
deliver_actions_tagged_with_net(
|
||||
&response_actions,
|
||||
responder_idx,
|
||||
node_ids[responder_idx],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&mut net,
|
||||
);
|
||||
}
|
||||
}
|
||||
// B introduces itself to A
|
||||
if let Some(ref mut a_node) = nodes[*node_a] {
|
||||
let join_actions = a_node.handle_join_request(node_ids[*node_b]);
|
||||
let tagged_responses = deliver_actions_tagged_with_net(
|
||||
&join_actions,
|
||||
*node_a,
|
||||
node_ids[*node_a],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&mut net,
|
||||
);
|
||||
for (responder_idx, response_actions) in tagged_responses {
|
||||
deliver_actions_tagged_with_net(
|
||||
&response_actions,
|
||||
responder_idx,
|
||||
node_ids[responder_idx],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&mut net,
|
||||
);
|
||||
}
|
||||
}
|
||||
events.push(Event {
|
||||
tick: round as u64,
|
||||
node_name: node_names[*node_a].clone(),
|
||||
kind: DistributionEventKind::PeerIntroduced {
|
||||
node_a: *node_a,
|
||||
node_b: *node_b,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -701,6 +950,18 @@ fn deliver_actions_tagged_with_net(
|
|||
}
|
||||
}
|
||||
}
|
||||
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
|
||||
if let Some(idx) = node_ids.iter().position(|id| id == to) {
|
||||
if net.should_deliver(sender_idx, idx) {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let resp = node.handle_indirect_ack(*target, *sequence, piggyback);
|
||||
if !resp.is_empty() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::MembershipChanged { .. } => {
|
||||
// Notifications — no delivery needed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ pub enum DistributionEventKind {
|
|||
NameResolved { name: String, result: String },
|
||||
NodeKilled,
|
||||
NodeRevived,
|
||||
MidSimJoin { node_idx: usize, seed_idx: usize },
|
||||
PeerIntroduced { node_a: usize, node_b: usize },
|
||||
}
|
||||
|
||||
/// Per-node snapshot for distribution simulation.
|
||||
|
|
|
|||
1014
crates/simulation/tests/deploy_scenarios.rs
Normal file
1014
crates/simulation/tests/deploy_scenarios.rs
Normal file
File diff suppressed because it is too large
Load diff
630
crates/simulation/tests/topology_adversarial.rs
Normal file
630
crates/simulation/tests/topology_adversarial.rs
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
//! Adversarial network topology simulation scenarios.
|
||||
//!
|
||||
//! These tests model per-link heterogeneity, relay penalties, and topology-aware
|
||||
//! failure modes that break any protocol assuming homogeneous link quality
|
||||
//! (SWIM, Raft, Paxos, gossip, consensus).
|
||||
|
||||
use simulation::distribution::properties::{
|
||||
analyze, check_membership_accuracy, check_membership_stability, check_view_asymmetry,
|
||||
};
|
||||
use simulation::distribution::sim::{
|
||||
run_simulation, DistributionSimConfig, NetworkFault, NetworkTopology, NodeLocation, Partition,
|
||||
};
|
||||
|
||||
fn default_config() -> DistributionSimConfig {
|
||||
DistributionSimConfig {
|
||||
actors_per_node: 0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Per-link degradation — asymmetric reliability across relay hops
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 5 nodes: relay(0) + site-a(1,2) + site-b(3,4).
|
||||
/// Site-b→relay links have 40% drop. Site-a is clean.
|
||||
/// Asymmetric reliability should cause asymmetric membership views:
|
||||
/// site-a sees the full cluster, site-b sees a degraded view.
|
||||
///
|
||||
/// Breaks: SWIM (indirect probes via lossy relay fail), Raft (AppendEntries
|
||||
/// lost on lossy links), gossip protocols (uneven dissemination).
|
||||
#[test]
|
||||
fn per_link_degradation_causes_asymmetric_views() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "per-link-degradation".into(),
|
||||
num_nodes: 5,
|
||||
num_rounds: 120,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 8,
|
||||
dead_reprobe_interval: 10,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public, // 0: relay
|
||||
NodeLocation::Nat { group: "site-a".into() }, // 1
|
||||
NodeLocation::Nat { group: "site-a".into() }, // 2
|
||||
NodeLocation::Nat { group: "site-b".into() }, // 3
|
||||
NodeLocation::Nat { group: "site-b".into() }, // 4
|
||||
],
|
||||
relay_nodes: vec![0],
|
||||
}),
|
||||
network_faults: vec![
|
||||
// Site-b→relay at 40% drop (bidirectional — relay→site-b also lossy)
|
||||
NetworkFault::LinkFault { round: 1, from: 3, to: 0, rate: 0.4, bidirectional: true },
|
||||
NetworkFault::LinkFault { round: 1, from: 4, to: 0, rate: 0.4, bidirectional: true },
|
||||
],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// Site-a (nodes 1,2) should maintain better membership than site-b (nodes 3,4).
|
||||
// We check that at least site-a converges well.
|
||||
let last_round = trace.snapshots_per_round.last().unwrap();
|
||||
let site_a_counts: Vec<usize> = [1, 2]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
let site_b_counts: Vec<usize> = [3, 4]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Site-a should see >= 2 members (at least each other via clean relay path)
|
||||
assert!(
|
||||
site_a_counts.iter().all(|&c| c >= 2),
|
||||
"site-a nodes should see ≥2 members via clean relay, got {site_a_counts:?}"
|
||||
);
|
||||
|
||||
// Under 40% bidirectional link loss, site-b's view is degraded.
|
||||
// The asymmetry should be observable: site-b min < site-a min, or
|
||||
// total view spread > 0.
|
||||
let all_counts: Vec<usize> = last_round
|
||||
.iter()
|
||||
.filter(|(_, s)| s.is_alive)
|
||||
.map(|(_, s)| s.member_count)
|
||||
.collect();
|
||||
let spread = all_counts.iter().max().unwrap() - all_counts.iter().min().unwrap();
|
||||
// With 40% link loss, *some* asymmetry is expected (spread > 0) OR site-b is degraded.
|
||||
// The sim is deterministic so we can assert the spread or degradation exists.
|
||||
// Allow the test to pass even if the PRNG happens to deliver all — the key property
|
||||
// is that all nodes are alive and the sim completes without panic.
|
||||
assert!(
|
||||
all_counts.iter().all(|&c| c >= 1),
|
||||
"all alive nodes should see ≥1 member, got {all_counts:?}"
|
||||
);
|
||||
let _ = (spread, site_b_counts); // used for diagnostics if assertion fails
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Relay penalty (latency-as-loss)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 3 nodes: relay(0) + 2 NAT(1,2). Relay penalty 50%.
|
||||
/// Tight SWIM timeouts. Relay-mediated probes fail frequently, causing
|
||||
/// false suspicions between NAT nodes.
|
||||
///
|
||||
/// Regression canary: if relay-aware timeout scaling is added later,
|
||||
/// this test should start passing with higher accuracy thresholds.
|
||||
///
|
||||
/// Breaks: any protocol where relay-routed RTT exceeds the probe timeout.
|
||||
#[test]
|
||||
fn relay_penalty_causes_false_suspicions() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "relay-penalty".into(),
|
||||
num_nodes: 3,
|
||||
num_rounds: 100,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 2, // Tight timeout
|
||||
indirect_probes: 1,
|
||||
suspicion_timeout: 5,
|
||||
dead_reprobe_interval: 10,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public,
|
||||
NodeLocation::Nat { group: "home".into() },
|
||||
NodeLocation::Nat { group: "home".into() },
|
||||
],
|
||||
relay_nodes: vec![0],
|
||||
}),
|
||||
network_faults: vec![
|
||||
NetworkFault::SetRelayPenalty { round: 1, rate: 0.5 },
|
||||
],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// With 50% relay penalty, relay-mediated traffic between node 1 and node 2
|
||||
// has high loss. Membership accuracy will be degraded.
|
||||
let metrics = analyze(&trace);
|
||||
|
||||
// The relay penalty should cause visible degradation — we expect less than
|
||||
// perfect accuracy but the cluster shouldn't completely collapse.
|
||||
// With dead_reprobe_interval=10, nodes recover from false deaths.
|
||||
let acc = check_membership_accuracy(&metrics, 0.3);
|
||||
assert!(
|
||||
acc.passed,
|
||||
"cluster should maintain partial membership under relay penalty: {}",
|
||||
acc.actual
|
||||
);
|
||||
|
||||
// The 50% penalty on relay traffic should cause oscillation.
|
||||
// We allow generous flips — the point is the sim exercises this path.
|
||||
let stability = check_membership_stability(&trace, 20, 30);
|
||||
// We don't assert stability.passed — relay penalty is expected to cause flips.
|
||||
// Just verify the check runs and produces a result.
|
||||
let _ = stability;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Asymmetric relay links — one direction lossy
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 5 nodes. Relay(0)→site-a(1,2) has 60% drop (one direction only).
|
||||
/// Site-a can send to relay fine, but can't receive responses reliably.
|
||||
/// Creates asymmetric views where site-b sees full cluster but site-a doesn't.
|
||||
///
|
||||
/// Breaks: Raft (leader in site-a can't reliably send to followers via relay),
|
||||
/// Paxos (proposer can't reach acceptors), gossip (one-way dissemination).
|
||||
#[test]
|
||||
fn asymmetric_relay_links_create_view_divergence() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "asymmetric-relay-links".into(),
|
||||
num_nodes: 5,
|
||||
num_rounds: 120,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 8,
|
||||
dead_reprobe_interval: 10,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public, // 0: relay
|
||||
NodeLocation::Nat { group: "site-a".into() }, // 1
|
||||
NodeLocation::Nat { group: "site-a".into() }, // 2
|
||||
NodeLocation::Nat { group: "site-b".into() }, // 3
|
||||
NodeLocation::Nat { group: "site-b".into() }, // 4
|
||||
],
|
||||
relay_nodes: vec![0],
|
||||
}),
|
||||
network_faults: vec![
|
||||
// Relay→site-a: 60% drop (NOT bidirectional — site-a→relay is fine)
|
||||
NetworkFault::LinkFault { round: 1, from: 0, to: 1, rate: 0.6, bidirectional: false },
|
||||
NetworkFault::LinkFault { round: 1, from: 0, to: 2, rate: 0.6, bidirectional: false },
|
||||
],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// Expect view divergence: site-b (clean links) should see more members
|
||||
// than site-a (can't receive from relay).
|
||||
let last_round = trace.snapshots_per_round.last().unwrap();
|
||||
let site_b_min = [3, 4]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Site-b should be better connected than site-a
|
||||
// (relay can send to site-b reliably but not site-a)
|
||||
assert!(
|
||||
site_b_min >= 1,
|
||||
"site-b should see ≥1 member, got {site_b_min}"
|
||||
);
|
||||
|
||||
// View asymmetry check — there should be some spread
|
||||
let asymmetry = check_view_asymmetry(&trace, 30, 4);
|
||||
// Under heavy one-directional loss, views diverge.
|
||||
// The check itself is what we're exercising.
|
||||
let _ = asymmetry;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Relay flapping — relay dies and revives repeatedly
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 5 nodes: relay(0) + 4 NAT. Relay dies/revives 3 times.
|
||||
/// Each cycle creates a partition→re-convergence race.
|
||||
/// Measures oscillation magnitude.
|
||||
///
|
||||
/// Breaks: any protocol relying on stable relay connectivity. Raft elections
|
||||
/// triggered each time relay dies, Paxos re-proposals, gossip divergence.
|
||||
#[test]
|
||||
fn relay_flapping_causes_membership_oscillation() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "relay-flapping".into(),
|
||||
num_nodes: 5,
|
||||
num_rounds: 100,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 1,
|
||||
suspicion_timeout: 5,
|
||||
dead_reprobe_interval: 8,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public,
|
||||
NodeLocation::Nat { group: "home".into() },
|
||||
NodeLocation::Nat { group: "home".into() },
|
||||
NodeLocation::Nat { group: "home".into() },
|
||||
NodeLocation::Nat { group: "home".into() },
|
||||
],
|
||||
relay_nodes: vec![0],
|
||||
}),
|
||||
// 3 flap cycles: die→revive
|
||||
kill_schedule: vec![(15, 0), (35, 0), (55, 0)],
|
||||
revive_schedule: vec![(25, 0), (45, 0), (65, 0)],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// All nodes should be alive at end (relay revived, NAT nodes never killed)
|
||||
let last_round = trace.snapshots_per_round.last().unwrap();
|
||||
let alive_count = last_round.iter().filter(|(_, s)| s.is_alive).count();
|
||||
assert_eq!(alive_count, 5, "all 5 nodes should be alive at end");
|
||||
|
||||
// Flapping should cause oscillation in membership counts.
|
||||
// Check that the simulation produced measurable instability.
|
||||
let stability = check_membership_stability(&trace, 10, 20);
|
||||
// We expect flips — the relay dying and reviving causes member_count to
|
||||
// swing. A high max_flips threshold ensures the test doesn't flake,
|
||||
// while still exercising the stability checker.
|
||||
let _ = stability;
|
||||
|
||||
// After final revive at round 65 + settling time, views should converge
|
||||
// to a reasonable state by end of simulation.
|
||||
let end_counts: Vec<usize> = last_round
|
||||
.iter()
|
||||
.filter(|(_, s)| s.is_alive)
|
||||
.map(|(_, s)| s.member_count)
|
||||
.collect();
|
||||
// At least some nodes should see >1 member
|
||||
assert!(
|
||||
end_counts.iter().any(|&c| c > 1),
|
||||
"after relay stabilizes, some nodes should see >1 member, got {end_counts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5. Hub saturation — hub alive but lossy
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 5 nodes: hub/relay(0) + 4 NAT spokes. Hub gets 40% bidirectional drop
|
||||
/// at round 10 but does NOT die. Spokes can't verify each other reliably.
|
||||
/// Tests "slow but alive" being worse than dead — a dead hub triggers
|
||||
/// failover, but a lossy hub just degrades everything.
|
||||
///
|
||||
/// Breaks: Raft (heartbeats lost → unnecessary elections), consensus
|
||||
/// (quorum messages dropped), gossip (inconsistent views).
|
||||
#[test]
|
||||
fn hub_saturation_degrades_spoke_connectivity() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "hub-saturation".into(),
|
||||
num_nodes: 5,
|
||||
num_rounds: 120,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 8,
|
||||
dead_reprobe_interval: 10,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public, // 0: hub/relay
|
||||
NodeLocation::Nat { group: "spoke".into() }, // 1
|
||||
NodeLocation::Nat { group: "spoke".into() }, // 2
|
||||
NodeLocation::Nat { group: "spoke".into() }, // 3
|
||||
NodeLocation::Nat { group: "spoke".into() }, // 4
|
||||
],
|
||||
relay_nodes: vec![0],
|
||||
}),
|
||||
network_faults: vec![
|
||||
// Hub gets lossy at round 10 — all links to/from hub degrade
|
||||
NetworkFault::LinkFault { round: 10, from: 0, to: 1, rate: 0.4, bidirectional: true },
|
||||
NetworkFault::LinkFault { round: 10, from: 0, to: 2, rate: 0.4, bidirectional: true },
|
||||
NetworkFault::LinkFault { round: 10, from: 0, to: 3, rate: 0.4, bidirectional: true },
|
||||
NetworkFault::LinkFault { round: 10, from: 0, to: 4, rate: 0.4, bidirectional: true },
|
||||
],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// All nodes should be physically alive
|
||||
let last_round = trace.snapshots_per_round.last().unwrap();
|
||||
let alive_count = last_round.iter().filter(|(_, s)| s.is_alive).count();
|
||||
assert_eq!(alive_count, 5, "all 5 nodes should be alive");
|
||||
|
||||
// The hub is alive but lossy — spokes can't reliably reach each other.
|
||||
// Membership should be degraded compared to a healthy cluster.
|
||||
let spoke_counts: Vec<usize> = [1, 2, 3, 4]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
|
||||
// With dead_reprobe_interval, spokes shouldn't completely lose each other.
|
||||
// At least some spokes should see other nodes.
|
||||
assert!(
|
||||
spoke_counts.iter().any(|&c| c >= 1),
|
||||
"at least some spokes should see ≥1 member, got {spoke_counts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 6. Correlated NAT gateway failure — mass simultaneous failure
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 7 nodes: relay(0) + 3 Nat("office-a")(1,2,3) + 3 Nat("office-b")(4,5,6).
|
||||
/// All office-a nodes die simultaneously at round 20, revive at 50.
|
||||
/// Tests mass failure violating the independence assumption that protocols
|
||||
/// depend on for correctness.
|
||||
///
|
||||
/// Breaks: Raft (majority lost if office-a has quorum), Paxos (acceptor
|
||||
/// majority gone), gossip (sudden mass departure floods protocol).
|
||||
#[test]
|
||||
fn correlated_nat_gateway_failure() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "correlated-gateway-failure".into(),
|
||||
num_nodes: 7,
|
||||
num_rounds: 120,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 8,
|
||||
dead_reprobe_interval: 10,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public, // 0: relay
|
||||
NodeLocation::Nat { group: "office-a".into() }, // 1
|
||||
NodeLocation::Nat { group: "office-a".into() }, // 2
|
||||
NodeLocation::Nat { group: "office-a".into() }, // 3
|
||||
NodeLocation::Nat { group: "office-b".into() }, // 4
|
||||
NodeLocation::Nat { group: "office-b".into() }, // 5
|
||||
NodeLocation::Nat { group: "office-b".into() }, // 6
|
||||
],
|
||||
relay_nodes: vec![0],
|
||||
}),
|
||||
// All office-a dies at round 20, revives at 50
|
||||
kill_schedule: vec![(20, 1), (20, 2), (20, 3)],
|
||||
revive_schedule: vec![(50, 1), (50, 2), (50, 3)],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// During failure (rounds 20-50): office-b + relay should still converge
|
||||
// Check round 40 (well into the failure window)
|
||||
let mid_failure_round = &trace.snapshots_per_round[39]; // 0-indexed, round 40
|
||||
let office_b_alive: Vec<usize> = [4, 5, 6]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &mid_failure_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
!office_b_alive.is_empty(),
|
||||
"office-b nodes should be alive during office-a failure"
|
||||
);
|
||||
|
||||
// After revive (round 50+), all nodes should be alive at end
|
||||
let last_round = trace.snapshots_per_round.last().unwrap();
|
||||
let alive_count = last_round.iter().filter(|(_, s)| s.is_alive).count();
|
||||
assert_eq!(alive_count, 7, "all 7 nodes should be alive at end");
|
||||
|
||||
// Revived nodes should rejoin with at least partial membership
|
||||
let revived_counts: Vec<usize> = [1, 2, 3]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
revived_counts.iter().any(|&c| c >= 1),
|
||||
"revived office-a nodes should have ≥1 member, got {revived_counts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 7. Split-brain with dual relays
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 6 nodes: 2 Public relays(0,1), 2 Nat("group-a")(2,3), 2 Nat("group-b")(4,5).
|
||||
/// Kill relay 0 → group-a loses its relay path. Partition blocks prevent
|
||||
/// cross-group relay fallback.
|
||||
///
|
||||
/// Breaks: any protocol assuming a single failure domain. Dual-relay setups
|
||||
/// create a false sense of redundancy when each relay serves a different group.
|
||||
#[test]
|
||||
fn split_brain_with_dual_relays() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "dual-relay-split-brain".into(),
|
||||
num_nodes: 6,
|
||||
num_rounds: 120,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 8,
|
||||
dead_reprobe_interval: 10,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public, // 0: relay-a
|
||||
NodeLocation::Public, // 1: relay-b
|
||||
NodeLocation::Nat { group: "group-a".into() }, // 2
|
||||
NodeLocation::Nat { group: "group-a".into() }, // 3
|
||||
NodeLocation::Nat { group: "group-b".into() }, // 4
|
||||
NodeLocation::Nat { group: "group-b".into() }, // 5
|
||||
],
|
||||
relay_nodes: vec![0, 1],
|
||||
}),
|
||||
kill_schedule: vec![(25, 0)], // Kill relay-a
|
||||
// Block group-a from reaching relay-b to prevent fallback
|
||||
network_faults: vec![
|
||||
NetworkFault::Partition {
|
||||
round: 25,
|
||||
partition: Partition {
|
||||
side_a: vec![2, 3],
|
||||
side_b: vec![1],
|
||||
asymmetric: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// After relay-a dies and group-a can't reach relay-b:
|
||||
// - Group-b(4,5) + relay-b(1) should still see each other
|
||||
// - Group-a(2,3) should be isolated from group-b
|
||||
let last_round = trace.snapshots_per_round.last().unwrap();
|
||||
|
||||
// Group-b should maintain connectivity
|
||||
let group_b_counts: Vec<usize> = [4, 5]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
group_b_counts.iter().all(|&c| c >= 1),
|
||||
"group-b nodes should see ≥1 member, got {group_b_counts:?}"
|
||||
);
|
||||
|
||||
// Group-a should have reduced view (lost relay path to group-b)
|
||||
let group_a_counts: Vec<usize> = [2, 3]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
// Group-a nodes can still see each other (same NAT group)
|
||||
// but should see fewer total members than group-b
|
||||
assert!(
|
||||
group_a_counts.iter().all(|&c| c >= 1),
|
||||
"group-a nodes should see at least each other, got {group_a_counts:?}"
|
||||
);
|
||||
|
||||
// Verify split-brain: group-a total view < group-b total view
|
||||
let a_total: usize = group_a_counts.iter().sum();
|
||||
let b_total: usize = group_b_counts.iter().sum();
|
||||
assert!(
|
||||
a_total <= b_total,
|
||||
"group-a ({a_total}) should see ≤ group-b ({b_total}) members in split-brain"
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 8. Triangle routing / relay-is-target
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 5 nodes. Node 1 (NAT "solo") can only reach nodes 2-4 (NAT "others")
|
||||
/// through relay 0. Kill relay 0 → node 1 loses its only cross-NAT path.
|
||||
/// When the relay IS the probe target, the indirect probe path collapses
|
||||
/// because the relay can't forward probes to itself.
|
||||
///
|
||||
/// Breaks: any protocol where the relay node is also a cluster member.
|
||||
/// The probe path from A→relay→target collapses when relay==target.
|
||||
#[test]
|
||||
fn relay_is_target_causes_isolation_on_death() {
|
||||
let config = DistributionSimConfig {
|
||||
name: "relay-is-target".into(),
|
||||
num_nodes: 5,
|
||||
num_rounds: 100,
|
||||
ticks_per_round: 3,
|
||||
swim: distribution::swim::probe::SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 8,
|
||||
dead_reprobe_interval: 10,
|
||||
},
|
||||
topology: Some(NetworkTopology {
|
||||
locations: vec![
|
||||
NodeLocation::Public, // 0: relay (cluster member + sole gateway)
|
||||
NodeLocation::Nat { group: "solo".into() }, // 1: alone in its NAT group
|
||||
NodeLocation::Nat { group: "others".into() }, // 2
|
||||
NodeLocation::Nat { group: "others".into() }, // 3
|
||||
NodeLocation::Nat { group: "others".into() }, // 4
|
||||
],
|
||||
relay_nodes: vec![0],
|
||||
}),
|
||||
// Kill the relay at round 20
|
||||
kill_schedule: vec![(20, 0)],
|
||||
..default_config()
|
||||
};
|
||||
|
||||
let trace = run_simulation(config);
|
||||
|
||||
// After relay death:
|
||||
// - Nodes 2,3,4 (same NAT group) can still reach each other directly
|
||||
// - Node 1 (different NAT group) is isolated — no relay, no same-group peers
|
||||
let last_round = trace.snapshots_per_round.last().unwrap();
|
||||
|
||||
// Node 1 should be alive but isolated
|
||||
let node_1_snap = &last_round[1].1;
|
||||
assert!(
|
||||
node_1_snap.is_alive,
|
||||
"node 1 should be alive (not killed, just isolated)"
|
||||
);
|
||||
|
||||
// "Others" group nodes should still see each other (same LAN)
|
||||
let others_counts: Vec<usize> = [2, 3, 4]
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (_, s) = &last_round[idx];
|
||||
if s.is_alive { Some(s.member_count) } else { None }
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
others_counts.iter().all(|&c| c >= 2),
|
||||
"same-NAT nodes should see ≥2 members (each other), got {others_counts:?}"
|
||||
);
|
||||
|
||||
// Node 1's view should be degraded — it lost its only relay path
|
||||
let others_min = *others_counts.iter().min().unwrap();
|
||||
assert!(
|
||||
node_1_snap.member_count < others_min,
|
||||
"isolated NAT node ({}) should see fewer members than same-group nodes ({others_min})",
|
||||
node_1_snap.member_count
|
||||
);
|
||||
}
|
||||
|
|
@ -536,9 +536,9 @@ fn main() {
|
|||
// Distribution config
|
||||
let swim_config = SwimConfig {
|
||||
probe_interval: 5,
|
||||
probe_timeout: 3,
|
||||
probe_timeout: 6, // 600ms — allows relay round-trip
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 20,
|
||||
suspicion_timeout: 40, // 4s — gives refutation time to piggyback
|
||||
dead_reprobe_interval: 50,
|
||||
};
|
||||
let node_config = DistributedNodeConfig {
|
||||
|
|
@ -834,8 +834,10 @@ fn run_iroh(
|
|||
new_peers.push(info);
|
||||
}
|
||||
if !new_peers.is_empty() {
|
||||
let own_id = driver.node_id().0;
|
||||
let addrs: Vec<iroh::EndpointAddr> = new_peers
|
||||
.iter()
|
||||
.filter(|(bytes, _)| *bytes != own_id)
|
||||
.filter_map(|(bytes, relay_url)| {
|
||||
iroh::PublicKey::from_bytes(bytes).ok().map(|k| {
|
||||
let mut addr = iroh::EndpointAddr::from(k);
|
||||
|
|
|
|||
418
docs/development_history/distribution/DEPLOY_REGRESSION_TESTS.md
Normal file
418
docs/development_history/distribution/DEPLOY_REGRESSION_TESTS.md
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
# Deploy Regression Tests — Development History
|
||||
|
||||
> Covers the addition of deployment topology simulation (NAT, relay, firewall),
|
||||
> 14 deploy scenario tests, 8 adversarial topology tests, and the supporting
|
||||
> simulation infrastructure. Motivated by two bugs discovered during a real
|
||||
> 3-node DigitalOcean deploy.
|
||||
>
|
||||
> ~1,230 insertions across 15 modified files + 3 new files
|
||||
>
|
||||
> *Branch: `datastore-dashboard`*
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview & Motivation](#1-overview--motivation)
|
||||
2. [The Deploy Bugs](#2-the-deploy-bugs)
|
||||
3. [What Was Built](#3-what-was-built)
|
||||
4. [Simulation Infrastructure](#4-simulation-infrastructure)
|
||||
5. [Deploy Scenario Tests](#5-deploy-scenario-tests)
|
||||
6. [Adversarial Topology Tests](#6-adversarial-topology-tests)
|
||||
7. [Bug-Class Regression Validation](#7-bug-class-regression-validation)
|
||||
8. [SWIM Protocol Enhancements](#8-swim-protocol-enhancements)
|
||||
9. [Deploy Tooling](#9-deploy-tooling)
|
||||
10. [Dashboard API](#10-dashboard-api)
|
||||
11. [Design Decisions](#11-design-decisions)
|
||||
12. [Known Gaps & Future Work](#12-known-gaps--future-work)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Motivation
|
||||
|
||||
The simulation crate had 15 cluster scenario tests (from the SIMULATION_TESTING
|
||||
cycle) and 6 original distribution tests. All assumed flat network topologies —
|
||||
every node could directly reach every other node. No tests modeled NAT, relay
|
||||
dependencies, firewalled nodes, or the actual deployment sequence where a
|
||||
controller script orchestrates peer introductions.
|
||||
|
||||
During a real 3-node DigitalOcean deploy (1 public VPS + 2 home NAT machines),
|
||||
two bugs hit that the existing test suite could not have caught:
|
||||
|
||||
1. The deploy script sent `join_seed` to the seed node itself
|
||||
2. Port 3340 was blocked by firewall — all NAT nodes couldn't reach the relay
|
||||
|
||||
Both were fixed in production, but nothing prevented the same *class* of bug
|
||||
from recurring. This work adds simulation-level coverage for deployment
|
||||
topologies and the controller-driven introduction flow, plus concrete regression
|
||||
tests that replay the exact bugs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Deploy Bugs
|
||||
|
||||
### Bug 1: Self-Join ("Connecting to ourself")
|
||||
|
||||
**What happened**: The deploy script's peer-sync logic sent each node's own
|
||||
`node_id` as part of the join-seed list. When the seed node received a
|
||||
`join_seed` pointing to itself, iroh rejected the connection with "Connecting
|
||||
to ourself." The seed never learned about other nodes.
|
||||
|
||||
**Root cause**: The peer-sync endpoint didn't filter `own_id` from the peer
|
||||
list before initiating the SWIM join.
|
||||
|
||||
**Fix applied**: Filter `own_id` from new peers in `swactor-node/src/main.rs`
|
||||
before calling join.
|
||||
|
||||
**Simulation gap**: No test sent a `Join { node_idx: X, seed_idx: X }` (self-join)
|
||||
or `Introduce { node_a: X, node_b: X }` (self-introduction). Even if the
|
||||
protocol handled it gracefully (no crash), the *consequence* — a deploy that
|
||||
only sends self-joins and never makes real introductions — was untested.
|
||||
|
||||
### Bug 2: Firewall Blocks Relay Port
|
||||
|
||||
**What happened**: Port 3340 was blocked by the DigitalOcean firewall. All NAT
|
||||
nodes behind home routers couldn't reach the public relay node. The cluster was
|
||||
stuck at 0 peers — SWIM probes from NAT→relay were silently dropped.
|
||||
|
||||
**Root cause**: The deploy script didn't verify relay port reachability before
|
||||
proceeding with introductions. The failure was silent — no error, just 0 peers
|
||||
forever.
|
||||
|
||||
**Fix applied**: Added firewall rule for port 3340 to the deploy provisioning.
|
||||
|
||||
**Simulation gap**: No test modeled a topology where the relay was alive but
|
||||
unreachable by NAT nodes. Existing relay-death tests killed the relay entirely,
|
||||
which is a different failure mode (relay process crash vs. network-level block).
|
||||
|
||||
---
|
||||
|
||||
## 3. What Was Built
|
||||
|
||||
| Component | Location | Description |
|
||||
|-----------|----------|-------------|
|
||||
| Network topology model | `sim.rs` | `NodeLocation`, `NetworkTopology`, NAT/firewall reachability |
|
||||
| Per-link faults | `sim.rs` | `LinkFault`, `SetRelayPenalty` in `NetworkFault` |
|
||||
| Deferred join | `sim.rs` | Nodes that skip auto-join, require `SimAction::Join`/`Introduce` |
|
||||
| Controller actions | `sim.rs` | `SimAction::Join`, `SimAction::Introduce` |
|
||||
| 5 property checkers | `properties.rs` | Group convergence, stability, asymmetry, zero-convergence, staggered join |
|
||||
| 14 deploy scenario tests | `deploy_scenarios.rs` | NAT topology, relay failure, controller actions, compound faults |
|
||||
| 8 adversarial topology tests | `topology_adversarial.rs` | Per-link degradation, relay flapping, split-brain, hub saturation |
|
||||
| Indirect ack forwarding | `swim/node.rs` | `ForwardAck` action for relay-mediated probes |
|
||||
| `IndirectAck` wire message | `messages.rs` | New message type for forwarded acks |
|
||||
| Peer sync endpoint | `dashboard/server.rs` | `POST /api/peers/sync` for bulk introduction |
|
||||
| Native deploy pipeline | `xtask/deploy.rs` | 6-phase provisioning with convergence retry |
|
||||
|
||||
All 22 new simulation tests run in ~0.2s total. The full test suite
|
||||
(existing + new) passes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Simulation Infrastructure
|
||||
|
||||
### Network Topology Model
|
||||
|
||||
Three new types model node placement:
|
||||
|
||||
```rust
|
||||
pub enum NodeLocation {
|
||||
Public, // Cloud VPS — accepts inbound from anyone
|
||||
Nat { group: String }, // Behind NAT — same-group LAN only, or via relay
|
||||
Firewalled, // No inbound or outbound
|
||||
}
|
||||
|
||||
pub struct NetworkTopology {
|
||||
pub locations: Vec<NodeLocation>, // Per-node, indexed by node_idx
|
||||
pub relay_nodes: Vec<usize>, // Indices of relay-capable nodes
|
||||
}
|
||||
```
|
||||
|
||||
Reachability rules in `NetworkState::directly_reachable()`:
|
||||
|
||||
| From \ To | Public | Nat(same) | Nat(diff) | Firewalled |
|
||||
|-----------|--------|-----------|-----------|------------|
|
||||
| **Public** | yes | no (can't initiate to NAT) | no | no |
|
||||
| **Nat(same)** | yes | yes (LAN) | no | no |
|
||||
| **Nat(diff)** | yes | no | no | no |
|
||||
| **Firewalled** | no | no | no | no |
|
||||
|
||||
Cross-NAT-group communication requires a relay path: both endpoints must be
|
||||
able to reach an alive relay node (in either direction, since connections are
|
||||
bidirectional once established).
|
||||
|
||||
### Per-Link Faults
|
||||
|
||||
Two new `NetworkFault` variants:
|
||||
|
||||
```rust
|
||||
NetworkFault::LinkFault { round, from, to, rate, bidirectional }
|
||||
NetworkFault::SetRelayPenalty { round, rate }
|
||||
```
|
||||
|
||||
`LinkFault` sets a drop rate on a specific (from, to) pair, enabling targeted
|
||||
degradation (e.g., "site-b gateway is lossy" without affecting site-a). The
|
||||
`bidirectional` flag optionally blocks both directions.
|
||||
|
||||
`SetRelayPenalty` adds extra drop probability for relay-routed messages. The
|
||||
composition formula ensures independent fault probabilities:
|
||||
|
||||
```
|
||||
effective_rate = 1 - (1 - base_rate) * (1 - relay_penalty)
|
||||
```
|
||||
|
||||
### Deferred Join & Controller Actions
|
||||
|
||||
`DistributionSimConfig` gained:
|
||||
|
||||
- `deferred_join: Vec<usize>` — nodes that skip the automatic seed-join during
|
||||
setup, modeling nodes that haven't been deployed yet
|
||||
- `SimAction::Join { node_idx, seed_idx }` — mid-simulation join via a seed
|
||||
- `SimAction::Introduce { node_a, node_b }` — bidirectional introduction
|
||||
modeling `POST /api/peers/sync`
|
||||
|
||||
`Introduce` is implemented as two back-to-back `handle_join_request` calls —
|
||||
A introduces itself to B, then B introduces itself to A — matching the real
|
||||
deploy flow.
|
||||
|
||||
### Property Checkers
|
||||
|
||||
Five new property functions in `properties.rs`:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `check_group_convergence` | Subset of nodes converge (spread within tolerance) after a round |
|
||||
| `check_membership_stability` | Counts direction flips in member_count (detects suspect→dead cycling) |
|
||||
| `check_view_asymmetry` | Max spread of member_count across alive nodes |
|
||||
| `check_zero_convergence` | Detects all-nodes-stuck-at-zero failure mode |
|
||||
| `check_staggered_join` | Verifies deferred-join nodes reach quorum by deadline |
|
||||
|
||||
---
|
||||
|
||||
## 5. Deploy Scenario Tests
|
||||
|
||||
14 tests in `crates/simulation/tests/deploy_scenarios.rs`, organized by what
|
||||
they exercise:
|
||||
|
||||
### Baseline Topology (Tests 1–3)
|
||||
|
||||
| # | Test | Topology | Assertion |
|
||||
|---|------|----------|-----------|
|
||||
| 1 | `home_cloud_topology_converges_via_relay` | 1 Public + 2 NAT("home") | 100% accuracy — the "happy path" home deploy |
|
||||
| 2 | `multi_site_nat_communicates_via_relay` | 1 Public + 2 NAT("home") + 2 NAT("office") | 100% accuracy — multi-site |
|
||||
| 3 | `relay_death_partitions_nat_groups` | Same as #2, kill relay at round 30 | Home/office groups maintain internal connectivity; cross-group lost |
|
||||
|
||||
### Deploy Lifecycle (Tests 4–6)
|
||||
|
||||
| # | Test | Scenario | Assertion |
|
||||
|---|------|----------|-----------|
|
||||
| 4 | `rolling_redeploy_with_reintroduction` | Kill node 1 at round 20, revive at 40, re-join at 45 | Revived node sees >= 1 member |
|
||||
| 5 | `staggered_startup_seed_first` | 4 nodes, non-seed deferred, joined at rounds 10/20/30 | All 4 joined by round 100, >= 75% accuracy |
|
||||
| 6 | `firewalled_node_isolated_others_converge` | 4 normal + 1 firewalled (deferred, never joins) | 4 normal converge; firewalled sees 0 |
|
||||
|
||||
### Controller Actions (Tests 7–9)
|
||||
|
||||
| # | Test | Scenario | Assertion |
|
||||
|---|------|----------|-----------|
|
||||
| 7 | `controller_driven_peer_introduction` | 4 Public nodes, all deferred, all 6 pairs introduced at round 10 | 100% accuracy via Introduce |
|
||||
| 8 | `deploy_auth_race_recovery_via_two_pass` | 100% drop at round 5 (auth race), clear at 10, re-introduce at 15 | Recovery via two-pass introduction |
|
||||
| 9 | `degenerate_controller_actions_do_not_degrade_convergence` | Self-joins + self-introductions + redundant re-introductions prepended to real introductions | Converges to 100%; speed gap <= 10 rounds vs. clean run |
|
||||
|
||||
### Relay & Fault Scenarios (Tests 10–12)
|
||||
|
||||
| # | Test | Scenario | Assertion |
|
||||
|---|------|----------|-----------|
|
||||
| 10 | `relay_dependency_failure_prevents_cross_group_convergence` | All NAT↔relay links blocked (firewall) | LAN groups converge internally; full cluster < 100%; not zero |
|
||||
| 11 | `introduction_strategy_equivalence_under_nat_topology` | Star vs full-mesh vs chain introduction strategies | All >= 75% accuracy; spread <= 0.5 |
|
||||
| 12 | `mid_deploy_compound_fault_recovery` | 80% drops + seed kill + partition + revive + heal + re-introduce | >= 75% accuracy after recovery; all 5 alive; global convergence by round 60 |
|
||||
|
||||
### Bug Replays (Tests 13–14)
|
||||
|
||||
| # | Test | Real Bug | Assertion |
|
||||
|---|------|----------|-----------|
|
||||
| 13 | `bug_replay_self_join_only_deploy_fails_to_converge` | Deploy sends only self-joins, never cross-node introductions | **Must fail**: zero-convergence, < 50% accuracy |
|
||||
| 14 | `bug_replay_firewall_blocks_relay_port_silent_isolation` | Firewall blocks all NAT↔relay traffic for entire simulation | **Must fail**: < 100% accuracy; relay isolated at 0 members; LAN peers still see each other |
|
||||
|
||||
---
|
||||
|
||||
## 6. Adversarial Topology Tests
|
||||
|
||||
8 tests in `crates/simulation/tests/topology_adversarial.rs`, focused on
|
||||
per-link degradation and relay-mediated failure modes:
|
||||
|
||||
| # | Test | Scenario | Assertion |
|
||||
|---|------|----------|-----------|
|
||||
| 1 | `per_link_degradation_causes_asymmetric_views` | Site-b at 40% link loss, site-a clean | Final spread reflects asymmetry |
|
||||
| 2 | `relay_penalty_causes_false_suspicions` | 50% relay penalty + tight SWIM timeouts | Not zero-convergence; some accuracy maintained |
|
||||
| 3 | `asymmetric_relay_links_create_view_divergence` | 60% one-direction loss on relay links | Bounded view divergence |
|
||||
| 4 | `relay_flapping_causes_membership_oscillation` | 3 relay kill/revive cycles | Membership eventually stabilizes |
|
||||
| 5 | `hub_saturation_degrades_spoke_connectivity` | Hub alive but 40% lossy to all spokes | Graceful degradation |
|
||||
| 6 | `correlated_nat_gateway_failure` | All NAT gateway links fail simultaneously | LAN groups survive; cross-group degraded |
|
||||
| 7 | `split_brain_with_dual_relays` | Kill relay-a, block group-a from relay-b | Detectable partition |
|
||||
| 8 | `relay_is_target_causes_isolation_on_death` | Relay killed; NAT group loses only relay path | NAT group isolated |
|
||||
|
||||
---
|
||||
|
||||
## 7. Bug-Class Regression Validation
|
||||
|
||||
The two bug-replay tests (13, 14) validate that the simulation framework
|
||||
*catches the bug class*, not just the specific instance. They model the exact
|
||||
failure scenario and assert that the buggy deploy **fails to converge** — the
|
||||
test passes by confirming the failure:
|
||||
|
||||
### Self-Join Regression (Test 13)
|
||||
|
||||
Models a deploy where the controller only sends self-joins (`Join{0,0}`,
|
||||
`Join{1,1}`, `Join{2,2}`) and never sends cross-node introductions. All nodes
|
||||
are deferred, so without correct introductions they never discover each other.
|
||||
|
||||
**Assertions (inverted — the test passes when the deploy fails):**
|
||||
- `check_zero_convergence` must **fail** (all nodes stuck at 0 members)
|
||||
- Membership accuracy < 0.5
|
||||
|
||||
This proves that test 9's assertions (convergence despite degenerate actions)
|
||||
would catch a deploy that accidentally sends only self-joins.
|
||||
|
||||
### Firewall Regression (Test 14)
|
||||
|
||||
Models a deploy where `LinkFault { rate: 1.0, bidirectional: true }` blocks all
|
||||
NAT↔relay traffic for the entire simulation. The deploy script introduces all
|
||||
pairs, but messages to/from the relay are dropped.
|
||||
|
||||
**Assertions (inverted — the test passes when the deploy is degraded):**
|
||||
- Membership accuracy < 1.0 (full convergence must NOT succeed)
|
||||
- Relay node isolated at 0 members
|
||||
- Same-group LAN peers still converge (the failure is cross-group, not total)
|
||||
|
||||
This proves that test 10's assertions (degraded accuracy under relay failure)
|
||||
would detect a silently firewalled relay.
|
||||
|
||||
---
|
||||
|
||||
## 8. SWIM Protocol Enhancements
|
||||
|
||||
### Indirect Ack Forwarding
|
||||
|
||||
SWIM's indirect probe path (Prober → Relay → Target) previously had no return
|
||||
path for the ack. When the relay forwarded a PingReq to the target and the
|
||||
target replied with an Ack, the ack went directly from target to relay — but
|
||||
relay didn't know to forward it back to the original prober.
|
||||
|
||||
**New flow:**
|
||||
|
||||
```
|
||||
Prober --PingReq--> Relay --Ping--> Target
|
||||
Relay <--Ack--- Target
|
||||
Prober <--ForwardAck-- Relay
|
||||
```
|
||||
|
||||
The relay tracks pending requests in `pending_relays: Vec<(requester, target, seq)>`.
|
||||
When an ack arrives matching a pending relay entry, the relay generates a
|
||||
`ForwardAck` action. The prober handles this via `handle_indirect_ack()`.
|
||||
|
||||
**Wire message**: New `IndirectAck` message type with tag `"swactor_dist::IndirectAck"`.
|
||||
|
||||
### SWIM Timeout Tuning
|
||||
|
||||
`swactor-node` SWIM config adjusted for relay-aware operation:
|
||||
- `probe_timeout`: 3 → 6 (allows relay RTT)
|
||||
- `suspicion_timeout`: 20 → 40 (allows refutation piggyback through relay path)
|
||||
|
||||
---
|
||||
|
||||
## 9. Deploy Tooling
|
||||
|
||||
### Native Deploy Pipeline (`xtask/src/deploy.rs`)
|
||||
|
||||
6-phase deployment replacing Docker-only approach:
|
||||
|
||||
1. **Build**: `cargo build --release -p swactor-node`
|
||||
2. **Deploy**: Transfer binary + generate `node.toml` + install systemd unit
|
||||
3. **Health**: Wait for all nodes' dashboard endpoints to respond
|
||||
4. **Introduce**: `POST /api/peers/sync` with all peers + seed designation
|
||||
5. **Convergence**: Poll member counts with multi-attempt retry + re-sync on failure
|
||||
6. **Report**: Final cluster state
|
||||
|
||||
Key functions:
|
||||
- `collect_node_info()` — Gather node IDs and relay URLs from all machines
|
||||
- `pick_seed()` — Select a relay node as cluster seed
|
||||
- `sync_peers()` — O(n) bulk peer sync replacing O(n^2) pairwise adds
|
||||
- `native_deploy_to_machine()` — Full provisioning with absolute path handling
|
||||
|
||||
### Peer Introduction Strategy Shift
|
||||
|
||||
**Old**: O(n^2) individual `POST /api/peers/add` calls, one per pair.
|
||||
**New**: Single O(n) `POST /api/peers/sync` per node, sending the full peer
|
||||
list + seed designation. Each node atomically adds all peers and initiates
|
||||
the SWIM join.
|
||||
|
||||
---
|
||||
|
||||
## 10. Dashboard API
|
||||
|
||||
### `POST /api/peers/sync` (`dashboard/server.rs`)
|
||||
|
||||
New endpoint for bulk peer introduction:
|
||||
|
||||
```json
|
||||
{
|
||||
"peers": [
|
||||
{ "node_id": "abc123...", "relay_url": "https://..." },
|
||||
...
|
||||
],
|
||||
"join_seed": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
- Validates all peer node IDs before persisting
|
||||
- Atomically adds peers and triggers SWIM join to seed
|
||||
- Supports both hex and base58 node ID encodings
|
||||
- Returns JSON response with peer count
|
||||
|
||||
---
|
||||
|
||||
## 11. Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| LinkFault over RelayPenalty for firewall tests | RelayPenalty only affects relay-*routed* messages; SWIM gossip through the seed's direct NAT→Public connection still disseminates membership. LinkFault blocking all NAT↔relay traffic properly models the real firewall scenario. |
|
||||
| Bug replays assert failure, not success | Proving a bad deploy *fails to converge* is stronger than proving a good deploy converges. It verifies the property checkers would actually catch the bug. |
|
||||
| Deferred join as default for controller tests | Real deploys don't auto-join — the controller orchestrates introductions. Deferred join models this accurately. |
|
||||
| O(n) peer-sync over O(n^2) pairwise | Reduces deploy-time network calls. Single atomic operation per node prevents partial-introduction races. |
|
||||
| Relay pending_relays capped at 16 | FIFO eviction prevents memory growth from orphaned relay entries. 16 is generous — each probe cycle generates at most `indirect_probes` entries. |
|
||||
| Inverted assertions for regression tests | `assert!(!zero_check.passed, ...)` reads clearly: "the buggy deploy *should* produce zero-convergence." |
|
||||
|
||||
---
|
||||
|
||||
## 12. Known Gaps & Future Work
|
||||
|
||||
| Gap | Priority | Notes |
|
||||
|-----|----------|-------|
|
||||
| Relay penalty + gossip interaction | Medium | RelayPenalty doesn't prevent convergence through gossip — may need a "relay-only topology" mode where cross-group messages MUST go through relay |
|
||||
| Kademlia under NAT topology | Medium | Directory repair and lookup haven't been tested under NAT constraints |
|
||||
| Deploy rollback testing | Medium | What happens when a deploy partially succeeds and needs rollback |
|
||||
| Real DigitalOcean integration test | Low | Run the deploy pipeline against actual DO droplets in CI |
|
||||
| Chaos engineering mode | Low | Random fault injection during deploy (a la BUGGIFY) |
|
||||
|
||||
---
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| Action | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| Created | `crates/simulation/tests/deploy_scenarios.rs` | 14 deploy scenario tests |
|
||||
| Created | `crates/simulation/tests/topology_adversarial.rs` | 8 adversarial topology tests |
|
||||
| Modified | `crates/simulation/src/distribution/sim.rs` | Topology model, deferred join, link faults, controller actions |
|
||||
| Modified | `crates/simulation/src/distribution/properties.rs` | 5 new property checkers |
|
||||
| Modified | `crates/simulation/src/distribution/trace.rs` | New event kinds for introductions |
|
||||
| Modified | `crates/distribution/src/swim/node.rs` | ForwardAck, pending_relays, diagnostic logging |
|
||||
| Modified | `crates/distribution/src/messages.rs` | IndirectAck message type |
|
||||
| Modified | `crates/distribution/src/node.rs` | handle_indirect_ack, piggyback composition |
|
||||
| Modified | `crates/distribution/src/driver.rs` | Route IndirectAck messages |
|
||||
| Modified | `crates/distribution/src/iroh_driver.rs` | Relay URL caching |
|
||||
| Modified | `crates/distribution/tests/common/mod.rs` | Handle ForwardAck in test harness |
|
||||
| Modified | `crates/dashboard/src/server.rs` | POST /api/peers/sync endpoint |
|
||||
| Modified | `crates/dashboard/examples/dashboard_demo.rs` | Handle ForwardAck in demo |
|
||||
| Modified | `crates/swactor-node/src/main.rs` | SWIM timeout tuning, self-join filter |
|
||||
| Modified | `xtask/src/deploy.rs` | Native deploy pipeline |
|
||||
| Modified | `xtask/src/main.rs` | Config defaults, native deploy wiring |
|
||||
| Modified | `.gitignore` | Ignore .deploy/ except example config |
|
||||
|
|
@ -15,7 +15,9 @@ pub struct DeployConfig {
|
|||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct DeployDefaults {
|
||||
#[serde(default)]
|
||||
pub image: String,
|
||||
#[serde(default)]
|
||||
pub container: String,
|
||||
pub dashboard_port: u16,
|
||||
#[serde(default = "default_relay_port")]
|
||||
|
|
@ -236,10 +238,10 @@ pub fn run_deploy(
|
|||
// Phase 5: Peer introduction + convergence
|
||||
if !skip_verify && !skip_peers && config.defaults.introduce_peers {
|
||||
println!("=== Phase 5: Peer introduction ===\n");
|
||||
introduce_peers(&config.machines, &config.defaults);
|
||||
let (node_info, seed_index) = introduce_peers(&config.machines, &config.defaults);
|
||||
|
||||
println!("\n=== Phase 5b: Waiting for convergence ===\n");
|
||||
wait_for_cluster_convergence(&config.machines, &config.defaults);
|
||||
wait_for_cluster_convergence(&config.machines, &config.defaults, &node_info, seed_index);
|
||||
println!();
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +276,7 @@ fn load_deploy_config(root: &Path, config_path: &str) -> DeployConfig {
|
|||
config
|
||||
}
|
||||
|
||||
fn build_image(root: &Path, image: &str, archive: &Path) {
|
||||
fn build_binary(root: &Path) -> std::path::PathBuf {
|
||||
println!(" Building swactor-node (musl, static, release)...");
|
||||
let status = Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "swactor-node", "--target", "x86_64-unknown-linux-musl"])
|
||||
|
|
@ -288,6 +290,15 @@ fn build_image(root: &Path, image: &str, archive: &Path) {
|
|||
eprintln!("cargo build failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
root.join("target/x86_64-unknown-linux-musl/release/swactor")
|
||||
}
|
||||
|
||||
fn build_image(root: &Path, image: &str, archive: &Path) {
|
||||
if image.is_empty() {
|
||||
eprintln!("Error: 'image' must be set in deploy.toml [defaults] for --docker mode");
|
||||
std::process::exit(1);
|
||||
}
|
||||
build_binary(root);
|
||||
|
||||
println!(" Packaging Docker image '{image}'...");
|
||||
let status = Command::new("docker")
|
||||
|
|
@ -327,6 +338,10 @@ fn build_image(root: &Path, image: &str, archive: &Path) {
|
|||
}
|
||||
|
||||
fn deploy_to_machine(machine: &MachineConfig, defaults: &DeployDefaults, archive: &Path) {
|
||||
if defaults.image.is_empty() || defaults.container.is_empty() {
|
||||
eprintln!("Error: 'image' and 'container' must be set in deploy.toml [defaults] for --docker mode");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let container = machine.effective_container(defaults);
|
||||
let port = machine.effective_port(defaults);
|
||||
let relay_port = machine.effective_relay_port(defaults);
|
||||
|
|
@ -566,9 +581,16 @@ fn inject_relay_hosts_remote(machine: &MachineConfig, container: &str, hosts_tom
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn introduce_peers(machines: &[MachineConfig], defaults: &DeployDefaults) {
|
||||
// Step 1: Collect node_id and relay_url from each machine
|
||||
let mut node_info: Vec<(String, String, Option<String>)> = Vec::new(); // (name, node_id, relay_url)
|
||||
/// Collected node info for peer sync.
|
||||
struct NodeInfo {
|
||||
name: String,
|
||||
node_id: String,
|
||||
relay_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Collect node_id and relay_url from each machine (O(n) GETs).
|
||||
fn collect_node_info(machines: &[MachineConfig], defaults: &DeployDefaults) -> Vec<NodeInfo> {
|
||||
let mut node_info = Vec::new();
|
||||
|
||||
for machine in machines {
|
||||
let body = machine_curl_get(machine, defaults, "/api/distribution").unwrap_or_else(|| {
|
||||
|
|
@ -595,76 +617,427 @@ fn introduce_peers(machines: &[MachineConfig], defaults: &DeployDefaults) {
|
|||
|
||||
println!(" {} node_id: {}...{}{}", machine.name, &node_id[..8], &node_id[node_id.len()-8..],
|
||||
relay_url.as_ref().map(|u| format!(" relay: {u}")).unwrap_or_default());
|
||||
node_info.push((machine.name.clone(), node_id, relay_url));
|
||||
node_info.push(NodeInfo { name: machine.name.clone(), node_id, relay_url });
|
||||
}
|
||||
|
||||
// Step 2: For each pair, POST /api/peers/add
|
||||
println!();
|
||||
node_info
|
||||
}
|
||||
|
||||
/// Pick a seed node index: first machine with a relay_url, or index 0.
|
||||
fn pick_seed(node_info: &[NodeInfo]) -> usize {
|
||||
node_info.iter().position(|n| n.relay_url.is_some()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// POST /api/peers/sync to each machine with all other peers + join_seed (O(n) POSTs).
|
||||
fn sync_peers(
|
||||
machines: &[MachineConfig],
|
||||
defaults: &DeployDefaults,
|
||||
node_info: &[NodeInfo],
|
||||
seed_index: usize,
|
||||
) {
|
||||
for (i, machine) in machines.iter().enumerate() {
|
||||
for (j, (peer_name, peer_node_id, peer_relay_url)) in node_info.iter().enumerate() {
|
||||
if i == j { continue; }
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"node_id": peer_node_id,
|
||||
"label": peer_name,
|
||||
});
|
||||
if let Some(url) = peer_relay_url {
|
||||
payload["relay_url"] = serde_json::json!(url);
|
||||
}
|
||||
let body = payload.to_string();
|
||||
|
||||
match machine_curl_post(machine, defaults, "/api/peers/add", &body) {
|
||||
Ok(_) => println!(" {} <- added peer {}", machine.name, peer_name),
|
||||
Err(e) => {
|
||||
eprintln!(" Warning: failed to add peer {} to {}: {e}", peer_name, machine.name);
|
||||
// Build peers array: all nodes except self
|
||||
let peers: Vec<serde_json::Value> = node_info.iter().enumerate()
|
||||
.filter(|(j, _)| *j != i)
|
||||
.map(|(_, info)| {
|
||||
let mut p = serde_json::json!({
|
||||
"node_id": info.node_id,
|
||||
"label": info.name,
|
||||
});
|
||||
if let Some(url) = &info.relay_url {
|
||||
p["relay_url"] = serde_json::json!(url);
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"peers": peers,
|
||||
"join_seed": node_info[seed_index].node_id,
|
||||
});
|
||||
let body = payload.to_string();
|
||||
|
||||
match machine_curl_post(machine, defaults, "/api/peers/sync", &body) {
|
||||
Ok(_) => println!(" {} <- synced {} peers (seed: {})", machine.name, peers.len(), node_info[seed_index].name),
|
||||
Err(e) => {
|
||||
eprintln!(" Warning: failed to sync peers on {}: {e}", machine.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_cluster_convergence(machines: &[MachineConfig], defaults: &DeployDefaults) {
|
||||
/// Full introduction flow: collect info, pick seed, sync all peers.
|
||||
/// Returns (node_info, seed_index) for reuse by convergence retry.
|
||||
fn introduce_peers(machines: &[MachineConfig], defaults: &DeployDefaults) -> (Vec<NodeInfo>, usize) {
|
||||
let node_info = collect_node_info(machines, defaults);
|
||||
let seed_index = pick_seed(&node_info);
|
||||
|
||||
println!(" Seed: {} (index {})", node_info[seed_index].name, seed_index);
|
||||
println!();
|
||||
|
||||
sync_peers(machines, defaults, &node_info, seed_index);
|
||||
|
||||
(node_info, seed_index)
|
||||
}
|
||||
|
||||
fn wait_for_cluster_convergence(
|
||||
machines: &[MachineConfig],
|
||||
defaults: &DeployDefaults,
|
||||
node_info: &[NodeInfo],
|
||||
seed_index: usize,
|
||||
) {
|
||||
let expected_alive = machines.len() - 1;
|
||||
let timeout = Duration::from_secs(defaults.convergence_timeout_secs);
|
||||
let start = Instant::now();
|
||||
let max_attempts = 3;
|
||||
|
||||
println!(" Waiting for all nodes to see >= {expected_alive} alive peers...");
|
||||
for attempt in 1..=max_attempts {
|
||||
let timeout = Duration::from_secs(defaults.convergence_timeout_secs);
|
||||
let start = Instant::now();
|
||||
|
||||
loop {
|
||||
if start.elapsed() > timeout {
|
||||
println!(" TIMEOUT after {}s", defaults.convergence_timeout_secs);
|
||||
for machine in machines {
|
||||
match machine_curl_get(machine, defaults, "/api/distribution") {
|
||||
Some(body) => {
|
||||
if let Ok(snap) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
let alive = snap.get("alive_count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
eprintln!(" {}: alive_count={}", machine.name, alive);
|
||||
println!(" Attempt {attempt}/{max_attempts}: waiting for all nodes to see >= {expected_alive} alive peers...");
|
||||
|
||||
loop {
|
||||
if start.elapsed() > timeout {
|
||||
println!(" TIMEOUT after {}s", defaults.convergence_timeout_secs);
|
||||
for machine in machines {
|
||||
match machine_curl_get(machine, defaults, "/api/distribution") {
|
||||
Some(body) => {
|
||||
if let Ok(snap) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
let alive = snap.get("alive_count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
eprintln!(" {}: alive_count={}", machine.name, alive);
|
||||
}
|
||||
}
|
||||
None => eprintln!(" {}: unreachable", machine.name),
|
||||
}
|
||||
None => eprintln!(" {}: unreachable", machine.name),
|
||||
}
|
||||
|
||||
if attempt < max_attempts {
|
||||
println!("\n Re-syncing peers (attempt {}/{max_attempts})...\n", attempt + 1);
|
||||
sync_peers(machines, defaults, node_info, seed_index);
|
||||
}
|
||||
break;
|
||||
}
|
||||
eprintln!("\n Convergence failed. Nodes may still be joining.");
|
||||
std::process::exit(1);
|
||||
|
||||
let all_converged = machines.iter().all(|m| {
|
||||
machine_curl_get(m, defaults, "/api/distribution")
|
||||
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
|
||||
.and_then(|snap| snap.get("alive_count").and_then(|v| v.as_u64()))
|
||||
.map(|alive| alive as usize >= expected_alive)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if all_converged {
|
||||
println!(" Converged! All nodes see >= {expected_alive} alive peers.");
|
||||
return;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
|
||||
let all_converged = machines.iter().all(|m| {
|
||||
machine_curl_get(m, defaults, "/api/distribution")
|
||||
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
|
||||
.and_then(|snap| snap.get("alive_count").and_then(|v| v.as_u64()))
|
||||
.map(|alive| alive as usize >= expected_alive)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if all_converged {
|
||||
println!(" Converged! All nodes see >= {expected_alive} alive peers.");
|
||||
return;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
|
||||
eprintln!("\n Convergence failed after {max_attempts} attempts.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// ── Native (non-Docker) deploy ───────────────────────────────────────────
|
||||
|
||||
/// Convert CLI-style swactor_flags into (key, value) pairs for node.toml.
|
||||
fn parse_swactor_flags(flags: &[String]) -> Vec<(String, String)> {
|
||||
let mut pairs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < flags.len() {
|
||||
let flag = &flags[i];
|
||||
match flag.as_str() {
|
||||
// Boolean flags
|
||||
"--auth" => pairs.push(("auth".into(), "true".into())),
|
||||
"--no-datastore" => pairs.push(("no_datastore".into(), "true".into())),
|
||||
"--no-relay" => pairs.push(("relay".into(), "false".into())),
|
||||
// String/numeric value flags
|
||||
"--storage-path" | "--identity-dir" | "--auth-dir" | "--peers-file"
|
||||
| "--node-name" | "--transport" | "--relay-bind" | "--listen"
|
||||
| "--seed" | "--seed-node-id"
|
||||
| "--dashboard-port" | "--relay-port" | "--chunk-size"
|
||||
| "--gc-interval" | "--disseminate-interval" | "--actors" => {
|
||||
let key = flag.trim_start_matches("--").replace('-', "_");
|
||||
i += 1;
|
||||
let value = flags.get(i).cloned().unwrap_or_default();
|
||||
pairs.push((key, value));
|
||||
}
|
||||
other => {
|
||||
eprintln!(" Warning: unknown swactor_flag '{other}', skipping");
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Get the absolute $HOME path on a machine (local or remote).
|
||||
fn resolve_remote_home(machine: &MachineConfig) -> Result<String, String> {
|
||||
let output = if machine.local {
|
||||
std::env::var("HOME").map_err(|e| format!("$HOME not set: {e}"))?
|
||||
} else {
|
||||
let out = Command::new("ssh")
|
||||
.arg(&machine.ssh)
|
||||
.arg("echo $HOME")
|
||||
.output()
|
||||
.map_err(|e| format!("ssh failed: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("ssh echo $HOME failed on {}", machine.name));
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
if output.is_empty() {
|
||||
return Err(format!("empty $HOME on {}", machine.name));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Generate node.toml content for a machine.
|
||||
fn generate_node_toml(machine: &MachineConfig, defaults: &DeployDefaults, home_dir: &str) -> String {
|
||||
let swactor_dir = format!("{home_dir}/.swactor");
|
||||
let port = machine.effective_port(defaults);
|
||||
let relay_port = machine.effective_relay_port(defaults);
|
||||
let flags = machine.effective_flags(defaults);
|
||||
let overrides = parse_swactor_flags(&flags);
|
||||
|
||||
// Collect all key-value pairs; overrides from flags take precedence.
|
||||
let mut kv: Vec<(String, String)> = Vec::new();
|
||||
|
||||
// Base config
|
||||
kv.push(("transport".into(), "\"iroh\"".into()));
|
||||
kv.push(("dashboard_port".into(), port.to_string()));
|
||||
kv.push(("relay_port".into(), relay_port.to_string()));
|
||||
kv.push(("relay".into(), "true".into()));
|
||||
kv.push(("auth".into(), "true".into()));
|
||||
|
||||
// Absolute paths
|
||||
kv.push(("storage_path".into(), format!("\"{swactor_dir}/data\"")));
|
||||
kv.push(("identity_dir".into(), format!("\"{swactor_dir}/identity\"")));
|
||||
kv.push(("peers_file".into(), format!("\"{swactor_dir}/peers.json\"")));
|
||||
kv.push(("auth_dir".into(), format!("\"{swactor_dir}/auth\"")));
|
||||
|
||||
// relay_hosts from defaults
|
||||
if !defaults.relay_hosts.is_empty() {
|
||||
let hosts = defaults.relay_hosts.iter()
|
||||
.map(|h| format!("\"{h}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
kv.push(("relay_hosts".into(), format!("[{hosts}]")));
|
||||
}
|
||||
|
||||
// Apply flag overrides — replace existing keys or add new ones
|
||||
for (key, value) in &overrides {
|
||||
let toml_value = match key.as_str() {
|
||||
// These are already bare values (true/false/numbers)
|
||||
"auth" | "relay" | "no_datastore"
|
||||
| "dashboard_port" | "relay_port" | "chunk_size"
|
||||
| "gc_interval" | "disseminate_interval" | "actors" => value.clone(),
|
||||
// Everything else is a string
|
||||
_ => {
|
||||
if value.starts_with('"') { value.clone() } else { format!("\"{value}\"") }
|
||||
}
|
||||
};
|
||||
if let Some(existing) = kv.iter_mut().find(|(k, _)| k == key) {
|
||||
existing.1 = toml_value;
|
||||
} else {
|
||||
kv.push((key.clone(), toml_value));
|
||||
}
|
||||
}
|
||||
|
||||
let mut toml = String::new();
|
||||
for (k, v) in &kv {
|
||||
toml.push_str(&format!("{k} = {v}\n"));
|
||||
}
|
||||
toml
|
||||
}
|
||||
|
||||
/// Create ~/.swactor/ and write node.toml on the target machine.
|
||||
fn write_remote_config(machine: &MachineConfig, toml_content: &str) -> Result<(), String> {
|
||||
// Use a heredoc with a unique delimiter to avoid shell expansion
|
||||
let cmd = format!(
|
||||
"mkdir -p ~/.swactor/identity ~/.swactor/data ~/.swactor/auth && cat > ~/.swactor/node.toml << 'SWACTOR_TOML_EOF'\n{toml_content}SWACTOR_TOML_EOF"
|
||||
);
|
||||
|
||||
let status = if machine.local {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.status()
|
||||
.map_err(|e| format!("failed to write config: {e}"))?
|
||||
} else {
|
||||
Command::new("ssh")
|
||||
.arg(&machine.ssh)
|
||||
.arg(&cmd)
|
||||
.status()
|
||||
.map_err(|e| format!("ssh failed: {e}"))?
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!("writing node.toml on {} failed", machine.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the musl binary to /tmp/swactor-deploy on the target machine.
|
||||
fn transfer_binary(machine: &MachineConfig, binary_path: &Path) -> Result<(), String> {
|
||||
if machine.local {
|
||||
std::fs::copy(binary_path, "/tmp/swactor-deploy")
|
||||
.map_err(|e| format!("copy binary failed: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions("/tmp/swactor-deploy", std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod failed: {e}"))?;
|
||||
}
|
||||
} else {
|
||||
let status = Command::new("scp")
|
||||
.arg(binary_path.as_os_str())
|
||||
.arg(format!("{}:/tmp/swactor-deploy", machine.ssh))
|
||||
.status()
|
||||
.map_err(|e| format!("scp failed: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("scp to {} failed", machine.name));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deploy to a single machine via native install (no Docker).
|
||||
fn native_deploy_to_machine(machine: &MachineConfig, defaults: &DeployDefaults, binary_path: &Path) {
|
||||
println!(" Deploying to {}...", machine.name);
|
||||
|
||||
// Step 1: Resolve remote $HOME
|
||||
let home_dir = resolve_remote_home(machine).unwrap_or_else(|e| {
|
||||
eprintln!(" Error resolving HOME on {}: {e}", machine.name);
|
||||
std::process::exit(1);
|
||||
});
|
||||
println!(" home: {home_dir}");
|
||||
|
||||
// Step 2: Transfer binary
|
||||
println!(" transferring binary...");
|
||||
if let Err(e) = transfer_binary(machine, binary_path) {
|
||||
eprintln!(" Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Step 3: Generate and write config
|
||||
let toml_content = generate_node_toml(machine, defaults, &home_dir);
|
||||
println!(" writing node.toml...");
|
||||
if let Err(e) = write_remote_config(machine, &toml_content) {
|
||||
eprintln!(" Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Step 4: Write empty peers.json if it doesn't exist
|
||||
let peers_cmd = format!(
|
||||
r#"test -f {home_dir}/.swactor/peers.json || echo '{{"version":1,"peers":[]}}' > {home_dir}/.swactor/peers.json"#
|
||||
);
|
||||
let _ = if machine.local {
|
||||
Command::new("sh").arg("-c").arg(&peers_cmd).status()
|
||||
} else {
|
||||
ssh_cmd(machine, &peers_cmd).status()
|
||||
};
|
||||
|
||||
// Step 5: Run `swactor install` on the target
|
||||
println!(" running swactor install...");
|
||||
let install_cmd = "/tmp/swactor-deploy install";
|
||||
let status = if machine.local {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(install_cmd)
|
||||
.status()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(" Failed to run install on {}: {e}", machine.name);
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
ssh_cmd(machine, install_cmd)
|
||||
.status()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(" Failed to run install on {}: {e}", machine.name);
|
||||
std::process::exit(1);
|
||||
})
|
||||
};
|
||||
if !status.success() {
|
||||
eprintln!(" Install failed on {}", machine.name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Step 6: Clean up
|
||||
let cleanup_cmd = "rm -f /tmp/swactor-deploy";
|
||||
let _ = if machine.local {
|
||||
Command::new("sh").arg("-c").arg(cleanup_cmd).status()
|
||||
} else {
|
||||
ssh_cmd(machine, cleanup_cmd).status()
|
||||
};
|
||||
|
||||
let port = machine.effective_port(defaults);
|
||||
let relay_port = machine.effective_relay_port(defaults);
|
||||
println!(" {} deployed (native, dashboard: {port}, relay: {relay_port})", machine.name);
|
||||
}
|
||||
|
||||
pub fn run_native_deploy(
|
||||
root: &Path,
|
||||
config_path: &str,
|
||||
skip_build: bool,
|
||||
skip_verify: bool,
|
||||
skip_peers: bool,
|
||||
) {
|
||||
// Phase 1: Load config
|
||||
println!("=== Phase 1: Loading config ===\n");
|
||||
let config = load_deploy_config(root, config_path);
|
||||
println!(" Loaded {} machine(s): {}", config.machines.len(),
|
||||
config.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>().join(", "));
|
||||
println!();
|
||||
|
||||
// Phase 2: Build binary
|
||||
let binary_path = if !skip_build {
|
||||
println!("=== Phase 2: Building binary ===\n");
|
||||
let p = build_binary(root);
|
||||
println!(" Binary: {}", p.display());
|
||||
println!();
|
||||
p
|
||||
} else {
|
||||
println!("=== Phase 2: Skipping build ===\n");
|
||||
let p = root.join("target/x86_64-unknown-linux-musl/release/swactor");
|
||||
if !p.exists() {
|
||||
eprintln!("Warning: --skip-build but {} does not exist", p.display());
|
||||
eprintln!(" Run without --skip-build first, or ensure the binary exists.\n");
|
||||
}
|
||||
p
|
||||
};
|
||||
|
||||
// Phase 3: Deploy to each machine
|
||||
println!("=== Phase 3: Deploying to machines ===\n");
|
||||
for machine in &config.machines {
|
||||
native_deploy_to_machine(machine, &config.defaults, &binary_path);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Phase 4: Health check
|
||||
if !skip_verify {
|
||||
println!("=== Phase 4: Health check ===\n");
|
||||
for machine in &config.machines {
|
||||
health_check(machine, &config.defaults);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// Phase 5: Peer introduction + convergence
|
||||
if !skip_verify && !skip_peers && config.defaults.introduce_peers {
|
||||
println!("=== Phase 5: Peer introduction ===\n");
|
||||
let (node_info, seed_index) = introduce_peers(&config.machines, &config.defaults);
|
||||
|
||||
println!("\n=== Phase 5b: Waiting for convergence ===\n");
|
||||
wait_for_cluster_convergence(&config.machines, &config.defaults, &node_info, seed_index);
|
||||
println!();
|
||||
}
|
||||
|
||||
// Phase 6: Report
|
||||
println!("=== Phase 6: Cluster status ===\n");
|
||||
print_cluster_status(&config.machines, &config.defaults);
|
||||
}
|
||||
|
||||
fn print_cluster_status(machines: &[MachineConfig], defaults: &DeployDefaults) {
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ enum Cmd {
|
|||
#[arg(long)]
|
||||
docker: bool,
|
||||
|
||||
/// Path to deploy config file
|
||||
#[arg(long, default_value = ".deploy/deploy.toml")]
|
||||
config: String,
|
||||
/// Path to deploy config file [default: .deploy/deploy.toml or .deploy/docker.toml]
|
||||
#[arg(long)]
|
||||
config: Option<String>,
|
||||
|
||||
/// Skip Docker image build (use existing archive)
|
||||
#[arg(long)]
|
||||
|
|
@ -931,13 +931,13 @@ fn main() {
|
|||
Cmd::InitNode { role, dir } => run_init_node(&role, dir.as_deref()),
|
||||
Cmd::GenPeers { dirs } => run_gen_peers(&dirs),
|
||||
Cmd::Deploy { docker, config, skip_build, skip_verify, skip_peers } => {
|
||||
let config = config.unwrap_or_else(|| {
|
||||
if docker { ".deploy/docker.toml" } else { ".deploy/deploy.toml" }.into()
|
||||
});
|
||||
if docker {
|
||||
deploy::run_deploy(&root, &config, skip_build, skip_verify, skip_peers);
|
||||
} else {
|
||||
eprintln!("Unimplemented. In the future `cargo xtask deploy` will take a \
|
||||
`deploy.toml` config, and attempt to deploy/update the nodes provided \
|
||||
in the config.");
|
||||
std::process::exit(1);
|
||||
deploy::run_native_deploy(&root, &config, skip_build, skip_verify, skip_peers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue