# 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, // Per-node, indexed by node_idx pub relay_nodes: Vec, // 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` — 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 |