Skip to content

Commit 149af97

Browse files
committed
test(integration): full Docker-based integration test suite
160+ shell tests covering: messaging, tasks, files, pub/sub, trust, networks, policies, gateway, NAT, chaos/loss, security, observability, race conditions, durability, and multi-topology scenarios (ring4, star5, splitbrain, mesh). Includes docker-compose topologies, helper libraries, and a TEST-PLAN.md.
1 parent e8f1035 commit 149af97

190 files changed

Lines changed: 19505 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

tests/integration/COVERAGE-REPORT.md

Lines changed: 524 additions & 0 deletions
Large diffs are not rendered by default.

tests/integration/Dockerfile.multi

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
FROM golang:1.25-bookworm AS builder
2+
WORKDIR /workspace
3+
COPY . .
4+
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/pilot-rendezvous ./cmd/rendezvous && \
5+
CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/pilot-daemon ./cmd/daemon && \
6+
CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/pilotctl ./cmd/pilotctl && \
7+
CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/pilot-gateway ./cmd/gateway
8+
9+
FROM debian:bookworm-slim
10+
RUN apt-get update && apt-get install -y --no-install-recommends \
11+
bash curl jq procps ca-certificates iproute2 python3 xxd \
12+
&& rm -rf /var/lib/apt/lists/*
13+
# Note: Chunk D security tests also need tcpdump for frame capture; they
14+
# install it on-demand via ensure_tcpdump() in sec_helpers.sh (apt-get
15+
# install -y tcpdump) so we don't bloat every agent image with it.
16+
COPY --from=builder /bin/pilot-rendezvous /usr/local/bin/
17+
COPY --from=builder /bin/pilot-daemon /usr/local/bin/
18+
COPY --from=builder /bin/pilotctl /usr/local/bin/
19+
COPY --from=builder /bin/pilot-gateway /usr/local/bin/
20+
RUN mkdir -p /root/.pilot/inbox /root/.pilot/received
21+
COPY tests/integration/test_p2p.sh /tests/
22+
# Security-test injector + helpers (Chunk D). Kept alongside /tests so
23+
# raw_udp.py can be invoked as `python3 /tests/raw_udp.py ...` from any
24+
# service that has python3 on PATH.
25+
COPY tests/integration/raw_udp.py /tests/
26+
COPY tests/integration/sec_helpers.sh /tests/
27+
RUN chmod +x /tests/test_p2p.sh /tests/raw_udp.py
28+
WORKDIR /tests
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM alpine:3.20
2+
RUN apk add --no-cache iptables ip6tables iproute2 bash
3+
# Entrypoint is supplied per-compose-service via `command:` — the gateway
4+
# containers set up masquerading rules keyed to a `NAT_MODE` env var:
5+
# - cone : stateful NAT (Linux default; behaves ~ port-restricted cone)
6+
# - symmetric : force per-destination source-port randomisation
7+
# Usage (from compose):
8+
# command: ["/bin/bash", "/nat-gw.sh"]
9+
COPY nat-gw.sh /nat-gw.sh
10+
RUN chmod +x /nat-gw.sh

tests/integration/TEST-PLAN.md

Lines changed: 311 additions & 0 deletions
Large diffs are not rendered by default.

tests/integration/chaos_helpers.sh

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/bin/bash
2+
# chaos_helpers.sh — sourceable helpers that apply tc netem + iptables
3+
# chaos inside the compose agent containers.
4+
#
5+
# Usage:
6+
# source chaos_helpers.sh
7+
# DC="docker compose -f docker-compose.multi.yml -f docker-compose.multi.chaos.yml"
8+
# apply_loss agent-b 30
9+
# ...
10+
# strip_chaos agent-b
11+
#
12+
# All helpers take the service name (agent-a / agent-b / rendezvous) as
13+
# the first argument and shell out to `$DC exec -T`. The overlay compose
14+
# file `docker-compose.multi.chaos.yml` grants NET_ADMIN; without it
15+
# every one of these will fail with "Operation not permitted".
16+
#
17+
# Helpers are idempotent in the sense that strip_chaos is safe to call
18+
# even when no qdisc is present, and iptables rules are keyed on
19+
# --comment pilot-chaos so repeated partition/heal don't leak state.
20+
#
21+
# DC env var MUST be set by the caller — we do not hardcode the compose
22+
# file list so tests can compose additional overlays (webhooks, etc.).
23+
24+
: "${DC:?chaos_helpers: caller must export DC (docker compose -f ...)}"
25+
26+
# Apply symmetric packet loss on a service's eth0 (root qdisc).
27+
# apply_loss <service> <percent>
28+
apply_loss() {
29+
local svc="$1" pct="$2"
30+
# Clean any previous root qdisc first so repeated calls work.
31+
$DC exec -T "$svc" tc qdisc del dev eth0 root 2>/dev/null || true
32+
$DC exec -T "$svc" tc qdisc add dev eth0 root netem loss "${pct}%"
33+
}
34+
35+
# Reorder pct% of packets with a 50% correlation (default netem shape).
36+
# apply_reorder <service> <percent>
37+
apply_reorder() {
38+
local svc="$1" pct="$2"
39+
$DC exec -T "$svc" tc qdisc del dev eth0 root 2>/dev/null || true
40+
# netem reorder requires a nonzero delay to actually reorder packets
41+
# (else there is no queue to shuffle). 10ms is fine for local tests.
42+
$DC exec -T "$svc" tc qdisc add dev eth0 root netem delay 10ms reorder "${pct}%" 50%
43+
}
44+
45+
# Add fixed delay (ms) with +/-50ms jitter.
46+
# apply_delay <service> <ms>
47+
apply_delay() {
48+
local svc="$1" ms="$2"
49+
$DC exec -T "$svc" tc qdisc del dev eth0 root 2>/dev/null || true
50+
$DC exec -T "$svc" tc qdisc add dev eth0 root netem delay "${ms}ms" 50ms distribution normal
51+
}
52+
53+
# Strip any netem qdisc AND iptables chaos rules from a service.
54+
# strip_chaos <service>
55+
strip_chaos() {
56+
local svc="$1"
57+
$DC exec -T "$svc" tc qdisc del dev eth0 root 2>/dev/null || true
58+
# Clear any partition rules we may have inserted.
59+
# We tag all of our rules with --comment pilot-chaos for easy removal.
60+
$DC exec -T "$svc" bash -c '
61+
while iptables -C INPUT -m comment --comment pilot-chaos -j DROP 2>/dev/null; do
62+
iptables -D INPUT -m comment --comment pilot-chaos -j DROP || break
63+
done
64+
while iptables -C OUTPUT -m comment --comment pilot-chaos -j DROP 2>/dev/null; do
65+
iptables -D OUTPUT -m comment --comment pilot-chaos -j DROP || break
66+
done
67+
# Src/dst-specific partition drops (dropped by remove below too).
68+
iptables -S INPUT 2>/dev/null | grep -- "--comment pilot-chaos" | while read -r line; do
69+
rule=$(echo "$line" | sed "s/^-A /-D /")
70+
iptables $rule 2>/dev/null || true
71+
done
72+
iptables -S OUTPUT 2>/dev/null | grep -- "--comment pilot-chaos" | while read -r line; do
73+
rule=$(echo "$line" | sed "s/^-A /-D /")
74+
iptables $rule 2>/dev/null || true
75+
done
76+
true
77+
' 2>/dev/null || true
78+
}
79+
80+
# Partition src from dst (one-way drop, run twice for bidirectional).
81+
# apply_partition <src_service> <dst_ip>
82+
#
83+
# Note: we must pass an IP, not a hostname, because iptables inside the
84+
# container cannot resolve compose service names reliably. Callers
85+
# typically resolve with: $DC exec -T rendezvous getent hosts agent-b
86+
apply_partition() {
87+
local src="$1" dst_ip="$2"
88+
# Drop both directions so tunnel traffic cannot sneak out on return
89+
# path. Tag with --comment pilot-chaos for strip_chaos cleanup.
90+
$DC exec -T "$src" iptables -I OUTPUT -d "$dst_ip" -m comment --comment pilot-chaos -j DROP
91+
$DC exec -T "$src" iptables -I INPUT -s "$dst_ip" -m comment --comment pilot-chaos -j DROP
92+
}
93+
94+
# Heal a partition previously applied with apply_partition.
95+
# heal_partition <src_service> <dst_ip>
96+
heal_partition() {
97+
local src="$1" dst_ip="$2"
98+
$DC exec -T "$src" iptables -D OUTPUT -d "$dst_ip" -m comment --comment pilot-chaos -j DROP 2>/dev/null || true
99+
$DC exec -T "$src" iptables -D INPUT -s "$dst_ip" -m comment --comment pilot-chaos -j DROP 2>/dev/null || true
100+
}
101+
102+
# Resolve a compose service hostname to its network IP from INSIDE the
103+
# compose network. Uses rendezvous as the resolver container (always up
104+
# in this stack). Echoes the v4 IP or empty.
105+
# resolve_service_ip <service>
106+
resolve_service_ip() {
107+
local svc="$1"
108+
$DC exec -T rendezvous getent hosts "$svc" 2>/dev/null | awk '{print $1}' | head -n1
109+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Gateway overlay. Stack with:
2+
# docker compose -f docker-compose.multi.yml -f docker-compose.multi.gateway.yml up -d
3+
#
4+
# Adds a `gateway` service that:
5+
# - runs its own pilot-daemon (so it's a full node on the overlay)
6+
# - runs pilot-gateway mapping agent-b to a local IP in 10.4.0.0/16
7+
# - exposes mapped ports on the docker network so tests can curl / nc them
8+
#
9+
# pilot-gateway today is a TCP bridge: it listens on a loopback alias for
10+
# each mapped pilot address on ports 80, 443, 1000, 1001, 1002, 7, 8080,
11+
# 8443 and forwards TCP streams over the pilot tunnel to <pilot-addr>:<port>.
12+
# It does NOT expose an HTTP/gRPC control-plane API of its own — all
13+
# operations (task submit, trust grant, polo read, pubsub, lookup, register,
14+
# rotate-key, ping) continue to flow through pilotctl / the daemon socket.
15+
# Tests therefore exercise "through the gateway" by driving pilotctl against
16+
# the gateway-side daemon, OR by hitting the mapped local IP:port with
17+
# standard TCP/HTTP tooling.
18+
#
19+
# NET_ADMIN is required so the gateway can add a loopback alias via `ip
20+
# addr add` (pkg/gateway/gateway.go addLoopbackAlias).
21+
services:
22+
gateway:
23+
build:
24+
context: ../..
25+
dockerfile: tests/integration/Dockerfile.multi
26+
hostname: gateway
27+
depends_on:
28+
rendezvous:
29+
condition: service_healthy
30+
agent-a:
31+
condition: service_started
32+
agent-b:
33+
condition: service_started
34+
networks:
35+
pilot-p2p:
36+
ipv4_address: ${PILOT_SUBNET_PREFIX:-172.29.0}.40
37+
environment:
38+
PILOT_REGISTRY: "${PILOT_SUBNET_PREFIX:-172.29.0}.10:9000"
39+
PILOT_SUBNET_PREFIX: "${PILOT_SUBNET_PREFIX:-172.29.0}"
40+
# NET_ADMIN is required by pilot-gateway to `ip addr add` a loopback
41+
# alias for each mapped local IP. Without it, the proxy listeners
42+
# never bind successfully.
43+
cap_add:
44+
- NET_ADMIN
45+
volumes:
46+
- ./results:/results
47+
# The container runs the daemon, then the gateway. The test scripts
48+
# exec into this container to drive pilotctl and curl through the
49+
# mapped IPs.
50+
command: >
51+
bash -c "
52+
mkdir -p /root/.pilot/inbox /root/.pilot/received &&
53+
pilot-daemon
54+
-registry ${PILOT_SUBNET_PREFIX:-172.29.0}.10:9000
55+
-beacon ${PILOT_SUBNET_PREFIX:-172.29.0}.10:9001
56+
-hostname gateway
57+
-identity /root/.pilot/identity.json
58+
-email gateway@p2p.test
59+
-endpoint ${PILOT_SUBNET_PREFIX:-172.29.0}.40:4000
60+
-listen :4000
61+
-public
62+
-keepalive 5s
63+
-log-level info
64+
> /tmp/gateway-daemon.log 2>&1 &
65+
DAEMON_PID=$$! ;
66+
for i in $$(seq 1 30); do
67+
if [ -S /tmp/pilot.sock ] && pilotctl --json info 2>/dev/null | jq -e '.data.node_id' >/dev/null 2>&1; then
68+
break;
69+
fi;
70+
sleep 1;
71+
done;
72+
pilot-gateway -subnet 10.4.0.0/16 -socket /tmp/pilot.sock run > /tmp/gateway.log 2>&1 &
73+
GW_PID=$$! ;
74+
echo 'gateway ready';
75+
wait $$DAEMON_PID $$GW_PID
76+
"
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# NAT overlay — places agent-a on a private bridge behind a NAT gateway,
2+
# while rendezvous + agent-b sit on the public bridge. Used by
3+
# tests/integration/test_nat_*.sh to exercise Pilot's STUN + beacon
4+
# hole-punch + relay fallback paths.
5+
#
6+
# Topology (public = 172.30.10.0/24, private-a = 172.30.11.0/24):
7+
#
8+
# agent-a (172.30.11.20) --> nat-gw (11.1 priv / 10.30 pub) --> public
9+
# ├── rendezvous (172.30.10.10)
10+
# └── agent-b (172.30.10.21)
11+
#
12+
# The nat-gw container runs iptables MASQUERADE (or random-fully SNAT
13+
# in NAT_MODE=symmetric) to translate agent-a's egress traffic. agent-a's
14+
# default route is rewritten to point at the gateway.
15+
#
16+
# Bring up:
17+
# docker compose -f docker-compose.multi.yml \
18+
# -f docker-compose.multi.nat.yml up -d
19+
# NOTE: This overlay REPLACES the network config from the base compose
20+
# because it needs two bridges. Tests run `docker compose -f
21+
# docker-compose.multi.nat.yml ...` standalone (no base overlay) for
22+
# NAT scenarios.
23+
24+
services:
25+
rendezvous:
26+
build:
27+
context: ../..
28+
dockerfile: tests/integration/Dockerfile.multi
29+
hostname: rendezvous
30+
networks:
31+
public:
32+
ipv4_address: 172.30.10.10
33+
command: >
34+
pilot-rendezvous
35+
-registry-addr 0.0.0.0:9000
36+
-beacon-addr 0.0.0.0:9001
37+
-http 0.0.0.0:8080
38+
-log-level info
39+
healthcheck:
40+
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/stats >/dev/null || exit 1"]
41+
interval: 2s
42+
timeout: 2s
43+
retries: 10
44+
45+
nat-gw:
46+
build:
47+
context: .
48+
dockerfile: Dockerfile.nat-gw
49+
hostname: nat-gw
50+
cap_add:
51+
- NET_ADMIN
52+
sysctls:
53+
- net.ipv4.ip_forward=1
54+
environment:
55+
NAT_MODE: "${NAT_MODE:-cone}"
56+
# Attach private FIRST (eth0) then public (eth1). Docker names
57+
# interfaces in attachment order; the nat-gw.sh script reads
58+
# PUBLIC_IFACE / PRIVATE_IFACE env.
59+
networks:
60+
private-a:
61+
ipv4_address: 172.30.11.2
62+
public:
63+
ipv4_address: 172.30.10.30
64+
command: ["/bin/bash", "/nat-gw.sh"]
65+
66+
agent-a:
67+
build:
68+
context: ../..
69+
dockerfile: tests/integration/Dockerfile.multi
70+
hostname: agent-a
71+
depends_on:
72+
rendezvous:
73+
condition: service_healthy
74+
nat-gw:
75+
condition: service_started
76+
cap_add:
77+
- NET_ADMIN
78+
networks:
79+
private-a:
80+
ipv4_address: 172.30.11.20
81+
environment:
82+
PILOT_REGISTRY: "172.30.10.10:9000"
83+
# Fix the default route to egress through the NAT gateway, then
84+
# exec the daemon. Using array form avoids YAML folding eating
85+
# flag tokens in the flow-scalar bash -c '…' path.
86+
entrypoint:
87+
- /bin/bash
88+
- -c
89+
- |
90+
set -e
91+
ip route del default 2>/dev/null || true
92+
ip route add default via 172.30.11.2
93+
echo "[agent-a] routes:"
94+
ip route
95+
exec pilot-daemon \
96+
-registry 172.30.10.10:9000 \
97+
-beacon 172.30.10.10:9001 \
98+
-hostname agent-a \
99+
-identity /root/.pilot/identity.json \
100+
-email agent-a@nat.test \
101+
-listen :4000 \
102+
-keepalive 5s \
103+
-log-level info
104+
command: []
105+
106+
agent-b:
107+
build:
108+
context: ../..
109+
dockerfile: tests/integration/Dockerfile.multi
110+
hostname: agent-b
111+
depends_on:
112+
rendezvous:
113+
condition: service_healthy
114+
networks:
115+
public:
116+
ipv4_address: 172.30.10.21
117+
environment:
118+
PILOT_REGISTRY: "172.30.10.10:9000"
119+
command: >
120+
pilot-daemon
121+
-registry 172.30.10.10:9000
122+
-beacon 172.30.10.10:9001
123+
-hostname agent-b
124+
-identity /root/.pilot/identity.json
125+
-email agent-b@nat.test
126+
-endpoint 172.30.10.21:4000
127+
-listen :4000
128+
-public
129+
-keepalive 5s
130+
-log-level info
131+
132+
networks:
133+
public:
134+
driver: bridge
135+
ipam:
136+
config:
137+
- subnet: 172.30.10.0/24
138+
private-a:
139+
driver: bridge
140+
# `internal: true` would forbid the bridge from NATing out via the
141+
# host. We WANT that forbidden — the only egress path for agent-a
142+
# is through nat-gw. But Docker's built-in NAT on a non-internal
143+
# bridge would let agent-a bypass the gateway. So we mark private-a
144+
# internal; agent-a reaches the public subnet only via 172.30.11.2.
145+
internal: true
146+
ipam:
147+
config:
148+
- subnet: 172.30.11.0/24

0 commit comments

Comments
 (0)