swactor-development-history/distribution/DEPLOY_REGRESSION_TESTS.md

418 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |